|
1 | 1 | <script> |
| 2 | + import { onMount } from 'svelte'; |
| 3 | + |
2 | 4 | let { logs = $bindable([]), t = {} } = $props(); |
3 | 5 | |
| 6 | + let searchQuery = $state(''); |
| 7 | + let levelFilter = $state('all'); // 'all' | 'error' | 'warn' | 'success' |
| 8 | + let autoScroll = $state(true); |
| 9 | + let showScrollBtn = $state(false); |
| 10 | + let copiedIdx = $state(-1); |
| 11 | + let logContainer; |
| 12 | +
|
4 | 13 | function stripAnsi(value) { |
5 | 14 | if (!value) return ''; |
6 | 15 | return value.replace(/\x1B\[[0-9;]*m/g, ''); |
7 | 16 | } |
8 | 17 |
|
| 18 | + // Detect log level from message content |
| 19 | + function getLogLevel(message) { |
| 20 | + if (!message) return 'info'; |
| 21 | + const msg = stripAnsi(message).toLowerCase(); |
| 22 | + if (msg.includes('error') || msg.includes('failed') || msg.includes('failure') || msg.includes('fatal') || msg.includes('错误') || msg.includes('失败')) return 'error'; |
| 23 | + if (msg.includes('warn') || msg.includes('warning') || msg.includes('警告')) return 'warn'; |
| 24 | + if (msg.includes('success') || msg.includes('completed') || msg.includes('created') || msg.includes('成功') || msg.includes('完成') || msg.includes('apply complete')) return 'success'; |
| 25 | + return 'info'; |
| 26 | + } |
| 27 | +
|
| 28 | + function getLevelColor(level) { |
| 29 | + switch (level) { |
| 30 | + case 'error': return 'text-red-400'; |
| 31 | + case 'warn': return 'text-yellow-400'; |
| 32 | + case 'success': return 'text-emerald-400'; |
| 33 | + default: return 'text-gray-300'; |
| 34 | + } |
| 35 | + } |
| 36 | +
|
| 37 | + function getLevelDot(level) { |
| 38 | + switch (level) { |
| 39 | + case 'error': return 'bg-red-500'; |
| 40 | + case 'warn': return 'bg-yellow-500'; |
| 41 | + case 'success': return 'bg-emerald-500'; |
| 42 | + default: return ''; |
| 43 | + } |
| 44 | + } |
| 45 | +
|
| 46 | + // Stats |
| 47 | + let errorCount = $derived(logs.filter(l => getLogLevel(l.message) === 'error').length); |
| 48 | + let warnCount = $derived(logs.filter(l => getLogLevel(l.message) === 'warn').length); |
| 49 | +
|
| 50 | + // Filtered logs |
| 51 | + let filteredLogs = $derived.by(() => { |
| 52 | + let result = logs.map((log, idx) => ({ ...log, _idx: idx })); |
| 53 | + if (levelFilter !== 'all') { |
| 54 | + result = result.filter(l => getLogLevel(l.message) === levelFilter); |
| 55 | + } |
| 56 | + if (searchQuery.trim()) { |
| 57 | + const q = searchQuery.trim().toLowerCase(); |
| 58 | + result = result.filter(l => stripAnsi(l.message).toLowerCase().includes(q)); |
| 59 | + } |
| 60 | + return result; |
| 61 | + }); |
| 62 | +
|
| 63 | + // Time gap detection: insert separator if gap > 5 minutes |
| 64 | + function shouldShowTimeSep(currentLog, prevLog) { |
| 65 | + if (!prevLog || !currentLog) return false; |
| 66 | + try { |
| 67 | + const parseTime = (t) => { |
| 68 | + const parts = t.split(':'); |
| 69 | + if (parts.length < 3) return 0; |
| 70 | + return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]); |
| 71 | + }; |
| 72 | + const diff = parseTime(currentLog.time) - parseTime(prevLog.time); |
| 73 | + return diff > 300 || diff < -300; // > 5 min or day wrap |
| 74 | + } catch { return false; } |
| 75 | + } |
| 76 | +
|
| 77 | + // Auto-scroll |
| 78 | + $effect(() => { |
| 79 | + if (logs.length && autoScroll && logContainer) { |
| 80 | + requestAnimationFrame(() => { |
| 81 | + logContainer.scrollTop = logContainer.scrollHeight; |
| 82 | + }); |
| 83 | + } |
| 84 | + }); |
| 85 | +
|
| 86 | + function handleScroll() { |
| 87 | + if (!logContainer) return; |
| 88 | + const { scrollTop, scrollHeight, clientHeight } = logContainer; |
| 89 | + const atBottom = scrollHeight - scrollTop - clientHeight < 40; |
| 90 | + autoScroll = atBottom; |
| 91 | + showScrollBtn = !atBottom && logs.length > 0; |
| 92 | + } |
| 93 | +
|
| 94 | + function scrollToBottom() { |
| 95 | + if (logContainer) { |
| 96 | + logContainer.scrollTop = logContainer.scrollHeight; |
| 97 | + autoScroll = true; |
| 98 | + showScrollBtn = false; |
| 99 | + } |
| 100 | + } |
| 101 | +
|
| 102 | + // Copy single log line |
| 103 | + function copyLog(log, idx) { |
| 104 | + const text = `[${log.time}] ${stripAnsi(log.message)}`; |
| 105 | + navigator.clipboard.writeText(text); |
| 106 | + copiedIdx = idx; |
| 107 | + setTimeout(() => { copiedIdx = -1; }, 1500); |
| 108 | + } |
| 109 | +
|
| 110 | + // Export all logs |
| 111 | + function exportLogs() { |
| 112 | + const text = logs.map(l => `[${l.time}] ${stripAnsi(l.message)}`).join('\n'); |
| 113 | + const blob = new Blob([text], { type: 'text/plain' }); |
| 114 | + const url = URL.createObjectURL(blob); |
| 115 | + const a = document.createElement('a'); |
| 116 | + a.href = url; |
| 117 | + a.download = `redc-console-${new Date().toISOString().slice(0, 10)}.log`; |
| 118 | + a.click(); |
| 119 | + URL.revokeObjectURL(url); |
| 120 | + } |
| 121 | +
|
9 | 122 | export function clearLogs() { |
10 | 123 | logs = []; |
11 | 124 | } |
12 | 125 | </script> |
13 | 126 |
|
14 | | -<div class="h-full flex flex-col bg-[#1e1e1e] rounded-xl overflow-hidden"> |
15 | | - <div class="flex items-center justify-between px-4 py-2.5 bg-[#252526] border-b border-[#3c3c3c]"> |
| 127 | +<div class="h-full flex flex-col bg-[#1e1e1e] rounded-xl overflow-hidden relative"> |
| 128 | + <!-- Header bar --> |
| 129 | + <div class="flex items-center justify-between px-4 py-2 bg-[#252526] border-b border-[#3c3c3c]"> |
16 | 130 | <div class="flex items-center gap-2"> |
17 | 131 | <div class="flex gap-1.5"> |
18 | 132 | <span class="w-3 h-3 rounded-full bg-[#ff5f56]"></span> |
19 | 133 | <span class="w-3 h-3 rounded-full bg-[#ffbd2e]"></span> |
20 | 134 | <span class="w-3 h-3 rounded-full bg-[#27ca40]"></span> |
21 | 135 | </div> |
22 | 136 | <span class="text-[12px] text-gray-500 ml-2">{t.terminal}</span> |
| 137 | + <!-- Log counts --> |
| 138 | + {#if logs.length > 0} |
| 139 | + <span class="text-[10px] text-gray-600 ml-1 tabular-nums">{logs.length}</span> |
| 140 | + {#if errorCount > 0} |
| 141 | + <span class="text-[10px] text-red-400 tabular-nums">{errorCount} {t.consoleErrors || 'err'}</span> |
| 142 | + {/if} |
| 143 | + {#if warnCount > 0} |
| 144 | + <span class="text-[10px] text-yellow-400 tabular-nums">{warnCount} {t.consoleWarns || 'warn'}</span> |
| 145 | + {/if} |
| 146 | + {/if} |
| 147 | + </div> |
| 148 | + <div class="flex items-center gap-2"> |
| 149 | + <!-- Search --> |
| 150 | + <div class="relative"> |
| 151 | + <input |
| 152 | + type="text" |
| 153 | + bind:value={searchQuery} |
| 154 | + placeholder={t.consoleSearch || '搜索日志...'} |
| 155 | + class="h-6 w-36 pl-6 pr-2 text-[11px] bg-[#3c3c3c] text-gray-300 rounded border border-[#4c4c4c] focus:border-gray-500 focus:outline-none placeholder-gray-600" |
| 156 | + /> |
| 157 | + <svg class="w-3 h-3 text-gray-600 absolute left-2 top-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg> |
| 158 | + </div> |
| 159 | + <!-- Level filter pills --> |
| 160 | + <div class="flex items-center gap-0.5 bg-[#1e1e1e] rounded p-0.5"> |
| 161 | + <button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'all' ? 'bg-[#3c3c3c] text-gray-300' : 'text-gray-600 hover:text-gray-400'}" onclick={() => levelFilter = 'all'}>{t.filterAll || '全部'}</button> |
| 162 | + <button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'error' ? 'bg-[#3c3c3c] text-red-400' : 'text-gray-600 hover:text-red-400'}" onclick={() => levelFilter = levelFilter === 'error' ? 'all' : 'error'}>ERR</button> |
| 163 | + <button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'warn' ? 'bg-[#3c3c3c] text-yellow-400' : 'text-gray-600 hover:text-yellow-400'}" onclick={() => levelFilter = levelFilter === 'warn' ? 'all' : 'warn'}>WARN</button> |
| 164 | + <button class="h-5 px-1.5 text-[10px] rounded transition-colors cursor-pointer {levelFilter === 'success' ? 'bg-[#3c3c3c] text-emerald-400' : 'text-gray-600 hover:text-emerald-400'}" onclick={() => levelFilter = levelFilter === 'success' ? 'all' : 'success'}>OK</button> |
| 165 | + </div> |
| 166 | + <!-- Export --> |
| 167 | + <button |
| 168 | + class="h-6 w-6 flex items-center justify-center text-gray-600 hover:text-gray-300 transition-colors cursor-pointer rounded hover:bg-[#3c3c3c] disabled:opacity-30" |
| 169 | + onclick={exportLogs} |
| 170 | + disabled={logs.length === 0} |
| 171 | + title={t.consoleExport || '导出日志'} |
| 172 | + > |
| 173 | + <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg> |
| 174 | + </button> |
| 175 | + <!-- Clear --> |
| 176 | + <button |
| 177 | + class="h-6 w-6 flex items-center justify-center text-gray-600 hover:text-gray-300 transition-colors cursor-pointer rounded hover:bg-[#3c3c3c] disabled:opacity-30" |
| 178 | + onclick={clearLogs} |
| 179 | + disabled={logs.length === 0} |
| 180 | + title={t.clear} |
| 181 | + > |
| 182 | + <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg> |
| 183 | + </button> |
23 | 184 | </div> |
24 | | - <button |
25 | | - class="text-[11px] text-gray-500 hover:text-gray-300 transition-colors" |
26 | | - onclick={clearLogs} |
27 | | - >{t.clear}</button> |
28 | 185 | </div> |
29 | | - <div class="flex-1 p-4 overflow-auto font-mono text-[12px] leading-5"> |
30 | | - {#each logs as log} |
31 | | - <div class="flex"> |
32 | | - <span class="text-gray-600 select-none">[{log.time}]</span> |
33 | | - <span class="text-gray-300 ml-2">{stripAnsi(log.message)}</span> |
| 186 | + |
| 187 | + <!-- Log area --> |
| 188 | + <div |
| 189 | + class="flex-1 px-4 py-3 overflow-auto font-mono text-[12px] leading-5 relative" |
| 190 | + bind:this={logContainer} |
| 191 | + onscroll={handleScroll} |
| 192 | + > |
| 193 | + {#if filteredLogs.length === 0 && logs.length === 0} |
| 194 | + <!-- Empty state --> |
| 195 | + <div class="flex flex-col items-center justify-center h-full text-gray-600 gap-3"> |
| 196 | + <svg class="w-10 h-10 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1"> |
| 197 | + <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" /> |
| 198 | + </svg> |
| 199 | + <div class="text-[12px]">$ {t.waitOutput}</div> |
| 200 | + <div class="text-[10px] text-gray-700">{t.consoleHint || '部署、启动、停止等操作的日志将在此显示'}</div> |
| 201 | + </div> |
| 202 | + {:else if filteredLogs.length === 0 && logs.length > 0} |
| 203 | + <!-- Filter empty --> |
| 204 | + <div class="flex flex-col items-center justify-center h-full text-gray-600 gap-2"> |
| 205 | + <div class="text-[12px]">{t.consoleNoMatch || '无匹配日志'}</div> |
| 206 | + <button class="text-[11px] text-gray-500 hover:text-gray-300 cursor-pointer" onclick={() => { searchQuery = ''; levelFilter = 'all'; }}>{t.consoleClearFilter || '清除筛选'}</button> |
34 | 207 | </div> |
35 | 208 | {:else} |
36 | | - <div class="text-gray-600">$ {t.waitOutput}</div> |
37 | | - {/each} |
| 209 | + {#each filteredLogs as log, i} |
| 210 | + <!-- Time separator --> |
| 211 | + {#if i > 0 && shouldShowTimeSep(log, filteredLogs[i - 1])} |
| 212 | + <div class="flex items-center gap-2 my-2"> |
| 213 | + <div class="flex-1 border-t border-[#3c3c3c]"></div> |
| 214 | + <span class="text-[9px] text-gray-700 flex-shrink-0">{log.time}</span> |
| 215 | + <div class="flex-1 border-t border-[#3c3c3c]"></div> |
| 216 | + </div> |
| 217 | + {/if} |
| 218 | + {@const level = getLogLevel(log.message)} |
| 219 | + {@const dotClass = getLevelDot(level)} |
| 220 | + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> |
| 221 | + <div |
| 222 | + class="flex items-start group hover:bg-[#2a2a2a] rounded px-1 -mx-1 cursor-pointer transition-colors" |
| 223 | + onclick={() => copyLog(log, log._idx)} |
| 224 | + title={t.consoleCopyLine || '点击复制'} |
| 225 | + > |
| 226 | + {#if dotClass} |
| 227 | + <span class="w-1.5 h-1.5 rounded-full {dotClass} mt-[7px] mr-1.5 flex-shrink-0"></span> |
| 228 | + {:else} |
| 229 | + <span class="w-1.5 mr-1.5 flex-shrink-0"></span> |
| 230 | + {/if} |
| 231 | + <span class="text-gray-600 select-none flex-shrink-0">[{log.time}]</span> |
| 232 | + <span class="{getLevelColor(level)} ml-2 break-all">{stripAnsi(log.message)}</span> |
| 233 | + <!-- Copy indicator --> |
| 234 | + <span class="ml-auto pl-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-gray-600"> |
| 235 | + {#if copiedIdx === log._idx} |
| 236 | + <svg class="w-3 h-3 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg> |
| 237 | + {:else} |
| 238 | + <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></svg> |
| 239 | + {/if} |
| 240 | + </span> |
| 241 | + </div> |
| 242 | + {/each} |
| 243 | + {/if} |
38 | 244 | </div> |
| 245 | + |
| 246 | + <!-- Scroll to bottom button --> |
| 247 | + {#if showScrollBtn} |
| 248 | + <button |
| 249 | + class="absolute bottom-4 right-6 w-8 h-8 bg-[#3c3c3c] hover:bg-[#4c4c4c] text-gray-400 hover:text-gray-200 rounded-full flex items-center justify-center shadow-lg transition-colors cursor-pointer z-10" |
| 250 | + onclick={scrollToBottom} |
| 251 | + title={t.consoleScrollBottom || '跳到底部'} |
| 252 | + > |
| 253 | + <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg> |
| 254 | + </button> |
| 255 | + {/if} |
39 | 256 | </div> |
0 commit comments