|
1 | 1 | import { useEffect, useState, useRef } from 'react'; |
2 | | -import { X, Download, Trash2, RefreshCw } from 'lucide-react'; |
| 2 | +import { X, Download, Trash2, RefreshCw, Copy } from 'lucide-react'; |
3 | 3 | import { useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; |
4 | 4 | import { getServerLogs, clearServerLogs, getServerLogFile, type ServerLogEntry } from '@/lib/api/logs'; |
5 | 5 |
|
@@ -32,6 +32,30 @@ const SOURCE_COLORS: Record<string, string> = { |
32 | 32 | server: 'text-cyan-400', |
33 | 33 | }; |
34 | 34 |
|
| 35 | +/** |
| 36 | + * Formats an ISO timestamp for log display and export. |
| 37 | + */ |
| 38 | +function formatTimestamp(ts: string): string { |
| 39 | + const date = new Date(ts); |
| 40 | + const hours = date.getHours().toString().padStart(2, '0'); |
| 41 | + const minutes = date.getMinutes().toString().padStart(2, '0'); |
| 42 | + const seconds = date.getSeconds().toString().padStart(2, '0'); |
| 43 | + const ms = date.getMilliseconds().toString().padStart(3, '0'); |
| 44 | + return `${hours}:${minutes}:${seconds}.${ms}`; |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * Formats a log entry as a single plain-text line for display export. |
| 49 | + */ |
| 50 | +function formatLogLine(log: ServerLogEntry): string { |
| 51 | + const level = log.level.toUpperCase().padEnd(5); |
| 52 | + const base = `${formatTimestamp(log.timestamp)} ${level} ${log.source} ${log.message}`; |
| 53 | + if (!log.metadata) { |
| 54 | + return base; |
| 55 | + } |
| 56 | + return `${base} ${JSON.stringify(log.metadata)}`; |
| 57 | +} |
| 58 | + |
35 | 59 | export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogViewerProps) { |
36 | 60 | const [logs, setLogs] = useState<ServerLogEntry[]>([]); |
37 | 61 | const [loading, setLoading] = useState(true); |
@@ -122,22 +146,32 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView |
122 | 146 | } |
123 | 147 | }; |
124 | 148 |
|
125 | | - const formatTimestamp = (ts: string) => { |
126 | | - const date = new Date(ts); |
127 | | - const hours = date.getHours().toString().padStart(2, '0'); |
128 | | - const minutes = date.getMinutes().toString().padStart(2, '0'); |
129 | | - const seconds = date.getSeconds().toString().padStart(2, '0'); |
130 | | - const ms = date.getMilliseconds().toString().padStart(3, '0'); |
131 | | - return `${hours}:${minutes}:${seconds}.${ms}`; |
132 | | - }; |
133 | | - |
134 | 149 | const filteredLogs = logs.filter(log => { |
135 | 150 | if (levelFilter === 'all') return true; |
136 | 151 | const logLevelIndex = LOG_LEVELS.indexOf(log.level as LogLevel); |
137 | 152 | const filterLevelIndex = LOG_LEVELS.indexOf(levelFilter); |
138 | 153 | return logLevelIndex >= filterLevelIndex; |
139 | 154 | }); |
140 | 155 |
|
| 156 | + /** Copies all currently visible (filtered) log lines to the clipboard. */ |
| 157 | + const handleCopyAll = async () => { |
| 158 | + if (filteredLogs.length === 0) { |
| 159 | + showError('Nothing to copy', 'No logs match the current filter'); |
| 160 | + return; |
| 161 | + } |
| 162 | + |
| 163 | + try { |
| 164 | + const text = filteredLogs.map((log) => formatLogLine(log)).join('\n'); |
| 165 | + await navigator.clipboard.writeText(text); |
| 166 | + success( |
| 167 | + 'Logs copied', |
| 168 | + `${filteredLogs.length} log${filteredLogs.length !== 1 ? 's' : ''} copied to clipboard` |
| 169 | + ); |
| 170 | + } catch (e) { |
| 171 | + showError('Failed to copy logs', e instanceof Error ? e.message : String(e)); |
| 172 | + } |
| 173 | + }; |
| 174 | + |
141 | 175 | return ( |
142 | 176 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> |
143 | 177 | <ToastContainer toasts={toasts} onClose={dismiss} /> |
@@ -195,6 +229,16 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView |
195 | 229 | > |
196 | 230 | <Download className="h-4 w-4" /> |
197 | 231 | </button> |
| 232 | + |
| 233 | + {/* Copy All */} |
| 234 | + <button |
| 235 | + onClick={handleCopyAll} |
| 236 | + disabled={filteredLogs.length === 0} |
| 237 | + className="p-1.5 rounded-lg hover:bg-[rgb(var(--surface-hover))] transition-colors disabled:opacity-40 disabled:pointer-events-none" |
| 238 | + title="Copy all visible logs to clipboard" |
| 239 | + > |
| 240 | + <Copy className="h-4 w-4" /> |
| 241 | + </button> |
198 | 242 |
|
199 | 243 | {/* Clear Logs */} |
200 | 244 | <button |
|
0 commit comments