From 7dcb3fe1e481983a900737914272cd2ccab55e8c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 13 Mar 2026 23:42:45 +0900 Subject: [PATCH] feat: status dashboard categories/ordering + live usage dashboard - Add getChannelMeta to Discord channel (batch guild fetch) - Status dashboard groups by Discord category, sorts by position - Add live usage dashboard (CPU, memory, disk, uptime) with message edit - Fix 4 failing discord.test.ts tests (sendMessage format, image fetch mock) --- src/channels/discord.test.ts | 63 +++++++++-- src/channels/discord.ts | 54 +++++++++ src/config.ts | 1 + src/index.ts | 213 +++++++++++++++++++++++++++++++---- src/types.ts | 8 ++ 5 files changed, 307 insertions(+), 32 deletions(-) diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 4fc5687..86e84e4 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -494,13 +494,38 @@ describe('DiscordChannel', () => { // --- Attachments --- describe('attachments', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }), + ); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + it('stores image attachment with placeholder', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); await channel.connect(); const attachments = new Map([ - ['att1', { name: 'photo.png', contentType: 'image/png' }], + [ + 'att1', + { + id: 'att1', + name: 'photo.png', + contentType: 'image/png', + url: 'https://cdn.example.com/photo.png', + }, + ], ]); const msg = createMessage({ content: '', @@ -512,7 +537,7 @@ describe('DiscordChannel', () => { expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ - content: '[Image: photo.png]', + content: expect.stringMatching(/^\[Image: .+\.png\]$/), }), ); }); @@ -569,7 +594,15 @@ describe('DiscordChannel', () => { await channel.connect(); const attachments = new Map([ - ['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }], + [ + 'att1', + { + id: 'att1', + name: 'photo.jpg', + contentType: 'image/jpeg', + url: 'https://cdn.example.com/photo.jpg', + }, + ], ]); const msg = createMessage({ content: 'Check this out', @@ -581,7 +614,7 @@ describe('DiscordChannel', () => { expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ - content: 'Check this out\n[Image: photo.jpg]', + content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/), }), ); }); @@ -592,7 +625,15 @@ describe('DiscordChannel', () => { await channel.connect(); const attachments = new Map([ - ['att1', { name: 'a.png', contentType: 'image/png' }], + [ + 'att1', + { + id: 'att1', + name: 'a.png', + contentType: 'image/png', + url: 'https://cdn.example.com/a.png', + }, + ], ['att2', { name: 'b.txt', contentType: 'text/plain' }], ]); const msg = createMessage({ @@ -605,7 +646,7 @@ describe('DiscordChannel', () => { expect(opts.onMessage).toHaveBeenCalledWith( 'dc:1234567890123456', expect.objectContaining({ - content: '[Image: a.png]\n[File: b.txt]', + content: expect.stringMatching(/^\[Image: .+\.png\]\n\[File: b\.txt\]$/), }), ); }); @@ -702,8 +743,14 @@ describe('DiscordChannel', () => { await channel.sendMessage('dc:1234567890123456', longText); expect(mockChannel.send).toHaveBeenCalledTimes(2); - expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000)); - expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000)); + expect(mockChannel.send).toHaveBeenNthCalledWith(1, { + content: 'x'.repeat(2000), + files: undefined, + }); + expect(mockChannel.send).toHaveBeenNthCalledWith(2, { + content: 'x'.repeat(1000), + files: undefined, + }); }); }); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index d8344a3..ddb5647 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -79,6 +79,7 @@ import { registerChannel, ChannelOpts } from './registry.js'; import { AgentType, Channel, + ChannelMeta, OnChatMetadata, OnInboundMessage, RegisteredGroup, @@ -438,6 +439,59 @@ export class DiscordChannel implements Channel { } } + async getChannelMeta(jids: string[]): Promise> { + const result = new Map(); + if (!this.client) return result; + + const dcJids = jids.filter((j) => j.startsWith('dc:')); + if (dcJids.length === 0) return result; + + const channelIdToJid = new Map(); + for (const jid of dcJids) { + channelIdToJid.set(jid.replace(/^dc:/, ''), jid); + } + + try { + // Fetch one channel to discover its guild, then batch-fetch all channels + const firstId = dcJids[0].replace(/^dc:/, ''); + const firstChannel = await this.client.channels.fetch(firstId); + if (!firstChannel || !('guild' in firstChannel)) return result; + + const guild = (firstChannel as TextChannel).guild; + const allChannels = await guild.channels.fetch(); + + for (const [id, channel] of allChannels) { + const jid = channelIdToJid.get(id); + if (!jid || !channel) continue; + result.set(jid, { + position: channel.position, + category: channel.parent?.name || '', + categoryPosition: channel.parent?.position ?? 999, + }); + } + } catch { + // Fallback: individual fetches + for (const jid of dcJids) { + try { + const channelId = jid.replace(/^dc:/, ''); + const channel = await this.client.channels.fetch(channelId); + if (channel && 'position' in channel) { + const tc = channel as TextChannel; + result.set(jid, { + position: tc.position, + category: tc.parent?.name || '', + categoryPosition: tc.parent?.position ?? 999, + }); + } + } catch { + /* skip inaccessible channels */ + } + } + } + + return result; + } + async editMessage( jid: string, messageId: string, diff --git a/src/config.ts b/src/config.ts index f3d0c4c..338dcaf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ export const TRIGGER_PATTERN = new RegExp( // 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 +export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/index.ts b/src/index.ts index 7155809..5bb03d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ +import { execSync } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import { @@ -9,6 +11,7 @@ import { STATUS_UPDATE_INTERVAL, TIMEZONE, TRIGGER_PATTERN, + USAGE_UPDATE_INTERVAL, } from './config.js'; import './channels/index.js'; import { @@ -58,7 +61,7 @@ import { startTokenRefreshLoop, stopTokenRefreshLoop, } from './token-refresh.js'; -import { Channel, NewMessage, RegisteredGroup } from './types.js'; +import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; // Re-export for backwards compatibility during refactor @@ -383,14 +386,28 @@ async function runAgent( } } -// ── Status Dashboard ──────────────────────────────────────────── +// ── Status & Usage Dashboards ─────────────────────────────────── 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`; + if (s < 3600) { + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}m${rem.toString().padStart(2, '0')}s`; + } + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`; + const d = Math.floor(h / 24); + const remH = h % 24; + return `${d}d${remH}h`; +} + +function formatBytes(bytes: number): string { + if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`; + if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`; + return `${(bytes / 1024).toFixed(0)}KB`; } const STATUS_ICONS: Record = { @@ -401,30 +418,145 @@ const STATUS_ICONS: Record = { }; let statusMessageId: string | null = null; +let usageMessageId: string | null = null; + +// Cache for Discord channel metadata (position, category) +let channelMetaCache = new Map(); +let channelMetaLastRefresh = 0; +const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes + +async function refreshChannelMeta(): Promise { + const now = Date.now(); + if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return; + + const ch = channels.find( + (c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta, + ); + if (!ch?.getChannelMeta) return; + + const jids = Object.keys(registeredGroups).filter((j) => + j.startsWith('dc:'), + ); + try { + channelMetaCache = await ch.getChannelMeta(jids); + channelMetaLastRefresh = now; + } catch (err) { + logger.debug({ err }, 'Failed to refresh channel metadata'); + } +} + +function getStatusLabel( + s: import('./group-queue.js').GroupStatus, +): string { + if (s.status === 'processing') + return `처리 중 (${formatElapsed(s.elapsedMs || 0)})`; + if (s.status === 'idle') return '대기 중'; + if (s.status === 'waiting') + return s.pendingTasks > 0 + ? `큐 대기 (태스크 ${s.pendingTasks}개)` + : '큐 대기 (메시지)'; + return '비활성'; +} 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 entries = statuses + .map((s) => ({ + status: s, + group: registeredGroups[s.jid], + meta: channelMetaCache.get(s.jid), + })) + .filter((e) => e.group); + + // Group by category + const categoryMap = new Map(); + for (const entry of entries) { + const cat = entry.meta?.category || '기타'; + if (!categoryMap.has(cat)) categoryMap.set(cat, []); + categoryMap.get(cat)!.push(entry); + } + + // Sort categories by position + const sortedCategories = [...categoryMap.entries()].sort((a, b) => { + const posA = a[1][0]?.meta?.categoryPosition ?? 999; + const posB = b[1][0]?.meta?.categoryPosition ?? 999; + return posA - posB; }); - 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')}_`; + const sections: string[] = []; + let totalActive = 0; + let totalIdle = 0; + let total = 0; + + for (const [catName, catEntries] of sortedCategories) { + catEntries.sort( + (a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999), + ); + + const lines = catEntries.map((e) => { + const icon = STATUS_ICONS[e.status.status] || '⚪'; + const label = getStatusLabel(e.status); + return ` ${icon} **${e.group.name}** — ${label}`; + }); + + if (channelMetaCache.size > 0 && catName !== '기타') { + sections.push(`📁 **${catName}**\n${lines.join('\n')}`); + } else { + sections.push(lines.join('\n')); + } + + totalActive += catEntries.filter( + (e) => e.status.status === 'processing', + ).length; + totalIdle += catEntries.filter( + (e) => e.status.status === 'idle', + ).length; + total += catEntries.length; + } + + const header = `**에이전트 상태** (${ASSISTANT_NAME}) — 활성 ${totalActive} | 대기 ${totalIdle} | 전체 ${total}`; + return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`; +} + +function buildUsageContent(): string { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memPercent = Math.round((usedMem / totalMem) * 100); + + const loadAvg = os.loadavg(); + const cpuCount = os.cpus().length; + + let diskInfo = '확인 불가'; + try { + const df = execSync('df -h / | tail -1', { + encoding: 'utf-8', + timeout: 5000, + }).trim(); + const parts = df.split(/\s+/); + diskInfo = `${parts[2]} / ${parts[1]} (${parts[4]})`; + } catch { + /* ignore */ + } + + const uptimeMs = process.uptime() * 1000; + const procMem = process.memoryUsage(); + + const lines = [ + `**시스템 리소스** (${ASSISTANT_NAME})`, + ``, + `💻 CPU: ${loadAvg[0].toFixed(1)} / ${cpuCount} cores (1m avg)`, + `🧠 시스템 메모리: ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memPercent}%)`, + `💾 디스크: ${diskInfo}`, + `📦 프로세스 메모리: ${formatBytes(procMem.rss)}`, + `⏱️ 업타임: ${formatElapsed(uptimeMs)}`, + ]; + + return ( + lines.join('\n') + `\n\n_${new Date().toLocaleTimeString('ko-KR')}_` + ); } async function startStatusDashboard(): Promise { @@ -440,6 +572,7 @@ async function startStatusDashboard(): Promise { if (!ch) return; try { + await refreshChannelMeta(); const content = buildStatusContent(); if (statusMessageId && ch.editMessage) { @@ -450,16 +583,47 @@ async function startStatusDashboard(): Promise { } } catch (err) { logger.debug({ err }, 'Status dashboard update failed'); - statusMessageId = null; // Reset on error, will re-create next tick + statusMessageId = null; } }; setInterval(updateStatus, STATUS_UPDATE_INTERVAL); - // Initial send await updateStatus(); logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started'); } +async function startUsageDashboard(): Promise { + if (!STATUS_CHANNEL_ID) return; + + const statusJid = `dc:${STATUS_CHANNEL_ID}`; + + const findDiscordChannel = () => + channels.find((c) => c.name.startsWith('discord') && c.isConnected()); + + const updateUsage = async () => { + const ch = findDiscordChannel(); + if (!ch) return; + + try { + const content = buildUsageContent(); + + if (usageMessageId && ch.editMessage) { + await ch.editMessage(statusJid, usageMessageId, content); + } else if (ch.sendAndTrack) { + const id = await ch.sendAndTrack(statusJid, content); + if (id) usageMessageId = id; + } + } catch (err) { + logger.debug({ err }, 'Usage dashboard update failed'); + usageMessageId = null; + } + }; + + setInterval(updateUsage, USAGE_UPDATE_INTERVAL); + await updateUsage(); + logger.info('Usage dashboard started'); +} + async function startMessageLoop(): Promise { if (messageLoopRunning) { logger.debug('Message loop already running, skipping duplicate start'); @@ -738,6 +902,7 @@ async function main(): Promise { queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); await startStatusDashboard(); + await startUsageDashboard(); startMessageLoop().catch((err) => { logger.fatal({ err }, 'Message loop crashed unexpectedly'); process.exit(1); diff --git a/src/types.ts b/src/types.ts index be7841e..b646ee2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,12 @@ export interface TaskRunLog { // --- Channel abstraction --- +export interface ChannelMeta { + position: number; + category: string; + categoryPosition: number; +} + export interface Channel { name: string; connect(): Promise; @@ -79,6 +85,8 @@ export interface Channel { sendAndTrack?(jid: string, text: string): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; + // Optional: get channel metadata (position, category) for ordering. + getChannelMeta?(jids: string[]): Promise>; } // Callback type that channels use to deliver inbound messages