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)
This commit is contained in:
Eyejoker
2026-03-13 23:42:45 +09:00
parent 27d4ea05dc
commit 7dcb3fe1e4
5 changed files with 307 additions and 32 deletions

View File

@@ -494,13 +494,38 @@ describe('DiscordChannel', () => {
// --- Attachments --- // --- Attachments ---
describe('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 () => { it('stores image attachment with placeholder', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
await channel.connect(); await channel.connect();
const attachments = new Map([ 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({ const msg = createMessage({
content: '', content: '',
@@ -512,7 +537,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith( expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456', 'dc:1234567890123456',
expect.objectContaining({ expect.objectContaining({
content: '[Image: photo.png]', content: expect.stringMatching(/^\[Image: .+\.png\]$/),
}), }),
); );
}); });
@@ -569,7 +594,15 @@ describe('DiscordChannel', () => {
await channel.connect(); await channel.connect();
const attachments = new Map([ 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({ const msg = createMessage({
content: 'Check this out', content: 'Check this out',
@@ -581,7 +614,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith( expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456', 'dc:1234567890123456',
expect.objectContaining({ 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(); await channel.connect();
const attachments = new Map([ 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' }], ['att2', { name: 'b.txt', contentType: 'text/plain' }],
]); ]);
const msg = createMessage({ const msg = createMessage({
@@ -605,7 +646,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith( expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456', 'dc:1234567890123456',
expect.objectContaining({ 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); await channel.sendMessage('dc:1234567890123456', longText);
expect(mockChannel.send).toHaveBeenCalledTimes(2); expect(mockChannel.send).toHaveBeenCalledTimes(2);
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000)); expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000)); content: 'x'.repeat(2000),
files: undefined,
});
expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
content: 'x'.repeat(1000),
files: undefined,
});
}); });
}); });

View File

@@ -79,6 +79,7 @@ import { registerChannel, ChannelOpts } from './registry.js';
import { import {
AgentType, AgentType,
Channel, Channel,
ChannelMeta,
OnChatMetadata, OnChatMetadata,
OnInboundMessage, OnInboundMessage,
RegisteredGroup, RegisteredGroup,
@@ -438,6 +439,59 @@ export class DiscordChannel implements Channel {
} }
} }
async getChannelMeta(jids: string[]): Promise<Map<string, ChannelMeta>> {
const result = new Map<string, ChannelMeta>();
if (!this.client) return result;
const dcJids = jids.filter((j) => j.startsWith('dc:'));
if (dcJids.length === 0) return result;
const channelIdToJid = new Map<string, string>();
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( async editMessage(
jid: string, jid: string,
messageId: string, messageId: string,

View File

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

View File

@@ -1,4 +1,6 @@
import { execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
import { import {
@@ -9,6 +11,7 @@ import {
STATUS_UPDATE_INTERVAL, STATUS_UPDATE_INTERVAL,
TIMEZONE, TIMEZONE,
TRIGGER_PATTERN, TRIGGER_PATTERN,
USAGE_UPDATE_INTERVAL,
} from './config.js'; } from './config.js';
import './channels/index.js'; import './channels/index.js';
import { import {
@@ -58,7 +61,7 @@ import {
startTokenRefreshLoop, startTokenRefreshLoop,
stopTokenRefreshLoop, stopTokenRefreshLoop,
} from './token-refresh.js'; } from './token-refresh.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
// Re-export for backwards compatibility during refactor // Re-export for backwards compatibility during refactor
@@ -383,14 +386,28 @@ async function runAgent(
} }
} }
// ── Status Dashboard ──────────────────────────────────────────── // ── Status & Usage Dashboards ───────────────────────────────────
function formatElapsed(ms: number): string { function formatElapsed(ms: number): string {
const s = Math.floor(ms / 1000); const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`; if (s < 60) return `${s}s`;
const m = Math.floor(s / 60); if (s < 3600) {
const rem = s % 60; const m = Math.floor(s / 60);
return `${m}m${rem.toString().padStart(2, '0')}s`; 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<string, string> = { const STATUS_ICONS: Record<string, string> = {
@@ -401,30 +418,145 @@ const STATUS_ICONS: Record<string, string> = {
}; };
let statusMessageId: string | null = null; let statusMessageId: string | null = null;
let usageMessageId: string | null = null;
// Cache for Discord channel metadata (position, category)
let channelMetaCache = new Map<string, ChannelMeta>();
let channelMetaLastRefresh = 0;
const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes
async function refreshChannelMeta(): Promise<void> {
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 { function buildStatusContent(): string {
const jids = Object.keys(registeredGroups); const jids = Object.keys(registeredGroups);
const statuses = queue.getStatuses(jids); const statuses = queue.getStatuses(jids);
const lines = statuses.map((s) => { const entries = statuses
const group = registeredGroups[s.jid]; .map((s) => ({
const name = group?.name || s.jid; status: s,
const icon = STATUS_ICONS[s.status] || '⚪'; group: registeredGroups[s.jid],
const label = meta: channelMetaCache.get(s.jid),
s.status === 'processing' }))
? `처리 중 (${formatElapsed(s.elapsedMs || 0)})` .filter((e) => e.group);
: s.status === 'idle'
? '대기 중' // Group by category
: s.status === 'waiting' const categoryMap = new Map<string, typeof entries>();
? `큐 대기 (${s.pendingTasks > 0 ? `태스크 ${s.pendingTasks}` : '메시지'})` for (const entry of entries) {
: '비활성'; const cat = entry.meta?.category || '기타';
return `${icon} **${name}** — ${label}`; 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 sections: string[] = [];
const idle = statuses.filter((s) => s.status === 'idle').length; let totalActive = 0;
const header = `**에이전트 상태** (${ASSISTANT_NAME}) — 활성 ${active} | 대기 ${idle} | 전체 ${statuses.length}`; let totalIdle = 0;
return `${header}\n\n${lines.join('\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`; 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<void> { async function startStatusDashboard(): Promise<void> {
@@ -440,6 +572,7 @@ async function startStatusDashboard(): Promise<void> {
if (!ch) return; if (!ch) return;
try { try {
await refreshChannelMeta();
const content = buildStatusContent(); const content = buildStatusContent();
if (statusMessageId && ch.editMessage) { if (statusMessageId && ch.editMessage) {
@@ -450,16 +583,47 @@ async function startStatusDashboard(): Promise<void> {
} }
} catch (err) { } catch (err) {
logger.debug({ err }, 'Status dashboard update failed'); logger.debug({ err }, 'Status dashboard update failed');
statusMessageId = null; // Reset on error, will re-create next tick statusMessageId = null;
} }
}; };
setInterval(updateStatus, STATUS_UPDATE_INTERVAL); setInterval(updateStatus, STATUS_UPDATE_INTERVAL);
// Initial send
await updateStatus(); await updateStatus();
logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started'); logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started');
} }
async function startUsageDashboard(): 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 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<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');
@@ -738,6 +902,7 @@ async function main(): Promise<void> {
queue.setProcessMessagesFn(processGroupMessages); queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages(); recoverPendingMessages();
await startStatusDashboard(); await startStatusDashboard();
await startUsageDashboard();
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);

View File

@@ -65,6 +65,12 @@ export interface TaskRunLog {
// --- Channel abstraction --- // --- Channel abstraction ---
export interface ChannelMeta {
position: number;
category: string;
categoryPosition: number;
}
export interface Channel { export interface Channel {
name: string; name: string;
connect(): Promise<void>; connect(): Promise<void>;
@@ -79,6 +85,8 @@ export interface Channel {
sendAndTrack?(jid: string, text: string): Promise<string | null>; 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>;
// Optional: get channel metadata (position, category) for ordering.
getChannelMeta?(jids: string[]): Promise<Map<string, ChannelMeta>>;
} }
// Callback type that channels use to deliver inbound messages // Callback type that channels use to deliver inbound messages