feat: add status dashboard for live agent monitoring

Dedicated Discord channel shows per-group agent status with live
elapsed time. Single message is edited every 10s instead of spamming.

- GroupQueue tracks startedAt timestamps and exposes getStatuses()
- Channel interface gets editMessage/sendAndTrack for message editing
- STATUS_CHANNEL_ID env var to configure the dashboard channel
- Shows processing/idle/waiting/inactive per registered group
This commit is contained in:
Eyejoker
2026-03-13 23:05:32 +09:00
parent 6a65136ff2
commit 27d4ea05dc
6 changed files with 175 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
DATA_DIR: '/tmp/nanoclaw-test-data',
}));
// Mock logger

View File

@@ -423,6 +423,37 @@ export class DiscordChannel implements Channel {
await sendOnce();
this.typingIntervals.set(jid, setInterval(sendOnce, 8000));
}
async sendAndTrack(jid: string, text: string): Promise<string | null> {
if (!this.client) return null;
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) return null;
const msg = await (channel as TextChannel).send(text);
return msg.id;
} catch (err) {
logger.error({ jid, err }, 'Failed to send tracked Discord message');
return null;
}
}
async editMessage(
jid: string,
messageId: string,
text: string,
): Promise<void> {
if (!this.client) return;
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('messages' in channel)) return;
const msg = await (channel as TextChannel).messages.fetch(messageId);
await msg.edit(text);
} catch (err) {
logger.debug({ jid, messageId, err }, 'Failed to edit Discord message');
}
}
}
registerChannel('discord', (opts: ChannelOpts) => {

View File

@@ -56,6 +56,10 @@ export const TRIGGER_PATTERN = new RegExp(
'i',
);
// Status dashboard: Discord channel ID for live agent status updates
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE =

View File

@@ -25,6 +25,15 @@ interface GroupState {
processName: string | null;
groupFolder: string | null;
retryCount: number;
startedAt: number | null;
}
export interface GroupStatus {
jid: string;
status: 'processing' | 'idle' | 'waiting' | 'inactive';
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
}
export class GroupQueue {
@@ -49,6 +58,7 @@ export class GroupQueue {
processName: null,
groupFolder: null,
retryCount: 0,
startedAt: null,
};
this.groups.set(groupJid, state);
}
@@ -207,6 +217,7 @@ export class GroupQueue {
state.idleWaiting = false;
state.isTaskProcess = false;
state.pendingMessages = false;
state.startedAt = Date.now();
this.activeCount++;
logger.debug(
@@ -228,6 +239,7 @@ export class GroupQueue {
this.scheduleRetry(groupJid, state);
} finally {
state.active = false;
state.startedAt = null;
state.process = null;
state.processName = null;
state.groupFolder = null;
@@ -242,6 +254,7 @@ export class GroupQueue {
state.idleWaiting = false;
state.isTaskProcess = true;
state.runningTaskId = task.id;
state.startedAt = Date.now();
this.activeCount++;
logger.debug(
@@ -257,6 +270,7 @@ export class GroupQueue {
state.active = false;
state.isTaskProcess = false;
state.runningTaskId = null;
state.startedAt = null;
state.process = null;
state.processName = null;
state.groupFolder = null;
@@ -349,6 +363,48 @@ export class GroupQueue {
}
}
/**
* Return current status of all known groups.
* Only includes groups that have been seen (registered or had activity).
*/
getStatuses(registeredJids?: string[]): GroupStatus[] {
const jids = registeredJids ?? [...this.groups.keys()];
const now = Date.now();
return jids.map((jid) => {
const state = this.groups.get(jid);
if (!state) {
return {
jid,
status: 'inactive' as const,
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
};
}
let status: GroupStatus['status'];
if (state.active && !state.idleWaiting) {
status = 'processing';
} else if (state.active && state.idleWaiting) {
status = 'idle';
} else if (
state.pendingMessages ||
state.pendingTasks.length > 0 ||
this.waitingGroups.includes(jid)
) {
status = 'waiting';
} else {
status = 'inactive';
}
return {
jid,
status,
elapsedMs: state.startedAt ? now - state.startedAt : null,
pendingMessages: state.pendingMessages,
pendingTasks: state.pendingTasks.length,
};
});
}
async shutdown(_gracePeriodMs: number): Promise<void> {
this.shuttingDown = true;

View File

@@ -5,6 +5,8 @@ import {
ASSISTANT_NAME,
IDLE_TIMEOUT,
POLL_INTERVAL,
STATUS_CHANNEL_ID,
STATUS_UPDATE_INTERVAL,
TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
@@ -381,6 +383,83 @@ async function runAgent(
}
}
// ── Status Dashboard ────────────────────────────────────────────
function formatElapsed(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}m${rem.toString().padStart(2, '0')}s`;
}
const STATUS_ICONS: Record<string, string> = {
processing: '🟡',
idle: '🟢',
waiting: '🔵',
inactive: '⚪',
};
let statusMessageId: string | null = null;
function buildStatusContent(): string {
const jids = Object.keys(registeredGroups);
const statuses = queue.getStatuses(jids);
const lines = statuses.map((s) => {
const group = registeredGroups[s.jid];
const name = group?.name || s.jid;
const icon = STATUS_ICONS[s.status] || '⚪';
const label =
s.status === 'processing'
? `처리 중 (${formatElapsed(s.elapsedMs || 0)})`
: s.status === 'idle'
? '대기 중'
: s.status === 'waiting'
? `큐 대기 (${s.pendingTasks > 0 ? `태스크 ${s.pendingTasks}` : '메시지'})`
: '비활성';
return `${icon} **${name}** — ${label}`;
});
const active = statuses.filter((s) => s.status === 'processing').length;
const idle = statuses.filter((s) => s.status === 'idle').length;
const header = `**에이전트 상태** (${ASSISTANT_NAME}) — 활성 ${active} | 대기 ${idle} | 전체 ${statuses.length}`;
return `${header}\n\n${lines.join('\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`;
}
async function startStatusDashboard(): Promise<void> {
if (!STATUS_CHANNEL_ID) return;
const statusJid = `dc:${STATUS_CHANNEL_ID}`;
const findDiscordChannel = () =>
channels.find((c) => c.name.startsWith('discord') && c.isConnected());
const updateStatus = async () => {
const ch = findDiscordChannel();
if (!ch) return;
try {
const content = buildStatusContent();
if (statusMessageId && ch.editMessage) {
await ch.editMessage(statusJid, statusMessageId, content);
} else if (ch.sendAndTrack) {
const id = await ch.sendAndTrack(statusJid, content);
if (id) statusMessageId = id;
}
} catch (err) {
logger.debug({ err }, 'Status dashboard update failed');
statusMessageId = null; // Reset on error, will re-create next tick
}
};
setInterval(updateStatus, STATUS_UPDATE_INTERVAL);
// Initial send
await updateStatus();
logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started');
}
async function startMessageLoop(): Promise<void> {
if (messageLoopRunning) {
logger.debug('Message loop already running, skipping duplicate start');
@@ -658,6 +737,7 @@ async function main(): Promise<void> {
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
await startStatusDashboard();
startMessageLoop().catch((err) => {
logger.fatal({ err }, 'Message loop crashed unexpectedly');
process.exit(1);

View File

@@ -74,6 +74,9 @@ export interface Channel {
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;
// Optional: edit/delete messages (used by status dashboard).
editMessage?(jid: string, messageId: string, text: string): Promise<void>;
sendAndTrack?(jid: string, text: string): Promise<string | null>;
// Optional: sync group/chat names from the platform.
syncGroups?(force: boolean): Promise<void>;
}