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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<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(
|
||||
jid: string,
|
||||
messageId: string,
|
||||
|
||||
@@ -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
|
||||
|
||||
213
src/index.ts
213
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<string, string> = {
|
||||
@@ -401,30 +418,145 @@ const STATUS_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
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 {
|
||||
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<string, typeof entries>();
|
||||
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<void> {
|
||||
@@ -440,6 +572,7 @@ async function startStatusDashboard(): Promise<void> {
|
||||
if (!ch) return;
|
||||
|
||||
try {
|
||||
await refreshChannelMeta();
|
||||
const content = buildStatusContent();
|
||||
|
||||
if (statusMessageId && ch.editMessage) {
|
||||
@@ -450,16 +583,47 @@ async function startStatusDashboard(): Promise<void> {
|
||||
}
|
||||
} 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<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> {
|
||||
if (messageLoopRunning) {
|
||||
logger.debug('Message loop already running, skipping duplicate start');
|
||||
@@ -738,6 +902,7 @@ async function main(): Promise<void> {
|
||||
queue.setProcessMessagesFn(processGroupMessages);
|
||||
recoverPendingMessages();
|
||||
await startStatusDashboard();
|
||||
await startUsageDashboard();
|
||||
startMessageLoop().catch((err) => {
|
||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
||||
process.exit(1);
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -79,6 +85,8 @@ export interface Channel {
|
||||
sendAndTrack?(jid: string, text: string): Promise<string | null>;
|
||||
// Optional: sync group/chat names from the platform.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user