@liujitcn/kratos-admin-system 0.0.27 → 0.0.29

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/package/declarations/src/api/system/base_i18n.d.ts +5 -5
  3. package/dist/package/declarations/src/api/system/runtime_log.d.ts +13 -0
  4. package/dist/package/declarations/src/api/system/runtime_log_sse.d.ts +8 -0
  5. package/dist/package/declarations/src/components/DynamicI18nCell.vue.d.ts +16 -0
  6. package/dist/package/declarations/src/components/DynamicI18nEditor.vue.d.ts +20 -0
  7. package/dist/package/declarations/src/components/dynamicI18n.d.ts +38 -0
  8. package/dist/package/declarations/src/locales/generated.d.ts +168 -52
  9. package/dist/package/declarations/src/rpc/system/admin/v1/base_config.d.ts +3 -3
  10. package/dist/package/declarations/src/rpc/system/admin/v1/base_dict.d.ts +4 -4
  11. package/dist/package/declarations/src/rpc/system/admin/v1/base_i18n.d.ts +23 -23
  12. package/dist/package/declarations/src/rpc/system/admin/v1/base_menu.d.ts +2 -2
  13. package/dist/package/declarations/src/rpc/system/admin/v1/code_gen.d.ts +1 -1
  14. package/dist/package/declarations/src/rpc/system/admin/v1/ops_monitoring.d.ts +4 -0
  15. package/dist/package/declarations/src/rpc/system/admin/v1/project_document.d.ts +4 -6
  16. package/dist/package/declarations/src/rpc/system/admin/v1/runtime_log.d.ts +96 -0
  17. package/dist/package/declarations/src/views/tool/runtime-log/index.vue.d.ts +3 -0
  18. package/dist/package/src/api/system/base_i18n.ts +12 -12
  19. package/dist/package/src/api/system/runtime_log.ts +80 -0
  20. package/dist/package/src/api/system/runtime_log_sse.ts +40 -0
  21. package/dist/package/src/components/{DynamicTranslationCell.vue → DynamicI18nCell.vue} +50 -50
  22. package/dist/package/src/components/{DynamicTranslationEditor.vue → DynamicI18nEditor.vue} +45 -45
  23. package/dist/package/src/components/{dynamicTranslation.ts → dynamicI18n.ts} +15 -15
  24. package/dist/package/src/locales/README.md +1 -1
  25. package/dist/package/src/locales/en-US.json +46 -17
  26. package/dist/package/src/locales/ja-JP.json +44 -15
  27. package/dist/package/src/locales/zh-CN.json +44 -15
  28. package/dist/package/src/locales/zh-TW.json +44 -15
  29. package/dist/package/src/rpc/system/admin/v1/base_config.ts +3 -3
  30. package/dist/package/src/rpc/system/admin/v1/base_dict.ts +4 -4
  31. package/dist/package/src/rpc/system/admin/v1/base_i18n.ts +23 -23
  32. package/dist/package/src/rpc/system/admin/v1/base_menu.ts +2 -2
  33. package/dist/package/src/rpc/system/admin/v1/code_gen.ts +1 -1
  34. package/dist/package/src/rpc/system/admin/v1/ops_monitoring.ts +4 -0
  35. package/dist/package/src/rpc/system/admin/v1/project_document.ts +4 -7
  36. package/dist/package/src/rpc/system/admin/v1/runtime_log.ts +113 -0
  37. package/dist/package/src/views/base/config/index.vue +38 -38
  38. package/dist/package/src/views/base/dict/index.vue +23 -23
  39. package/dist/package/src/views/base/dict/item.vue +25 -25
  40. package/dist/package/src/views/base/menu/index.vue +23 -23
  41. package/dist/package/src/views/tool/api-doc/index.vue +14 -6
  42. package/dist/package/src/views/tool/code-gen/code-preview/index.vue +23 -6
  43. package/dist/package/src/views/tool/code-gen/columns/index.vue +1 -1
  44. package/dist/package/src/views/tool/code-gen/components/CodeGenLocaleEditor.vue +96 -14
  45. package/dist/package/src/views/tool/code-gen/table/index.vue +33 -1
  46. package/dist/package/src/views/tool/ops-monitoring/index.vue +130 -24
  47. package/dist/package/src/views/tool/project-doc/index.vue +9 -4
  48. package/dist/package/src/views/tool/runtime-log/index.vue +883 -0
  49. package/package.json +2 -2
  50. package/dist/package/declarations/src/components/DynamicTranslationCell.vue.d.ts +0 -16
  51. package/dist/package/declarations/src/components/DynamicTranslationEditor.vue.d.ts +0 -20
  52. package/dist/package/declarations/src/components/dynamicTranslation.d.ts +0 -38
@@ -0,0 +1,883 @@
1
+ <template>
2
+ <div class="app-container runtime-log-page">
3
+ <aside class="runtime-log-sources">
4
+ <div class="source-panel__header">
5
+ <h2>{{ t("system.runtime.log.section.sources") }}</h2>
6
+ <el-tooltip :content="t('common.action.refresh')" placement="top">
7
+ <el-button circle text :icon="Refresh" :loading="filesLoading" @click="loadFiles" />
8
+ </el-tooltip>
9
+ </div>
10
+
11
+ <button
12
+ type="button"
13
+ class="source-item source-item--live"
14
+ :class="{ 'is-active': activeSource.kind === 'live' }"
15
+ @click="selectLiveConsole"
16
+ >
17
+ <span class="source-item__icon"><el-icon><Operation /></el-icon></span>
18
+ <span class="source-item__body">
19
+ <strong>{{ t("system.runtime.log.value.live_console") }}</strong>
20
+ <small>
21
+ <i class="status-dot" :class="`is-${connectionState}`" />
22
+ {{ connectionStatusText }}
23
+ </small>
24
+ </span>
25
+ </button>
26
+
27
+ <div class="source-panel__section">
28
+ <div class="source-panel__label">
29
+ <span>{{ t("system.runtime.log.section.history_files") }}</span>
30
+ <span>{{ filteredFiles.length }}</span>
31
+ </div>
32
+ <el-input
33
+ v-model="fileKeyword"
34
+ clearable
35
+ :prefix-icon="Search"
36
+ :placeholder="t('system.runtime.log.placeholder.search_file')"
37
+ />
38
+ </div>
39
+
40
+ <el-scrollbar class="source-panel__list">
41
+ <button
42
+ v-for="file in filteredFiles"
43
+ :key="file.file_id"
44
+ type="button"
45
+ class="source-item source-item--file"
46
+ :class="{ 'is-active': activeFile?.file_id === file.file_id }"
47
+ @click="selectHistoryFile(file)"
48
+ >
49
+ <span class="source-item__icon"><el-icon><Document /></el-icon></span>
50
+ <span class="source-item__body">
51
+ <strong :title="file.name">{{ file.name }}</strong>
52
+ <small>{{ formatFileSize(file.size_bytes) }} · {{ formatDateTime(file.modified_at) }}</small>
53
+ </span>
54
+ <el-tag v-if="file.is_compressed" size="small" effect="plain">GZ</el-tag>
55
+ </button>
56
+ <el-empty
57
+ v-if="!filesLoading && filteredFiles.length === 0"
58
+ :image-size="64"
59
+ :description="t('system.runtime.log.empty.files')"
60
+ />
61
+ </el-scrollbar>
62
+ </aside>
63
+
64
+ <main class="runtime-log-workspace">
65
+ <header class="workspace-header">
66
+ <div class="workspace-title">
67
+ <div class="workspace-title__row">
68
+ <h1 :title="activeTitle">{{ activeTitle }}</h1>
69
+ <el-tag v-if="activeSource.kind === 'live'" size="small" :type="connectionTagType" effect="plain">
70
+ {{ connectionStatusText }}
71
+ </el-tag>
72
+ <el-tag v-else-if="activeFile?.is_compressed" size="small" effect="plain">GZIP</el-tag>
73
+ </div>
74
+ <p v-if="activeSource.kind === 'live'">
75
+ {{ t("system.runtime.log.value.instance", { id: instanceId || "-" }) }}
76
+ </p>
77
+ <p v-else-if="activeFile">
78
+ {{ formatFileSize(activeFile.size_bytes) }} · {{ formatDateTime(activeFile.modified_at) }}
79
+ </p>
80
+ </div>
81
+
82
+ <div class="workspace-actions">
83
+ <el-tooltip
84
+ v-if="activeSource.kind === 'live'"
85
+ :content="paused ? t('system.runtime.log.action.resume') : t('system.runtime.log.action.pause')"
86
+ placement="top"
87
+ >
88
+ <el-button
89
+ circle
90
+ :icon="paused ? VideoPlay : VideoPause"
91
+ :disabled="connectionState === 'connecting'"
92
+ @click="togglePause"
93
+ />
94
+ </el-tooltip>
95
+ <el-tooltip v-else :content="t('common.action.refresh')" placement="top">
96
+ <el-button circle :icon="Refresh" :loading="contentLoading" @click="refreshHistory" />
97
+ </el-tooltip>
98
+ <el-tooltip v-if="activeFile" :content="t('system.runtime.log.action.download')" placement="top">
99
+ <el-button
100
+ circle
101
+ type="primary"
102
+ :icon="Download"
103
+ :loading="downloadLoading"
104
+ @click="downloadHistoryFile"
105
+ />
106
+ </el-tooltip>
107
+ </div>
108
+ </header>
109
+
110
+ <div class="workspace-filters">
111
+ <el-input
112
+ v-model="keyword"
113
+ clearable
114
+ :prefix-icon="Search"
115
+ :placeholder="t('system.runtime.log.placeholder.search_content')"
116
+ />
117
+ <el-select
118
+ v-model="selectedLevels"
119
+ multiple
120
+ collapse-tags
121
+ collapse-tags-tooltip
122
+ :max-collapse-tags="2"
123
+ :placeholder="t('system.runtime.log.placeholder.level')"
124
+ >
125
+ <el-option v-for="level in levelOptions" :key="level" :label="level" :value="level" />
126
+ </el-select>
127
+ <label class="auto-scroll-control">
128
+ <span>{{ t("system.runtime.log.action.auto_scroll") }}</span>
129
+ <el-switch v-model="autoScroll" />
130
+ </label>
131
+ <span class="entry-count">{{ t("system.runtime.log.value.entry_count", { count: displayedEntries.length }) }}</span>
132
+ </div>
133
+
134
+ <el-alert
135
+ v-if="gapCount > 0"
136
+ class="runtime-gap-alert"
137
+ type="warning"
138
+ show-icon
139
+ :closable="true"
140
+ :title="t('system.runtime.log.message.gap', { count: gapCount })"
141
+ @close="gapCount = 0"
142
+ />
143
+
144
+ <section class="log-viewer" :aria-label="t('system.runtime.log.section.viewer')">
145
+ <el-scrollbar ref="scrollbarRef" class="log-viewer__scroll" always>
146
+ <div class="log-viewer__content">
147
+ <div v-if="activeFile && hasMore" class="load-more-row">
148
+ <el-button text type="primary" :loading="olderLoading" @click="loadOlderHistory">
149
+ {{ t("system.runtime.log.action.load_older") }}
150
+ </el-button>
151
+ </div>
152
+ <div
153
+ v-for="(entry, index) in displayedEntries"
154
+ :key="entryKey(entry, index)"
155
+ class="log-line"
156
+ :class="`is-${entryLevel(entry)}`"
157
+ >
158
+ <span class="log-line__number">{{ index + 1 }}</span>
159
+ <span v-if="entry.level" class="log-line__level">{{ entry.level }}</span>
160
+ <span class="log-line__text">{{ entryLine(entry) }}</span>
161
+ </div>
162
+ <el-empty
163
+ v-if="!contentLoading && displayedEntries.length === 0"
164
+ :image-size="72"
165
+ :description="t('system.runtime.log.empty.entries')"
166
+ />
167
+ </div>
168
+ </el-scrollbar>
169
+ <div v-if="contentLoading" class="log-viewer__loading">
170
+ <el-icon class="is-loading"><Refresh /></el-icon>
171
+ <span>{{ t("system.runtime.log.status.loading") }}</span>
172
+ </div>
173
+ </section>
174
+ </main>
175
+ </div>
176
+ </template>
177
+
178
+ <script setup lang="ts">
179
+ import { Document, Download, Operation, Refresh, Search, VideoPause, VideoPlay } from "@element-plus/icons-vue";
180
+ import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
181
+ import { getCurrentLocale, t } from "@liujitcn/kratos-admin-core";
182
+ import { defRuntimeLogService } from "@liujitcn/kratos-admin-system/api/system/runtime_log";
183
+ import { subscribeRuntimeLog } from "@liujitcn/kratos-admin-system/api/system/runtime_log_sse";
184
+ import type {
185
+ RuntimeLogEntry,
186
+ RuntimeLogFile
187
+ } from "@liujitcn/kratos-admin-system/rpc/system/admin/v1/runtime_log";
188
+
189
+ defineOptions({
190
+ name: "RuntimeLog",
191
+ inheritAttrs: false
192
+ });
193
+
194
+ /** 当前日志来源。 */
195
+ type RuntimeLogSource = { kind: "live" } | { kind: "file"; file: RuntimeLogFile };
196
+
197
+ /** 实时控制台连接状态。 */
198
+ type RuntimeConnectionState = "connecting" | "live" | "paused" | "disconnected";
199
+
200
+ /** Element Plus 滚动条公开能力。 */
201
+ interface RuntimeLogScrollbar {
202
+ /** 滚动条内容元素。 */
203
+ wrapRef?: HTMLElement;
204
+ /** 设置垂直滚动位置。 */
205
+ setScrollTop: (top: number) => void;
206
+ }
207
+
208
+ const maxLiveEntries = 2000;
209
+ const levelOptions = ["DEBUG", "INFO", "WARN", "ERROR"];
210
+ const files = ref<RuntimeLogFile[]>([]);
211
+ const entries = ref<RuntimeLogEntry[]>([]);
212
+ const pendingEntries = ref<RuntimeLogEntry[]>([]);
213
+ const activeSource = ref<RuntimeLogSource>({ kind: "live" });
214
+ const connectionState = ref<RuntimeConnectionState>("connecting");
215
+ const filesLoading = ref(false);
216
+ const contentLoading = ref(false);
217
+ const olderLoading = ref(false);
218
+ const downloadLoading = ref(false);
219
+ const paused = ref(false);
220
+ const autoScroll = ref(true);
221
+ const keyword = ref("");
222
+ const fileKeyword = ref("");
223
+ const selectedLevels = ref<string[]>([]);
224
+ const nextCursor = ref("");
225
+ const hasMore = ref(false);
226
+ const instanceId = ref("");
227
+ const gapCount = ref(0);
228
+ const scrollbarRef = ref<RuntimeLogScrollbar>();
229
+ let stopRuntimeLog: (() => void) | undefined;
230
+ let filterTimer: ReturnType<typeof setTimeout> | undefined;
231
+ let activeRequestId = 0;
232
+
233
+ const activeFile = computed(() => (activeSource.value.kind === "file" ? activeSource.value.file : undefined));
234
+ const activeTitle = computed(() => activeFile.value?.name ?? t("system.runtime.log.value.live_console"));
235
+ const filteredFiles = computed(() => {
236
+ const search = fileKeyword.value.toLowerCase();
237
+ return search ? files.value.filter(file => file.name.toLowerCase().includes(search)) : files.value;
238
+ });
239
+ const displayedEntries = computed(() => {
240
+ const search = keyword.value.toLowerCase();
241
+ const levelSet = new Set(selectedLevels.value);
242
+ return entries.value.filter(entry => {
243
+ if (levelSet.size > 0 && !levelSet.has(entry.level)) return false;
244
+ return !search || entryLine(entry).toLowerCase().includes(search);
245
+ });
246
+ });
247
+ const connectionStatusText = computed(() => t(`system.runtime.log.status.${connectionState.value}`));
248
+ const connectionTagType = computed(() => {
249
+ if (connectionState.value === "live") return "success";
250
+ if (connectionState.value === "paused") return "warning";
251
+ return "info";
252
+ });
253
+
254
+ /** 查询左侧历史日志文件列表。 */
255
+ async function loadFiles() {
256
+ filesLoading.value = true;
257
+ try {
258
+ const response = await defRuntimeLogService.ListRuntimeLogFiles({});
259
+ files.value = response.files ?? [];
260
+ if (activeFile.value) {
261
+ const refreshed = files.value.find(file => file.name === activeFile.value?.name);
262
+ if (refreshed) activeSource.value = { kind: "file", file: refreshed };
263
+ }
264
+ } finally {
265
+ filesLoading.value = false;
266
+ }
267
+ }
268
+
269
+ /** 切换到实时控制台。 */
270
+ function selectLiveConsole() {
271
+ activeSource.value = { kind: "live" };
272
+ entries.value = [];
273
+ pendingEntries.value = [];
274
+ nextCursor.value = "";
275
+ hasMore.value = false;
276
+ paused.value = false;
277
+ const requestId = ++activeRequestId;
278
+ void openRuntimeConsole(requestId);
279
+ }
280
+
281
+ /** 创建并订阅当前用户的实时控制台频道。 */
282
+ async function openRuntimeConsole(requestId: number) {
283
+ stopRuntimeSubscription();
284
+ connectionState.value = "connecting";
285
+ contentLoading.value = true;
286
+ try {
287
+ const response = await defRuntimeLogService.OpenRuntimeConsole({ backlog_limit: 300 });
288
+ if (requestId !== activeRequestId || activeSource.value.kind !== "live") return;
289
+ entries.value = (response.entries ?? []).slice(-maxLiveEntries);
290
+ instanceId.value = response.instance_id;
291
+ connectionState.value = "live";
292
+ stopRuntimeLog = subscribeRuntimeLog(
293
+ response.channel_id,
294
+ entry => appendRuntimeEntry(entry),
295
+ gap => {
296
+ gapCount.value += Number(gap.dropped_count || 0);
297
+ }
298
+ );
299
+ scrollToBottom();
300
+ } catch {
301
+ if (requestId === activeRequestId) connectionState.value = "disconnected";
302
+ } finally {
303
+ if (requestId === activeRequestId) contentLoading.value = false;
304
+ }
305
+ }
306
+
307
+ /** 追加一条实时日志,并在暂停时暂存。 */
308
+ function appendRuntimeEntry(entry: RuntimeLogEntry) {
309
+ if (activeSource.value.kind !== "live") return;
310
+ if (paused.value) {
311
+ pendingEntries.value.push(entry);
312
+ if (pendingEntries.value.length > maxLiveEntries) pendingEntries.value.splice(0, pendingEntries.value.length - maxLiveEntries);
313
+ return;
314
+ }
315
+ entries.value.push(entry);
316
+ if (entries.value.length > maxLiveEntries) entries.value.splice(0, entries.value.length - maxLiveEntries);
317
+ scrollToBottom();
318
+ }
319
+
320
+ /** 暂停或继续实时日志展示。 */
321
+ function togglePause() {
322
+ paused.value = !paused.value;
323
+ connectionState.value = paused.value ? "paused" : "live";
324
+ if (!paused.value && pendingEntries.value.length > 0) {
325
+ entries.value.push(...pendingEntries.value);
326
+ pendingEntries.value = [];
327
+ if (entries.value.length > maxLiveEntries) entries.value.splice(0, entries.value.length - maxLiveEntries);
328
+ scrollToBottom();
329
+ }
330
+ }
331
+
332
+ /** 切换到指定历史日志文件。 */
333
+ function selectHistoryFile(file: RuntimeLogFile) {
334
+ stopRuntimeSubscription();
335
+ activeSource.value = { kind: "file", file };
336
+ entries.value = [];
337
+ nextCursor.value = "";
338
+ hasMore.value = false;
339
+ paused.value = false;
340
+ connectionState.value = "disconnected";
341
+ void refreshHistory();
342
+ }
343
+
344
+ /** 从文件尾部重新读取当前历史日志。 */
345
+ async function refreshHistory() {
346
+ if (!activeFile.value) return;
347
+ const requestId = ++activeRequestId;
348
+ contentLoading.value = true;
349
+ nextCursor.value = "";
350
+ hasMore.value = false;
351
+ try {
352
+ const response = await readActiveHistory("");
353
+ if (requestId !== activeRequestId) return;
354
+ if (response.file_changed) {
355
+ ElMessage.warning(t("system.runtime.log.message.file_changed"));
356
+ return;
357
+ }
358
+ entries.value = response.entries ?? [];
359
+ nextCursor.value = response.next_cursor;
360
+ hasMore.value = response.has_more;
361
+ scrollToBottom();
362
+ } finally {
363
+ if (requestId === activeRequestId) contentLoading.value = false;
364
+ }
365
+ }
366
+
367
+ /** 读取当前历史日志的更早一页。 */
368
+ async function loadOlderHistory() {
369
+ if (!activeFile.value || !hasMore.value || olderLoading.value) return;
370
+ const requestId = activeRequestId;
371
+ olderLoading.value = true;
372
+ try {
373
+ const response = await readActiveHistory(nextCursor.value);
374
+ if (requestId !== activeRequestId) return;
375
+ if (response.file_changed) {
376
+ ElMessage.warning(t("system.runtime.log.message.file_changed"));
377
+ await loadFiles();
378
+ await refreshHistory();
379
+ return;
380
+ }
381
+ entries.value = [...(response.entries ?? []), ...entries.value];
382
+ nextCursor.value = response.next_cursor;
383
+ hasMore.value = response.has_more;
384
+ } finally {
385
+ olderLoading.value = false;
386
+ }
387
+ }
388
+
389
+ /** 按当前筛选条件读取历史日志。 */
390
+ function readActiveHistory(cursor: string) {
391
+ const file = activeFile.value;
392
+ if (!file) throw new Error("runtime log file is not selected");
393
+ return defRuntimeLogService.ReadRuntimeLogFile({
394
+ file_id: file.file_id,
395
+ cursor,
396
+ limit: 300,
397
+ keyword: keyword.value,
398
+ levels: selectedLevels.value
399
+ });
400
+ }
401
+
402
+ /** 下载当前选中的历史日志原文件。 */
403
+ async function downloadHistoryFile() {
404
+ const file = activeFile.value;
405
+ if (!file) return;
406
+ downloadLoading.value = true;
407
+ try {
408
+ await defRuntimeLogService.DownloadRuntimeLogFile({ file_id: file.file_id }, file.name);
409
+ ElMessage.success(t("system.runtime.log.message.download_started"));
410
+ } finally {
411
+ downloadLoading.value = false;
412
+ }
413
+ }
414
+
415
+ /** 关闭当前实时日志 SSE 订阅。 */
416
+ function stopRuntimeSubscription() {
417
+ stopRuntimeLog?.();
418
+ stopRuntimeLog = undefined;
419
+ }
420
+
421
+ /** 在启用自动滚动时移动到日志底部。 */
422
+ function scrollToBottom() {
423
+ if (!autoScroll.value) return;
424
+ void nextTick(() => {
425
+ const wrap = scrollbarRef.value?.wrapRef;
426
+ if (wrap) scrollbarRef.value?.setScrollTop(wrap.scrollHeight);
427
+ });
428
+ }
429
+
430
+ /** 返回稳定的日志行渲染键。 */
431
+ function entryKey(entry: RuntimeLogEntry, index: number) {
432
+ return entry.sequence || `${entry.timestamp ?? ""}:${index}:${entryLine(entry)}`;
433
+ }
434
+
435
+ /** 返回日志级别对应的样式名称,兼容 Proto JSON 省略空字段。 */
436
+ function entryLevel(entry: RuntimeLogEntry) {
437
+ return String(entry.level ?? "").toLowerCase() || "unknown";
438
+ }
439
+
440
+ /** 返回可展示的日志正文,兼容 Proto JSON 省略空字段。 */
441
+ function entryLine(entry: RuntimeLogEntry) {
442
+ return String(entry.line ?? entry.message ?? "");
443
+ }
444
+
445
+ /** 格式化日志文件大小。 */
446
+ function formatFileSize(rawBytes: number) {
447
+ const bytes = Number(rawBytes || 0);
448
+ if (bytes < 1024) return `${bytes} B`;
449
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
450
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
451
+ }
452
+
453
+ /** 格式化日志文件时间。 */
454
+ function formatDateTime(value: string) {
455
+ if (!value) return "-";
456
+ const date = new Date(value);
457
+ if (Number.isNaN(date.getTime())) return value;
458
+ return new Intl.DateTimeFormat(getCurrentLocale(), {
459
+ month: "2-digit",
460
+ day: "2-digit",
461
+ hour: "2-digit",
462
+ minute: "2-digit"
463
+ }).format(date);
464
+ }
465
+
466
+ watch([keyword, selectedLevels], () => {
467
+ if (!activeFile.value) return;
468
+ if (filterTimer) clearTimeout(filterTimer);
469
+ filterTimer = setTimeout(() => void refreshHistory(), 300);
470
+ });
471
+
472
+ onMounted(() => {
473
+ void loadFiles();
474
+ selectLiveConsole();
475
+ });
476
+
477
+ onActivated(() => {
478
+ if (activeSource.value.kind === "live" && !stopRuntimeLog && connectionState.value !== "connecting") {
479
+ const requestId = ++activeRequestId;
480
+ void openRuntimeConsole(requestId);
481
+ }
482
+ });
483
+
484
+ onDeactivated(stopRuntimeSubscription);
485
+
486
+ onBeforeUnmount(() => {
487
+ if (filterTimer) clearTimeout(filterTimer);
488
+ stopRuntimeSubscription();
489
+ });
490
+ </script>
491
+
492
+ <style scoped lang="scss">
493
+ .runtime-log-page {
494
+ display: grid;
495
+ grid-template-columns: minmax(240px, 286px) minmax(0, 1fr);
496
+ height: calc(100vh - 142px);
497
+ min-height: 560px;
498
+ padding: 0;
499
+ overflow: hidden;
500
+ color: var(--admin-page-text-primary);
501
+ background: var(--admin-page-card-bg);
502
+ border: 1px solid var(--admin-page-card-border-soft);
503
+ border-radius: 6px;
504
+ }
505
+
506
+ .runtime-log-sources {
507
+ display: flex;
508
+ min-width: 0;
509
+ min-height: 0;
510
+ flex-direction: column;
511
+ padding: 14px 12px;
512
+ background: var(--admin-page-card-bg-soft);
513
+ border-right: 1px solid var(--admin-page-divider);
514
+ }
515
+
516
+ .source-panel__header,
517
+ .source-panel__label,
518
+ .workspace-header,
519
+ .workspace-filters,
520
+ .workspace-title__row,
521
+ .workspace-actions,
522
+ .auto-scroll-control {
523
+ display: flex;
524
+ align-items: center;
525
+ }
526
+
527
+ .source-panel__header {
528
+ justify-content: space-between;
529
+ padding: 0 4px 10px;
530
+ }
531
+
532
+ .source-panel__header h2,
533
+ .workspace-title h1 {
534
+ min-width: 0;
535
+ margin: 0;
536
+ overflow: hidden;
537
+ font-size: 15px;
538
+ font-weight: 650;
539
+ letter-spacing: 0;
540
+ text-overflow: ellipsis;
541
+ white-space: nowrap;
542
+ }
543
+
544
+ .source-item {
545
+ display: flex;
546
+ width: 100%;
547
+ min-height: 54px;
548
+ align-items: center;
549
+ gap: 10px;
550
+ padding: 8px 10px;
551
+ color: var(--admin-page-text-primary);
552
+ text-align: left;
553
+ cursor: pointer;
554
+ background: transparent;
555
+ border: 1px solid transparent;
556
+ border-radius: 5px;
557
+ }
558
+
559
+ .source-item:hover {
560
+ background: var(--el-fill-color);
561
+ }
562
+
563
+ .source-item.is-active {
564
+ background: var(--el-color-primary-light-9);
565
+ border-color: var(--el-color-primary-light-5);
566
+ }
567
+
568
+ .source-item__icon {
569
+ display: grid;
570
+ width: 32px;
571
+ height: 32px;
572
+ flex: 0 0 32px;
573
+ place-items: center;
574
+ color: var(--el-color-primary);
575
+ background: var(--el-color-primary-light-9);
576
+ border-radius: 5px;
577
+ }
578
+
579
+ .source-item__body {
580
+ display: flex;
581
+ min-width: 0;
582
+ flex: 1;
583
+ flex-direction: column;
584
+ gap: 4px;
585
+ }
586
+
587
+ .source-item__body strong,
588
+ .source-item__body small {
589
+ overflow: hidden;
590
+ text-overflow: ellipsis;
591
+ white-space: nowrap;
592
+ }
593
+
594
+ .source-item__body strong {
595
+ font-size: 13px;
596
+ font-weight: 600;
597
+ }
598
+
599
+ .source-item__body small {
600
+ color: var(--admin-page-text-placeholder);
601
+ font-size: 11px;
602
+ }
603
+
604
+ .status-dot {
605
+ display: inline-block;
606
+ width: 7px;
607
+ height: 7px;
608
+ margin-right: 4px;
609
+ background: var(--el-text-color-placeholder);
610
+ border-radius: 50%;
611
+ }
612
+
613
+ .status-dot.is-live {
614
+ background: var(--el-color-success);
615
+ }
616
+
617
+ .status-dot.is-paused {
618
+ background: var(--el-color-warning);
619
+ }
620
+
621
+ .status-dot.is-connecting {
622
+ background: var(--el-color-primary);
623
+ }
624
+
625
+ .status-dot.is-disconnected {
626
+ background: var(--el-color-danger);
627
+ }
628
+
629
+ .source-panel__section {
630
+ padding: 18px 4px 8px;
631
+ }
632
+
633
+ .source-panel__label {
634
+ justify-content: space-between;
635
+ margin-bottom: 8px;
636
+ color: var(--admin-page-text-secondary);
637
+ font-size: 12px;
638
+ font-weight: 600;
639
+ }
640
+
641
+ .source-panel__list {
642
+ min-height: 0;
643
+ flex: 1;
644
+ margin-top: 4px;
645
+ }
646
+
647
+ .source-item--file + .source-item--file {
648
+ margin-top: 2px;
649
+ }
650
+
651
+ .runtime-log-workspace {
652
+ display: flex;
653
+ min-width: 0;
654
+ min-height: 0;
655
+ flex-direction: column;
656
+ overflow: hidden;
657
+ }
658
+
659
+ .workspace-header {
660
+ min-height: 72px;
661
+ justify-content: space-between;
662
+ gap: 16px;
663
+ padding: 12px 18px;
664
+ border-bottom: 1px solid var(--admin-page-divider);
665
+ }
666
+
667
+ .workspace-title {
668
+ min-width: 0;
669
+ }
670
+
671
+ .workspace-title__row {
672
+ min-width: 0;
673
+ gap: 8px;
674
+ }
675
+
676
+ .workspace-title p {
677
+ margin: 5px 0 0;
678
+ color: var(--admin-page-text-placeholder);
679
+ font-size: 11px;
680
+ }
681
+
682
+ .workspace-actions {
683
+ flex: 0 0 auto;
684
+ gap: 6px;
685
+ }
686
+
687
+ .workspace-filters {
688
+ min-height: 54px;
689
+ gap: 10px;
690
+ padding: 9px 18px;
691
+ background: var(--admin-page-card-bg-soft);
692
+ border-bottom: 1px solid var(--admin-page-divider);
693
+ }
694
+
695
+ .workspace-filters :deep(.el-input) {
696
+ width: min(320px, 32vw);
697
+ }
698
+
699
+ .workspace-filters :deep(.el-select) {
700
+ width: 210px;
701
+ }
702
+
703
+ .auto-scroll-control {
704
+ flex: 0 0 auto;
705
+ gap: 8px;
706
+ color: var(--admin-page-text-secondary);
707
+ font-size: 12px;
708
+ }
709
+
710
+ .entry-count {
711
+ margin-left: auto;
712
+ color: var(--admin-page-text-placeholder);
713
+ font-size: 12px;
714
+ white-space: nowrap;
715
+ }
716
+
717
+ .runtime-gap-alert {
718
+ border-radius: 0;
719
+ }
720
+
721
+ .log-viewer {
722
+ position: relative;
723
+ min-height: 0;
724
+ flex: 1;
725
+ overflow: hidden;
726
+ background: var(--el-bg-color-page);
727
+ }
728
+
729
+ .log-viewer__scroll {
730
+ height: 100%;
731
+ }
732
+
733
+ .log-viewer__content {
734
+ min-width: 760px;
735
+ min-height: 100%;
736
+ padding: 10px 0 18px;
737
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
738
+ }
739
+
740
+ .load-more-row {
741
+ display: flex;
742
+ justify-content: center;
743
+ padding: 2px 0 8px;
744
+ }
745
+
746
+ .log-line {
747
+ display: grid;
748
+ grid-template-columns: 54px 58px minmax(0, 1fr);
749
+ min-height: 25px;
750
+ align-items: start;
751
+ padding: 3px 16px 3px 0;
752
+ border-left: 2px solid transparent;
753
+ }
754
+
755
+ .log-line:hover {
756
+ background: var(--el-fill-color-light);
757
+ }
758
+
759
+ .log-line.is-error {
760
+ background: var(--el-color-danger-light-9);
761
+ border-left-color: var(--el-color-danger);
762
+ }
763
+
764
+ .log-line.is-warn {
765
+ background: var(--el-color-warning-light-9);
766
+ border-left-color: var(--el-color-warning);
767
+ }
768
+
769
+ .log-line__number {
770
+ padding-right: 12px;
771
+ color: var(--el-text-color-placeholder);
772
+ font-size: 11px;
773
+ line-height: 19px;
774
+ text-align: right;
775
+ user-select: none;
776
+ }
777
+
778
+ .log-line__level {
779
+ width: fit-content;
780
+ min-width: 42px;
781
+ padding: 1px 5px;
782
+ color: var(--el-text-color-regular);
783
+ font-size: 10px;
784
+ font-weight: 700;
785
+ line-height: 16px;
786
+ text-align: center;
787
+ background: var(--el-fill-color);
788
+ border-radius: 3px;
789
+ }
790
+
791
+ .log-line.is-debug .log-line__level {
792
+ color: var(--el-color-info);
793
+ }
794
+
795
+ .log-line.is-info .log-line__level {
796
+ color: var(--el-color-success);
797
+ }
798
+
799
+ .log-line.is-warn .log-line__level {
800
+ color: var(--el-color-warning-dark-2);
801
+ }
802
+
803
+ .log-line.is-error .log-line__level {
804
+ color: var(--el-color-danger);
805
+ }
806
+
807
+ .log-line__text {
808
+ min-width: 0;
809
+ color: var(--admin-page-text-secondary);
810
+ font-size: 12px;
811
+ line-height: 19px;
812
+ overflow-wrap: anywhere;
813
+ white-space: pre-wrap;
814
+ }
815
+
816
+ .log-viewer__loading {
817
+ position: absolute;
818
+ inset: 0;
819
+ display: flex;
820
+ align-items: center;
821
+ justify-content: center;
822
+ gap: 8px;
823
+ color: var(--admin-page-text-secondary);
824
+ background: color-mix(in srgb, var(--el-bg-color-page) 82%, transparent);
825
+ }
826
+
827
+ @media (max-width: 900px) {
828
+ .runtime-log-page {
829
+ grid-template-columns: 220px minmax(0, 1fr);
830
+ }
831
+
832
+ .workspace-filters {
833
+ flex-wrap: wrap;
834
+ }
835
+
836
+ .workspace-filters :deep(.el-input) {
837
+ width: min(100%, 320px);
838
+ }
839
+
840
+ .entry-count {
841
+ display: none;
842
+ }
843
+ }
844
+
845
+ @media (max-width: 680px) {
846
+ .runtime-log-page {
847
+ display: flex;
848
+ height: auto;
849
+ min-height: calc(100vh - 120px);
850
+ flex-direction: column;
851
+ overflow: visible;
852
+ }
853
+
854
+ .runtime-log-sources {
855
+ max-height: 310px;
856
+ border-right: 0;
857
+ border-bottom: 1px solid var(--admin-page-divider);
858
+ }
859
+
860
+ .source-panel__list {
861
+ min-height: 120px;
862
+ }
863
+
864
+ .runtime-log-workspace {
865
+ min-height: 560px;
866
+ }
867
+
868
+ .workspace-header,
869
+ .workspace-filters {
870
+ padding-right: 12px;
871
+ padding-left: 12px;
872
+ }
873
+
874
+ .workspace-filters :deep(.el-input),
875
+ .workspace-filters :deep(.el-select) {
876
+ width: 100%;
877
+ }
878
+
879
+ .auto-scroll-control {
880
+ margin-left: auto;
881
+ }
882
+ }
883
+ </style>