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', () => ({
|
vi.mock('../config.js', () => ({
|
||||||
ASSISTANT_NAME: 'Andy',
|
ASSISTANT_NAME: 'Andy',
|
||||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||||
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger
|
// Mock logger
|
||||||
|
|||||||
@@ -423,6 +423,37 @@ export class DiscordChannel implements Channel {
|
|||||||
await sendOnce();
|
await sendOnce();
|
||||||
this.typingIntervals.set(jid, setInterval(sendOnce, 8000));
|
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) => {
|
registerChannel('discord', (opts: ChannelOpts) => {
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export const TRIGGER_PATTERN = new RegExp(
|
|||||||
'i',
|
'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.)
|
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||||
// Uses system timezone by default
|
// Uses system timezone by default
|
||||||
export const TIMEZONE =
|
export const TIMEZONE =
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ interface GroupState {
|
|||||||
processName: string | null;
|
processName: string | null;
|
||||||
groupFolder: string | null;
|
groupFolder: string | null;
|
||||||
retryCount: number;
|
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 {
|
export class GroupQueue {
|
||||||
@@ -49,6 +58,7 @@ export class GroupQueue {
|
|||||||
processName: null,
|
processName: null,
|
||||||
groupFolder: null,
|
groupFolder: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
startedAt: null,
|
||||||
};
|
};
|
||||||
this.groups.set(groupJid, state);
|
this.groups.set(groupJid, state);
|
||||||
}
|
}
|
||||||
@@ -207,6 +217,7 @@ export class GroupQueue {
|
|||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
state.isTaskProcess = false;
|
state.isTaskProcess = false;
|
||||||
state.pendingMessages = false;
|
state.pendingMessages = false;
|
||||||
|
state.startedAt = Date.now();
|
||||||
this.activeCount++;
|
this.activeCount++;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -228,6 +239,7 @@ export class GroupQueue {
|
|||||||
this.scheduleRetry(groupJid, state);
|
this.scheduleRetry(groupJid, state);
|
||||||
} finally {
|
} finally {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
|
state.startedAt = null;
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.processName = null;
|
state.processName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
@@ -242,6 +254,7 @@ export class GroupQueue {
|
|||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
state.isTaskProcess = true;
|
state.isTaskProcess = true;
|
||||||
state.runningTaskId = task.id;
|
state.runningTaskId = task.id;
|
||||||
|
state.startedAt = Date.now();
|
||||||
this.activeCount++;
|
this.activeCount++;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -257,6 +270,7 @@ export class GroupQueue {
|
|||||||
state.active = false;
|
state.active = false;
|
||||||
state.isTaskProcess = false;
|
state.isTaskProcess = false;
|
||||||
state.runningTaskId = null;
|
state.runningTaskId = null;
|
||||||
|
state.startedAt = null;
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.processName = null;
|
state.processName = null;
|
||||||
state.groupFolder = 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> {
|
async shutdown(_gracePeriodMs: number): Promise<void> {
|
||||||
this.shuttingDown = true;
|
this.shuttingDown = true;
|
||||||
|
|
||||||
|
|||||||
80
src/index.ts
80
src/index.ts
@@ -5,6 +5,8 @@ import {
|
|||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
|
STATUS_CHANNEL_ID,
|
||||||
|
STATUS_UPDATE_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
} from './config.js';
|
} 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> {
|
async function startMessageLoop(): Promise<void> {
|
||||||
if (messageLoopRunning) {
|
if (messageLoopRunning) {
|
||||||
logger.debug('Message loop already running, skipping duplicate start');
|
logger.debug('Message loop already running, skipping duplicate start');
|
||||||
@@ -658,6 +737,7 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
queue.setProcessMessagesFn(processGroupMessages);
|
queue.setProcessMessagesFn(processGroupMessages);
|
||||||
recoverPendingMessages();
|
recoverPendingMessages();
|
||||||
|
await startStatusDashboard();
|
||||||
startMessageLoop().catch((err) => {
|
startMessageLoop().catch((err) => {
|
||||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export interface Channel {
|
|||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
// Optional: typing indicator. Channels that support it implement it.
|
// Optional: typing indicator. Channels that support it implement it.
|
||||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
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.
|
// Optional: sync group/chat names from the platform.
|
||||||
syncGroups?(force: boolean): Promise<void>;
|
syncGroups?(force: boolean): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user