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:
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
80
src/index.ts
80
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<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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user