From 27d4ea05dc234ab192ceb03ca18e0cc487f8663b Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 13 Mar 2026 23:05:32 +0900 Subject: [PATCH] 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 --- src/channels/discord.test.ts | 1 + src/channels/discord.ts | 31 ++++++++++++++ src/config.ts | 4 ++ src/group-queue.ts | 56 +++++++++++++++++++++++++ src/index.ts | 80 ++++++++++++++++++++++++++++++++++++ src/types.ts | 3 ++ 6 files changed, 175 insertions(+) diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index ff11d93..4fc5687 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -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 diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 65e071d..d8344a3 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -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 { + 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 { + 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) => { diff --git a/src/config.ts b/src/config.ts index f270f28..f3d0c4c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 = diff --git a/src/group-queue.ts b/src/group-queue.ts index 141edb7..10fa6c4 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -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 { this.shuttingDown = true; diff --git a/src/index.ts b/src/index.ts index cf4c38b..7155809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = { + 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 { + 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 { if (messageLoopRunning) { logger.debug('Message loop already running, skipping duplicate start'); @@ -658,6 +737,7 @@ async function main(): Promise { }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); + await startStatusDashboard(); startMessageLoop().catch((err) => { logger.fatal({ err }, 'Message loop crashed unexpectedly'); process.exit(1); diff --git a/src/types.ts b/src/types.ts index ff95140..be7841e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,9 @@ export interface Channel { disconnect(): Promise; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; + // Optional: edit/delete messages (used by status dashboard). + editMessage?(jid: string, messageId: string, text: string): Promise; + sendAndTrack?(jid: string, text: string): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; }