@jungjaehoon/mama-os 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agent/agent-loop.d.ts +1 -8
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +44 -159
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/claude-cli-wrapper.d.ts +6 -0
  7. package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
  8. package/dist/agent/claude-cli-wrapper.js +6 -0
  9. package/dist/agent/claude-cli-wrapper.js.map +1 -1
  10. package/dist/agent/codex-mcp-process.d.ts +85 -0
  11. package/dist/agent/codex-mcp-process.d.ts.map +1 -0
  12. package/dist/agent/codex-mcp-process.js +357 -0
  13. package/dist/agent/codex-mcp-process.js.map +1 -0
  14. package/dist/agent/session-pool.d.ts +17 -2
  15. package/dist/agent/session-pool.d.ts.map +1 -1
  16. package/dist/agent/session-pool.js +51 -26
  17. package/dist/agent/session-pool.js.map +1 -1
  18. package/dist/agent/types.d.ts +9 -24
  19. package/dist/agent/types.d.ts.map +1 -1
  20. package/dist/agent/types.js.map +1 -1
  21. package/dist/api/graph-api.d.ts.map +1 -1
  22. package/dist/api/graph-api.js +133 -45
  23. package/dist/api/graph-api.js.map +1 -1
  24. package/dist/cli/commands/init.d.ts +1 -1
  25. package/dist/cli/commands/init.d.ts.map +1 -1
  26. package/dist/cli/commands/init.js +14 -25
  27. package/dist/cli/commands/init.js.map +1 -1
  28. package/dist/cli/commands/run.d.ts.map +1 -1
  29. package/dist/cli/commands/run.js +3 -10
  30. package/dist/cli/commands/run.js.map +1 -1
  31. package/dist/cli/commands/start.d.ts.map +1 -1
  32. package/dist/cli/commands/start.js +143 -54
  33. package/dist/cli/commands/start.js.map +1 -1
  34. package/dist/cli/commands/status.d.ts.map +1 -1
  35. package/dist/cli/commands/status.js +2 -7
  36. package/dist/cli/commands/status.js.map +1 -1
  37. package/dist/cli/config/config-manager.d.ts.map +1 -1
  38. package/dist/cli/config/config-manager.js +9 -17
  39. package/dist/cli/config/config-manager.js.map +1 -1
  40. package/dist/cli/config/types.d.ts +19 -25
  41. package/dist/cli/config/types.d.ts.map +1 -1
  42. package/dist/cli/config/types.js.map +1 -1
  43. package/dist/cli/index.js +2 -2
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/gateways/context-injector.d.ts.map +1 -1
  46. package/dist/gateways/context-injector.js +6 -3
  47. package/dist/gateways/context-injector.js.map +1 -1
  48. package/dist/gateways/discord.d.ts +4 -0
  49. package/dist/gateways/discord.d.ts.map +1 -1
  50. package/dist/gateways/discord.js +39 -16
  51. package/dist/gateways/discord.js.map +1 -1
  52. package/dist/gateways/message-router.d.ts +6 -1
  53. package/dist/gateways/message-router.d.ts.map +1 -1
  54. package/dist/gateways/message-router.js +92 -7
  55. package/dist/gateways/message-router.js.map +1 -1
  56. package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
  57. package/dist/multi-agent/agent-process-manager.js +36 -9
  58. package/dist/multi-agent/agent-process-manager.js.map +1 -1
  59. package/dist/multi-agent/runtime-process.d.ts +4 -4
  60. package/dist/multi-agent/runtime-process.d.ts.map +1 -1
  61. package/dist/multi-agent/runtime-process.js +9 -20
  62. package/dist/multi-agent/runtime-process.js.map +1 -1
  63. package/dist/multi-agent/types.d.ts +13 -8
  64. package/dist/multi-agent/types.d.ts.map +1 -1
  65. package/dist/multi-agent/types.js.map +1 -1
  66. package/dist/setup/setup-prompt.d.ts +1 -1
  67. package/dist/setup/setup-prompt.d.ts.map +1 -1
  68. package/dist/setup/setup-prompt.js +19 -0
  69. package/dist/setup/setup-prompt.js.map +1 -1
  70. package/dist/setup/setup-server.d.ts.map +1 -1
  71. package/dist/setup/setup-server.js +39 -16
  72. package/dist/setup/setup-server.js.map +1 -1
  73. package/dist/skills/skill-registry.d.ts.map +1 -1
  74. package/dist/skills/skill-registry.js +5 -2
  75. package/dist/skills/skill-registry.js.map +1 -1
  76. package/package.json +5 -3
  77. package/public/setup.html +12 -1
  78. package/public/viewer/js/modules/chat.js +1760 -1976
  79. package/public/viewer/js/modules/dashboard.js +613 -695
  80. package/public/viewer/js/modules/graph.js +857 -970
  81. package/public/viewer/js/modules/memory.js +357 -312
  82. package/public/viewer/js/modules/settings.js +1009 -1026
  83. package/public/viewer/js/modules/skills.js +336 -355
  84. package/public/viewer/js/utils/api.js +255 -255
  85. package/public/viewer/js/utils/debug-logger.js +20 -26
  86. package/public/viewer/js/utils/dom.js +73 -60
  87. package/public/viewer/js/utils/format.js +182 -228
  88. package/public/viewer/js/utils/markdown.js +40 -0
  89. package/public/viewer/src/modules/chat.ts +2258 -0
  90. package/public/viewer/src/modules/dashboard.ts +1052 -0
  91. package/public/viewer/src/modules/graph.ts +1080 -0
  92. package/public/viewer/src/modules/memory.ts +453 -0
  93. package/public/viewer/src/modules/settings.ts +1398 -0
  94. package/public/viewer/src/modules/skills.ts +457 -0
  95. package/public/viewer/src/types/global.d.ts +168 -0
  96. package/public/viewer/src/utils/api.ts +650 -0
  97. package/public/viewer/src/utils/debug-logger.ts +36 -0
  98. package/public/viewer/src/utils/dom.ts +138 -0
  99. package/public/viewer/src/utils/format.ts +331 -0
  100. package/public/viewer/src/utils/markdown.ts +46 -0
  101. package/public/viewer/tsconfig.viewer.json +18 -0
  102. package/public/viewer/viewer.html +214 -311
  103. package/dist/agent/codex-cli-wrapper.d.ts +0 -85
  104. package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
  105. package/dist/agent/codex-cli-wrapper.js +0 -295
  106. package/dist/agent/codex-cli-wrapper.js.map +0 -1
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * Dashboard Module - MAMA OS Dashboard
3
+ * @module modules/dashboard
4
+ * @version 1.0.0
5
+ *
6
+ * Handles Dashboard tab functionality including:
7
+ * - Gateway status display (Discord, Slack, Telegram, Chatwork)
8
+ * - Memory statistics
9
+ * - Agent configuration display
10
+ * - Top topics
11
+ */
12
+
13
+ /* eslint-env browser */
14
+
15
+ import { escapeAttr, escapeHtml, getElementByIdOrNull, getErrorMessage } from '../utils/dom.js';
16
+ import { formatModelName } from '../utils/format.js';
17
+ import {
18
+ API,
19
+ type McpServer,
20
+ type McpServersResponse,
21
+ type MultiAgentAgent,
22
+ type MultiAgentDashboardStatus,
23
+ type TokenSummaryResponse,
24
+ type TokensByAgentResponse,
25
+ } from '../utils/api.js';
26
+ import { DebugLogger } from '../utils/debug-logger.js';
27
+
28
+ const logger = new DebugLogger('Dashboard');
29
+
30
+ type DashboardGateway = {
31
+ enabled?: boolean;
32
+ configured?: boolean;
33
+ channel?: string;
34
+ chats?: string[];
35
+ rooms?: string[];
36
+ };
37
+
38
+ type DashboardSessionChannel = {
39
+ source: string;
40
+ channelId: string;
41
+ channelName?: string;
42
+ messageCount?: number;
43
+ lastActive?: number | string;
44
+ };
45
+
46
+ type DashboardSessions = {
47
+ total: number;
48
+ bySource: Record<string, number>;
49
+ channels: DashboardSessionChannel[];
50
+ };
51
+
52
+ type DashboardMemoryStats = {
53
+ total?: number;
54
+ thisWeek?: number;
55
+ thisMonth?: number;
56
+ checkpoints?: number;
57
+ topTopics?: Array<{ topic: string; count: number }>;
58
+ };
59
+
60
+ type DashboardAgentConfig = {
61
+ model?: string;
62
+ maxTurns?: number;
63
+ timeout?: number;
64
+ };
65
+
66
+ type DashboardData = {
67
+ gateways?: Record<string, DashboardGateway>;
68
+ sessions?: DashboardSessions;
69
+ memory?: DashboardMemoryStats;
70
+ agent?: DashboardAgentConfig;
71
+ heartbeat?: {
72
+ enabled?: boolean;
73
+ interval?: number;
74
+ quiet_start?: number;
75
+ quiet_end?: number;
76
+ quietStart?: number;
77
+ quietEnd?: number;
78
+ };
79
+ };
80
+
81
+ type MultiAgentDashboardData = {
82
+ enabled: boolean;
83
+ agents: MultiAgentAgent[];
84
+ activeChains?: number;
85
+ };
86
+
87
+ type DashboardDelegation = {
88
+ status: string;
89
+ claimedBy?: string;
90
+ description?: string;
91
+ wave?: number;
92
+ completedAt?: number | string;
93
+ claimedAt?: number | string;
94
+ };
95
+
96
+ type DashboardDelegationsData = {
97
+ delegations: DashboardDelegation[];
98
+ count?: number;
99
+ };
100
+
101
+ type DashboardTokenData = {
102
+ summary?: TokenSummaryResponse;
103
+ byAgent?: TokensByAgentResponse;
104
+ };
105
+
106
+ /**
107
+ * Dashboard Module Class
108
+ */
109
+ export class DashboardModule {
110
+ data: DashboardData | null = null;
111
+ updateInterval: ReturnType<typeof setInterval> | null = null;
112
+ initialized = false;
113
+ onCronClick: ((event: MouseEvent) => void) | null = null;
114
+ mcpServers: McpServer[] = [];
115
+ multiAgentData: MultiAgentDashboardData = { enabled: false, agents: [] };
116
+ delegationsData: DashboardDelegationsData = { delegations: [], count: 0 };
117
+ cronData: {
118
+ jobs?: Array<{
119
+ id: string;
120
+ name: string;
121
+ schedule?: string;
122
+ cron?: string;
123
+ nextRun?: string;
124
+ enabled?: boolean;
125
+ }>;
126
+ } | null = null;
127
+ tokenData: DashboardTokenData | null = null;
128
+
129
+ constructor() {
130
+ this.data = null;
131
+ this.updateInterval = null;
132
+ this.initialized = false;
133
+ this.onCronClick = null;
134
+ this.mcpServers = [];
135
+ }
136
+
137
+ /**
138
+ * Initialize dashboard
139
+ */
140
+ async init(): Promise<void> {
141
+ if (this.initialized) {
142
+ return;
143
+ }
144
+ this.initialized = true;
145
+
146
+ // Event delegation for dashboard actions
147
+ this.onCronClick = (e: MouseEvent) => {
148
+ const target = e.target as HTMLElement | null;
149
+ if (!target) {
150
+ return;
151
+ }
152
+
153
+ const cronButton = target.closest<HTMLElement>('[data-action="run-cron"]');
154
+ if (cronButton) {
155
+ const jobId = cronButton.getAttribute('data-cron-id');
156
+ if (jobId) {
157
+ this.runCronJob(jobId);
158
+ }
159
+ return;
160
+ }
161
+
162
+ const settingsLink = target.closest<HTMLElement>('[data-action="open-settings"]');
163
+ if (settingsLink) {
164
+ const settingsTab = document.querySelector<HTMLElement>('[data-tab="settings"]');
165
+ if (settingsTab) {
166
+ settingsTab.click();
167
+ }
168
+ }
169
+ };
170
+ document.addEventListener('click', this.onCronClick);
171
+
172
+ await this.loadStatus();
173
+
174
+ // Auto-refresh every 30 seconds
175
+ this.updateInterval = setInterval(() => this.loadStatus(), 30000);
176
+ }
177
+
178
+ /**
179
+ * Load dashboard status from API
180
+ */
181
+ async loadStatus(): Promise<void> {
182
+ try {
183
+ this.data = await API.get<DashboardData>('/api/dashboard/status');
184
+
185
+ // Load multi-agent status (Sprint 3 F2)
186
+ try {
187
+ this.multiAgentData = await API.get<MultiAgentDashboardStatus>('/api/multi-agent/status');
188
+ } catch (e) {
189
+ logger.warn('[Dashboard] Multi-agent status unavailable:', e);
190
+ this.multiAgentData = { enabled: false, agents: [] };
191
+ }
192
+
193
+ // Load delegations (F4 endpoint)
194
+ try {
195
+ this.delegationsData = await API.get<DashboardDelegationsData>(
196
+ '/api/multi-agent/delegations?limit=10'
197
+ );
198
+ } catch (e) {
199
+ logger.warn('[Dashboard] Delegations unavailable:', e);
200
+ this.delegationsData = { delegations: [], count: 0 };
201
+ }
202
+
203
+ // Load cron jobs
204
+ try {
205
+ this.cronData = await API.getCronJobs();
206
+ } catch (e) {
207
+ logger.warn('[Dashboard] Cron data unavailable:', e);
208
+ this.cronData = null;
209
+ }
210
+
211
+ // Load token summary
212
+ try {
213
+ const [summary, byAgent] = await Promise.all([
214
+ API.getTokenSummary(),
215
+ API.getTokensByAgent(),
216
+ ]);
217
+ this.tokenData = { summary, byAgent };
218
+ } catch (e) {
219
+ logger.warn('[Dashboard] Token data unavailable:', e);
220
+ this.tokenData = null;
221
+ }
222
+
223
+ // Load MCP servers
224
+ await this.loadMCPServers();
225
+
226
+ this.render();
227
+ this.setStatus(`Last updated: ${new Date().toLocaleTimeString()}`);
228
+ } catch (error) {
229
+ logger.error('[Dashboard] Load error:', error);
230
+ this.setStatus(`Error: ${getErrorMessage(error)}`, 'error');
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Load MCP servers from API
236
+ */
237
+ async loadMCPServers(): Promise<void> {
238
+ try {
239
+ const data = await API.get<McpServersResponse>('/api/mcp-servers');
240
+ if (data && 'servers' in data) {
241
+ this.mcpServers = data.servers || [];
242
+ this.renderMCPServers();
243
+ }
244
+ } catch (error) {
245
+ logger.error('Failed to load MCP servers:', error);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Render all dashboard sections
251
+ */
252
+ render(): void {
253
+ if (!this.data) {
254
+ return;
255
+ }
256
+
257
+ this.renderGateways();
258
+ this.renderMCPServers();
259
+ this.renderSessions();
260
+ this.renderAgentSwarm();
261
+ this.renderMemoryStats();
262
+ this.renderAgentConfig();
263
+ this.renderCronJobs();
264
+ this.renderTokenSummary();
265
+ this.renderTopTopics();
266
+ }
267
+
268
+ /**
269
+ * Render gateway status cards
270
+ */
271
+ renderGateways(): void {
272
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-gateways');
273
+ if (!container || !this.data.gateways) {
274
+ return;
275
+ }
276
+
277
+ const gateways = [
278
+ { key: 'discord', name: 'Discord', icon: '💬', color: 'indigo' },
279
+ { key: 'slack', name: 'Slack', icon: '📱', color: 'green' },
280
+ { key: 'telegram', name: 'Telegram', icon: '✈️', color: 'blue' },
281
+ { key: 'chatwork', name: 'Chatwork', icon: '💼', color: 'orange' },
282
+ ];
283
+
284
+ // Count active bots
285
+ const enabledCount = gateways.filter((gw) => this.data.gateways[gw.key]?.enabled).length;
286
+ const configuredCount = gateways.filter((gw) => this.data.gateways[gw.key]?.configured).length;
287
+
288
+ // Update header with bot count
289
+ const header = container.previousElementSibling;
290
+ if (header && header.tagName === 'H2') {
291
+ header.innerHTML = `Gateway Status <span class="text-sm font-normal text-gray-500">(${enabledCount}/${configuredCount} active)</span>`;
292
+ }
293
+
294
+ const html = gateways
295
+ .map((gw) => {
296
+ const status = this.data.gateways[gw.key] || {};
297
+ const isConfigured = status.configured;
298
+ const isEnabled = status.enabled;
299
+
300
+ const statusBadge = isConfigured
301
+ ? isEnabled
302
+ ? `<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400">Enabled</span>`
303
+ : `<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400">Disabled</span>`
304
+ : `<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500">Not Configured</span>`;
305
+
306
+ // Get channel info based on gateway type
307
+ let channelInfo = '';
308
+ if (isConfigured) {
309
+ if (gw.key === 'discord' && status.channel) {
310
+ channelInfo = `<span class="text-[10px] bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded">#${escapeHtml(status.channel)}</span>`;
311
+ } else if (gw.key === 'telegram' && status.chats?.length > 0) {
312
+ channelInfo = `<span class="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">${status.chats.length} chat(s)</span>`;
313
+ } else if (gw.key === 'slack' && status.channel) {
314
+ channelInfo = `<span class="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded">#${escapeHtml(status.channel)}</span>`;
315
+ } else if (gw.key === 'chatwork' && status.rooms?.length > 0) {
316
+ channelInfo = `<span class="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded">${status.rooms.length} room(s)</span>`;
317
+ }
318
+ }
319
+
320
+ return `
321
+ <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
322
+ <div class="flex items-center justify-between mb-2">
323
+ <span class="text-2xl">${gw.icon}</span>
324
+ ${statusBadge}
325
+ </div>
326
+ <h3 class="font-semibold text-gray-900 dark:text-gray-100">${gw.name}</h3>
327
+ <div class="flex items-center gap-2 mt-1">
328
+ <p class="text-xs text-gray-500 dark:text-gray-400">
329
+ ${isConfigured ? 'Token ✓' : 'No token'}
330
+ </p>
331
+ ${channelInfo}
332
+ </div>
333
+ </div>
334
+ `;
335
+ })
336
+ .join('');
337
+
338
+ container.innerHTML = html;
339
+ }
340
+
341
+ /**
342
+ * Render MCP servers
343
+ */
344
+ renderMCPServers(): void {
345
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-mcp');
346
+ if (!container) {
347
+ return;
348
+ }
349
+
350
+ if (this.mcpServers.length === 0) {
351
+ container.innerHTML = `
352
+ <p class="text-gray-500 text-sm col-span-full py-4 text-center">
353
+ No MCP servers configured
354
+ </p>
355
+ `;
356
+ return;
357
+ }
358
+
359
+ const icons = {
360
+ 'brave-devtools': '🌐',
361
+ 'brave-search': '🔍',
362
+ mama: '🧠',
363
+ slack: '💬',
364
+ notion: '📝',
365
+ linear: '📊',
366
+ asana: '✅',
367
+ atlassian: '🔷',
368
+ ms365: '📧',
369
+ monday: '📅',
370
+ clickup: '✓',
371
+ };
372
+
373
+ const html = this.mcpServers
374
+ .map((server) => {
375
+ const icon = icons[server.name] || '🔌';
376
+ const isHttp = server.type === 'http';
377
+ const statusBadge = isHttp
378
+ ? 'bg-yellow-100 text-yellow-700'
379
+ : 'bg-green-100 text-green-700';
380
+ const statusText = isHttp ? 'OAuth' : 'Ready';
381
+
382
+ return `
383
+ <div class="bg-white border border-gray-200 rounded-lg p-2.5 hover:shadow-md transition-shadow">
384
+ <div class="flex items-center justify-between mb-1">
385
+ <div class="flex items-center gap-2">
386
+ <span class="text-lg">${icon}</span>
387
+ <h4 class="font-semibold text-sm text-gray-900">${escapeHtml(server.name)}</h4>
388
+ </div>
389
+ <span class="text-[10px] px-1.5 py-0.5 rounded ${statusBadge}">${statusText}</span>
390
+ </div>
391
+ <p class="text-[10px] text-gray-500 truncate">${escapeHtml(server.type === 'http' ? server.url : server.command)}</p>
392
+ </div>
393
+ `;
394
+ })
395
+ .join('');
396
+
397
+ container.innerHTML = html;
398
+ }
399
+
400
+ /**
401
+ * Render session statistics
402
+ */
403
+ renderSessions(): void {
404
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-sessions');
405
+ if (!container) {
406
+ return;
407
+ }
408
+
409
+ const sessions = this.data.sessions || { total: 0, bySource: {}, channels: [] };
410
+
411
+ if (sessions.total === 0) {
412
+ container.innerHTML = `
413
+ <p class="text-gray-500 dark:text-gray-400 text-sm text-center py-4">
414
+ No active sessions yet. Start chatting to create sessions.
415
+ </p>
416
+ `;
417
+ return;
418
+ }
419
+
420
+ // Source icons and labels
421
+ const sourceInfo = {
422
+ discord: { icon: '🎮', label: 'Discord', color: 'bg-indigo-100 text-indigo-700' },
423
+ telegram: { icon: '✈️', label: 'Telegram', color: 'bg-sky-100 text-sky-700' },
424
+ slack: { icon: '📱', label: 'Slack', color: 'bg-purple-100 text-purple-700' },
425
+ chatwork: { icon: '💼', label: 'Chatwork', color: 'bg-green-100 text-green-700' },
426
+ viewer: { icon: '🖥️', label: 'OS Viewer', color: 'bg-gray-100 text-gray-700' },
427
+ mobile: { icon: '📲', label: 'Mobile', color: 'bg-orange-100 text-orange-700' },
428
+ };
429
+
430
+ // Build source summary
431
+ const sourceSummary = Object.entries(sessions.bySource)
432
+ .map(([source, count]) => {
433
+ const info = sourceInfo[source] || {
434
+ icon: '📝',
435
+ label: source,
436
+ color: 'bg-gray-100 text-gray-700',
437
+ };
438
+ const safeLabel = escapeHtml(info.label);
439
+ return `<span class="inline-flex items-center gap-1 ${info.color} px-2 py-1 rounded text-xs font-medium">
440
+ ${info.icon} ${safeLabel}: ${count}
441
+ </span>`;
442
+ })
443
+ .join('');
444
+
445
+ // Build recent channels list
446
+ const channelList = sessions.channels
447
+ .slice(0, 5)
448
+ .map((ch) => {
449
+ const info = sourceInfo[ch.source] || {
450
+ icon: '📝',
451
+ label: ch.source,
452
+ color: 'bg-gray-100 text-gray-700',
453
+ };
454
+ const lastActive = this.formatRelativeTime(ch.lastActive);
455
+
456
+ // Use channel name if available, otherwise use meaningful fallbacks
457
+ let channelDisplay;
458
+ if (ch.channelName) {
459
+ // Show channel name (already human-readable)
460
+ channelDisplay =
461
+ ch.channelName.length > 25 ? ch.channelName.slice(0, 22) + '...' : ch.channelName;
462
+ } else if (ch.source === 'viewer' || ch.channelId === 'mama_os_main') {
463
+ // OS Viewer - shared channel
464
+ channelDisplay = 'MAMA OS';
465
+ } else if (ch.source === 'mobile') {
466
+ // Mobile app - show user-friendly name
467
+ channelDisplay = 'Mobile App';
468
+ } else {
469
+ // Fallback: truncate channel ID (Discord channels before update)
470
+ channelDisplay =
471
+ ch.channelId.length > 12
472
+ ? ch.channelId.slice(0, 6) + '...' + ch.channelId.slice(-4)
473
+ : ch.channelId;
474
+ }
475
+
476
+ return `
477
+ <div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700 last:border-0">
478
+ <div class="flex items-center gap-2">
479
+ <span class="${info.color} px-1.5 py-0.5 rounded text-[10px] font-medium" title="${escapeHtml(info.label)}">${info.icon}</span>
480
+ <span class="text-xs text-gray-700 dark:text-gray-300" title="${escapeHtml(ch.channelId)}">${escapeHtml(channelDisplay)}</span>
481
+ </div>
482
+ <div class="flex items-center gap-2">
483
+ <span class="text-[10px] bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">${ch.messageCount} turns</span>
484
+ <span class="text-[10px] text-gray-400">${lastActive}</span>
485
+ </div>
486
+ </div>
487
+ `;
488
+ })
489
+ .join('');
490
+
491
+ container.innerHTML = `
492
+ <div class="mb-3">
493
+ <div class="flex items-center justify-between mb-2">
494
+ <span class="text-sm font-medium text-gray-900 dark:text-gray-100">Sessions by Platform</span>
495
+ <span class="text-xs bg-indigo-100 text-indigo-600 px-2 py-0.5 rounded-full">${sessions.total} total</span>
496
+ </div>
497
+ <div class="flex flex-wrap gap-2">
498
+ ${sourceSummary}
499
+ </div>
500
+ </div>
501
+ <div>
502
+ <p class="text-xs text-gray-500 mb-2">Recent Channels:</p>
503
+ ${channelList || '<p class="text-xs text-gray-400">No recent activity</p>'}
504
+ </div>
505
+ `;
506
+ }
507
+
508
+ /**
509
+ * Format relative time (e.g., "2h ago", "3d ago")
510
+ */
511
+ formatRelativeTime(timestamp: number | string | Date | undefined): string {
512
+ if (timestamp === undefined || timestamp === null || timestamp === '') {
513
+ return 'Never';
514
+ }
515
+
516
+ const now = Date.now();
517
+ const target = typeof timestamp === 'number' ? timestamp : new Date(timestamp).getTime();
518
+ const diff = now - target;
519
+ const minutes = Math.floor(diff / 60000);
520
+ const hours = Math.floor(diff / 3600000);
521
+ const days = Math.floor(diff / 86400000);
522
+
523
+ if (minutes < 1) {
524
+ return 'Just now';
525
+ }
526
+ if (minutes < 60) {
527
+ return `${minutes}m ago`;
528
+ }
529
+ if (hours < 24) {
530
+ return `${hours}h ago`;
531
+ }
532
+ return `${days}d ago`;
533
+ }
534
+
535
+ /**
536
+ * Render memory statistics
537
+ */
538
+ renderMemoryStats(): void {
539
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-memory');
540
+ if (!container || !this.data.memory) {
541
+ return;
542
+ }
543
+
544
+ const memory = this.data.memory;
545
+
546
+ const stats = [
547
+ { label: 'Total Decisions', value: memory.total || 0, icon: '🧠' },
548
+ { label: 'This Week', value: memory.thisWeek || 0, icon: '📅' },
549
+ { label: 'This Month', value: memory.thisMonth || 0, icon: '📆' },
550
+ { label: 'Checkpoints', value: memory.checkpoints || 0, icon: '💾' },
551
+ ];
552
+
553
+ const html = stats
554
+ .map(
555
+ (stat) => `
556
+ <div class="bg-white border border-gray-200 rounded-lg p-2.5 text-center">
557
+ <span class="text-lg">${stat.icon}</span>
558
+ <p class="text-xl font-bold text-gray-900 mt-1">${stat.value}</p>
559
+ <p class="text-[10px] text-gray-500">${stat.label}</p>
560
+ </div>
561
+ `
562
+ )
563
+ .join('');
564
+
565
+ container.innerHTML = html;
566
+ }
567
+
568
+ /**
569
+ * Render agent configuration
570
+ */
571
+ renderAgentConfig(): void {
572
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-agent');
573
+ if (!container || !this.data.agent) {
574
+ return;
575
+ }
576
+
577
+ const agent = this.data.agent;
578
+ const heartbeat = this.data.heartbeat || {};
579
+ const friendlyModel = formatModelName(agent.model) || 'Not Set';
580
+
581
+ container.innerHTML = `
582
+ <div class="mb-3 pb-3 border-b border-gray-200 dark:border-gray-700">
583
+ <div class="flex items-center justify-between">
584
+ <div>
585
+ <p class="text-[10px] text-gray-500 uppercase tracking-wide">Current Model</p>
586
+ <p class="font-bold text-gray-900 dark:text-gray-100 text-lg">${escapeHtml(friendlyModel)}</p>
587
+ <p class="text-[10px] text-gray-400 font-mono">${escapeHtml(agent.model || 'Not configured')}</p>
588
+ </div>
589
+ <span class="text-3xl">🤖</span>
590
+ </div>
591
+ </div>
592
+ <div class="grid grid-cols-3 gap-3">
593
+ <div>
594
+ <p class="text-[10px] text-gray-500 uppercase tracking-wide">Max Turns</p>
595
+ <p class="font-semibold text-gray-900 dark:text-gray-100 text-sm mt-0.5">${agent.maxTurns || 'N/A'}</p>
596
+ </div>
597
+ <div>
598
+ <p class="text-[10px] text-gray-500 uppercase tracking-wide">Timeout</p>
599
+ <p class="font-semibold text-gray-900 dark:text-gray-100 text-sm mt-0.5">${this.formatTimeout(agent.timeout)}</p>
600
+ </div>
601
+ <div>
602
+ <p class="text-[10px] text-gray-500 uppercase tracking-wide">Heartbeat</p>
603
+ <p class="font-semibold text-gray-900 dark:text-gray-100 text-sm mt-0.5">
604
+ ${heartbeat.enabled ? `${Math.round((heartbeat.interval || 1800000) / 60000)}min` : 'Off'}
605
+ </p>
606
+ </div>
607
+ </div>
608
+ ${
609
+ heartbeat.enabled
610
+ ? `
611
+ <div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
612
+ <p class="text-[10px] text-gray-500">
613
+ Quiet hours: ${heartbeat.quiet_start ?? heartbeat.quietStart ?? 23}:00 - ${
614
+ heartbeat.quiet_end ?? heartbeat.quietEnd ?? 8
615
+ }:00
616
+ </p>
617
+ </div>
618
+ `
619
+ : ''
620
+ }
621
+ `;
622
+ }
623
+
624
+ /**
625
+ * Render agent swarm section
626
+ * Sprint 3 F2: Multi-agent dashboard
627
+ */
628
+ renderAgentSwarm(): void {
629
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-agent-swarm');
630
+ if (!container) {
631
+ return;
632
+ }
633
+
634
+ const multiAgent = this.multiAgentData || { enabled: false, agents: [] };
635
+
636
+ if (!multiAgent.enabled) {
637
+ container.innerHTML = `
638
+ <p class="text-gray-500 dark:text-gray-400 text-sm text-center py-4">
639
+ Multi-agent is not enabled. Enable in <a href="/viewer?tab=settings" class="text-indigo-600 hover:underline" data-action="open-settings">Settings</a>.
640
+ </p>
641
+ `;
642
+ return;
643
+ }
644
+
645
+ const agents = multiAgent.agents || [];
646
+
647
+ if (agents.length === 0) {
648
+ container.innerHTML = `
649
+ <p class="text-gray-500 dark:text-gray-400 text-sm text-center py-4">
650
+ No agents configured yet.
651
+ </p>
652
+ `;
653
+ return;
654
+ }
655
+
656
+ // Tier badge colors
657
+ const tierColors = {
658
+ 1: { bg: 'bg-indigo-100', text: 'text-indigo-700', label: 'T1' },
659
+ 2: { bg: 'bg-green-100', text: 'text-green-700', label: 'T2' },
660
+ 3: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'T3' },
661
+ };
662
+
663
+ // Status icons (F2 enhanced)
664
+ const statusIcons = {
665
+ idle: '🟢', // 대기 중
666
+ online: '🟢', // 온라인 (fallback)
667
+ busy: '🟡', // 작업 중
668
+ starting: '🔵', // 시작 중
669
+ dead: '🔴', // 비정상 종료
670
+ offline: '🔴', // 오프라인
671
+ disabled: '⚪', // 비활성
672
+ };
673
+
674
+ // Status text labels
675
+ const statusLabels = {
676
+ idle: 'Ready',
677
+ online: 'Ready',
678
+ busy: 'Working...',
679
+ starting: 'Starting...',
680
+ dead: 'Error',
681
+ offline: 'Offline',
682
+ disabled: 'Disabled',
683
+ };
684
+
685
+ // Agent cards
686
+ const agentCards = agents
687
+ .map((agent) => {
688
+ const tier = tierColors[agent.tier] || tierColors[1];
689
+ const statusIcon = statusIcons[agent.status] || statusIcons.offline;
690
+ const statusLabel = statusLabels[agent.status] || 'Unknown';
691
+ const friendlyModel = formatModelName(agent.model) || agent.model || 'Default';
692
+
693
+ return `
694
+ <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-2.5 hover:shadow-md transition-shadow">
695
+ <div class="flex items-center justify-between mb-1.5">
696
+ <div class="flex items-center gap-2">
697
+ <span class="${tier.bg} ${tier.text} text-xs font-bold px-1.5 py-0.5 rounded">${tier.label}</span>
698
+ <span class="text-xs">${statusIcon} ${escapeHtml(statusLabel)}</span>
699
+ </div>
700
+ </div>
701
+ <h3 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">${escapeHtml(agent.name)}</h3>
702
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${escapeHtml(friendlyModel)}</p>
703
+ ${
704
+ agent.lastActivity
705
+ ? `<p class="text-[10px] text-gray-400 mt-1">Last: ${this.formatRelativeTime(agent.lastActivity)}</p>`
706
+ : ''
707
+ }
708
+ </div>
709
+ `;
710
+ })
711
+ .join('');
712
+
713
+ // Recent delegations (F2 F4 API integration)
714
+ const delegationsData = this.delegationsData || { delegations: [], count: 0 };
715
+ const delegations = delegationsData.delegations || [];
716
+
717
+ // Status badge colors
718
+ const statusColors = {
719
+ completed: 'bg-green-100 text-green-700',
720
+ claimed: 'bg-yellow-100 text-yellow-700',
721
+ failed: 'bg-red-100 text-red-700',
722
+ pending: 'bg-gray-100 text-gray-700',
723
+ };
724
+
725
+ const delegationList =
726
+ delegations.length > 0
727
+ ? delegations
728
+ .slice(0, 5)
729
+ .map((del) => {
730
+ const statusColor = statusColors[del.status] || statusColors.pending;
731
+ const timestamp = del.completedAt || del.claimedAt;
732
+ return `
733
+ <div class="text-xs text-gray-700 dark:text-gray-300 py-1 border-b border-gray-100 dark:border-gray-700 last:border-0">
734
+ <span class="${statusColor} text-[10px] font-bold px-1 py-0.5 rounded">${escapeHtml(del.status)}</span>
735
+ <span class="font-medium">${escapeHtml(del.claimedBy || 'unknown')}</span>:
736
+ "${escapeHtml(del.description)}"
737
+ ${del.wave ? `<span class="text-gray-400">(wave ${del.wave})</span>` : ''}
738
+ ${timestamp ? `<span class="text-gray-400 text-[10px]"> ${this.formatRelativeTime(timestamp)}</span>` : ''}
739
+ </div>
740
+ `;
741
+ })
742
+ .join('')
743
+ : '<p class="text-xs text-gray-400">No recent delegations</p>';
744
+
745
+ // Active chains
746
+ const activeChains = multiAgent.activeChains || 0;
747
+ const chainBadge =
748
+ activeChains > 0
749
+ ? `<span class="text-xs bg-green-100 text-green-600 px-2 py-0.5 rounded-full">${activeChains} active</span>`
750
+ : '<span class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">0 active</span>';
751
+
752
+ container.innerHTML = `
753
+ <div class="mb-3">
754
+ <p class="text-xs text-gray-500 mb-2">Agent Team:</p>
755
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-2">
756
+ ${agentCards}
757
+ </div>
758
+ </div>
759
+ <div class="mb-2 pb-2 border-b border-gray-200 dark:border-gray-700">
760
+ <div class="flex items-center justify-between mb-2">
761
+ <p class="text-xs text-gray-500">Delegation Chain:</p>
762
+ ${chainBadge}
763
+ </div>
764
+ <div class="bg-gray-50 dark:bg-gray-800 rounded p-2">
765
+ ${delegationList}
766
+ </div>
767
+ </div>
768
+ `;
769
+ }
770
+
771
+ /**
772
+ * Render top topics
773
+ */
774
+ renderTopTopics(): void {
775
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-topics');
776
+ if (!container || !this.data.memory) {
777
+ return;
778
+ }
779
+
780
+ const topics = this.data.memory.topTopics || [];
781
+
782
+ if (topics.length === 0) {
783
+ container.innerHTML = `
784
+ <p class="text-gray-500 dark:text-gray-400 text-sm">No topics yet. Start making decisions!</p>
785
+ `;
786
+ return;
787
+ }
788
+
789
+ const counts = topics
790
+ .map((topic) => (Number.isFinite(topic.count) ? topic.count : 0))
791
+ .filter((count) => count >= 0);
792
+ const maxCount = Math.max(1, ...counts);
793
+
794
+ const html = topics
795
+ .map((topic) => {
796
+ const safeCount = Number.isFinite(topic.count) ? topic.count : 0;
797
+ return `
798
+ <div class="flex items-center gap-3 mb-2">
799
+ <div class="flex-1">
800
+ <div class="flex justify-between items-center mb-1">
801
+ <span class="text-sm font-medium text-gray-900 dark:text-gray-100">${escapeHtml(topic.topic)}</span>
802
+ <span class="text-xs text-gray-500 dark:text-gray-400">${safeCount}</span>
803
+ </div>
804
+ <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
805
+ <div class="bg-indigo-500 h-2 rounded-full" style="width: ${(safeCount / maxCount) * 100}%"></div>
806
+ </div>
807
+ </div>
808
+ </div>
809
+ `;
810
+ })
811
+ .join('');
812
+
813
+ container.innerHTML = html;
814
+ }
815
+
816
+ /**
817
+ * Format timeout in human readable format
818
+ */
819
+ formatTimeout(ms?: number): string {
820
+ if (!ms) {
821
+ return 'N/A';
822
+ }
823
+ if (ms < 60000) {
824
+ return `${Math.round(ms / 1000)}s`;
825
+ }
826
+ return `${Math.round(ms / 60000)}min`;
827
+ }
828
+
829
+ /**
830
+ * Render cron jobs section
831
+ */
832
+ renderCronJobs(): void {
833
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-cron');
834
+ if (!container) {
835
+ return;
836
+ }
837
+
838
+ const jobs = this.cronData?.jobs || this.cronData || [];
839
+
840
+ if (!Array.isArray(jobs) || jobs.length === 0) {
841
+ container.innerHTML = `
842
+ <p class="text-gray-500 text-sm text-center py-4">
843
+ No cron jobs configured. Ask the agent to schedule a task or use the Settings tab.
844
+ </p>
845
+ `;
846
+ return;
847
+ }
848
+
849
+ const rows = jobs
850
+ .map((job) => {
851
+ const isEnabled = job.enabled !== false;
852
+ const statusBadge = isEnabled
853
+ ? '<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-600">Active</span>'
854
+ : '<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">Paused</span>';
855
+
856
+ const nextRun = job.nextRun
857
+ ? new Date(job.nextRun).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
858
+ : '-';
859
+
860
+ return `
861
+ <div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
862
+ <div class="flex-1 min-w-0">
863
+ <div class="flex items-center gap-2">
864
+ <span class="font-medium text-sm text-gray-900 truncate">${escapeHtml(job.name || job.id)}</span>
865
+ ${statusBadge}
866
+ </div>
867
+ <p class="text-xs text-gray-500 mt-0.5">
868
+ <code class="bg-gray-100 px-1 py-0.5 rounded text-[10px]">${escapeHtml(job.schedule || job.cron || '')}</code>
869
+ <span class="ml-2">Next: ${nextRun}</span>
870
+ </p>
871
+ </div>
872
+ <div class="flex items-center gap-1 ml-2 shrink-0">
873
+ <button class="text-xs px-2 py-1 bg-mama-yellow hover:bg-mama-yellow-hover text-mama-black rounded transition-colors"
874
+ data-action="run-cron"
875
+ data-cron-id="${escapeAttr(job.id)}" title="Run Now">
876
+ Run
877
+ </button>
878
+ </div>
879
+ </div>
880
+ `;
881
+ })
882
+ .join('');
883
+
884
+ container.innerHTML = `
885
+ <div class="space-y-0">
886
+ ${rows}
887
+ </div>
888
+ `;
889
+ }
890
+
891
+ /**
892
+ * Run a cron job immediately
893
+ */
894
+ async runCronJob(id: string): Promise<void> {
895
+ try {
896
+ await API.runCronJob(id);
897
+ const statusEl = getElementByIdOrNull<HTMLElement>('dashboard-status');
898
+ if (statusEl) {
899
+ statusEl.textContent = `Cron job "${id}" triggered`;
900
+ }
901
+ await this.loadStatus();
902
+ } catch (e) {
903
+ logger.error('[Dashboard] Failed to run cron job:', e);
904
+ const statusEl = getElementByIdOrNull<HTMLElement>('dashboard-status');
905
+ if (statusEl) {
906
+ const message = getErrorMessage(e);
907
+ statusEl.textContent = `Cron job "${id}" failed: ${message}`;
908
+ }
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Render token usage summary section
914
+ */
915
+ renderTokenSummary(): void {
916
+ const container = getElementByIdOrNull<HTMLElement>('dashboard-tokens');
917
+ if (!container) {
918
+ return;
919
+ }
920
+
921
+ if (!this.tokenData?.summary) {
922
+ container.innerHTML = `
923
+ <p class="text-gray-500 text-sm text-center py-4">
924
+ Token tracking not yet available. Usage data will appear after conversations.
925
+ </p>
926
+ `;
927
+ return;
928
+ }
929
+
930
+ const s = this.tokenData.summary;
931
+ const agents = this.tokenData.byAgent?.agents || [];
932
+
933
+ const formatTokens = (n: number | undefined): string => {
934
+ if (!n || n === 0) {
935
+ return '0';
936
+ }
937
+ if (n >= 1000000) {
938
+ return (n / 1000000).toFixed(1) + 'M';
939
+ }
940
+ if (n >= 1000) {
941
+ return (n / 1000).toFixed(1) + 'K';
942
+ }
943
+ return n.toString();
944
+ };
945
+
946
+ const formatCost = (usd: number | undefined): string => {
947
+ if (!usd || usd === 0) {
948
+ return '$0.00';
949
+ }
950
+ return '$' + usd.toFixed(2);
951
+ };
952
+
953
+ // Summary cards
954
+ const periods = [
955
+ {
956
+ label: 'Today',
957
+ tokens: (s.today?.input_tokens || 0) + (s.today?.output_tokens || 0),
958
+ cost: s.today?.cost_usd,
959
+ icon: '📊',
960
+ },
961
+ {
962
+ label: 'This Week',
963
+ tokens: (s.week?.input_tokens || 0) + (s.week?.output_tokens || 0),
964
+ cost: s.week?.cost_usd,
965
+ icon: '📅',
966
+ },
967
+ {
968
+ label: 'This Month',
969
+ tokens: (s.month?.input_tokens || 0) + (s.month?.output_tokens || 0),
970
+ cost: s.month?.cost_usd,
971
+ icon: '📆',
972
+ },
973
+ ];
974
+
975
+ const cards = periods
976
+ .map(
977
+ (p) => `
978
+ <div class="bg-white border border-gray-200 rounded-lg p-2.5 text-center">
979
+ <span class="text-lg">${p.icon}</span>
980
+ <p class="text-xl font-bold text-gray-900 mt-1">${formatTokens(p.tokens)}</p>
981
+ <p class="text-[10px] text-gray-500">${p.label}</p>
982
+ <p class="text-[10px] text-mama-yellow-hover font-medium">${formatCost(p.cost)}</p>
983
+ </div>
984
+ `
985
+ )
986
+ .join('');
987
+
988
+ // Agent breakdown (mini bar chart)
989
+ const maxTokens = Math.max(
990
+ ...agents.map((a) => (a.input_tokens || 0) + (a.output_tokens || 0)),
991
+ 1
992
+ );
993
+ const agentBars = agents
994
+ .slice(0, 5)
995
+ .map((a) => {
996
+ const totalTokens = (a.input_tokens || 0) + (a.output_tokens || 0);
997
+ const pct = Math.round((totalTokens / maxTokens) * 100);
998
+ const agentLabel = a.agent_name || a.agent_id || 'unknown';
999
+ return `
1000
+ <div class="flex items-center gap-2 mb-1.5">
1001
+ <span class="text-xs text-gray-700 w-20 truncate" title="${escapeHtml(agentLabel)}">${escapeHtml(agentLabel)}</span>
1002
+ <div class="flex-1 bg-gray-200 rounded-full h-2">
1003
+ <div class="bg-mama-yellow h-2 rounded-full transition-all" style="width: ${pct}%"></div>
1004
+ </div>
1005
+ <span class="text-[10px] text-gray-500 w-12 text-right">${formatTokens(totalTokens)}</span>
1006
+ </div>
1007
+ `;
1008
+ })
1009
+ .join('');
1010
+
1011
+ container.innerHTML = `
1012
+ <div class="grid grid-cols-3 gap-2 mb-3">
1013
+ ${cards}
1014
+ </div>
1015
+ ${
1016
+ agents.length > 0
1017
+ ? `
1018
+ <div>
1019
+ <p class="text-xs text-gray-500 mb-2">By Agent:</p>
1020
+ ${agentBars}
1021
+ </div>
1022
+ `
1023
+ : ''
1024
+ }
1025
+ `;
1026
+ }
1027
+
1028
+ /**
1029
+ * Set status message
1030
+ */
1031
+ setStatus(message: string, type = ''): void {
1032
+ const statusEl = getElementByIdOrNull<HTMLElement>('dashboard-status');
1033
+ if (statusEl) {
1034
+ statusEl.textContent = message;
1035
+ statusEl.className = `text-sm text-center py-2 ${type === 'error' ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`;
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Cleanup interval on destroy
1041
+ */
1042
+ cleanup(): void {
1043
+ if (this.updateInterval) {
1044
+ clearInterval(this.updateInterval);
1045
+ this.updateInterval = null;
1046
+ }
1047
+ if (this.onCronClick) {
1048
+ document.removeEventListener('click', this.onCronClick);
1049
+ this.onCronClick = null;
1050
+ }
1051
+ }
1052
+ }