diff --git a/web/App.vue b/web/App.vue index 4adf420..f3307dd 100644 --- a/web/App.vue +++ b/web/App.vue @@ -6,6 +6,7 @@ import { NAV } from './nav' import { density, toggleDensity } from './prefs' import { kernel, refreshKernel, reloadRuntime } from './runtime' import { startTelemetry } from './telemetry' +import { startLogStream } from './log-store' import ConfigPage from './views/ConfigPage.vue' import ConnectionsPage from './views/ConnectionsPage.vue' import DashboardPage from './views/DashboardPage.vue' @@ -71,9 +72,10 @@ function submitToken(value: string) { onMounted(() => { if (!needsToken.value) void load() - // 流量和每路吞吐的历史跨页面留存,所以采集挂在外壳上而不是仪表盘里。 + // 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。 // 单例自己跟着 kernel.online 起落,这里只负责点火 startTelemetry() + startLogStream() }) /** diff --git a/web/components/SelectBox.vue b/web/components/SelectBox.vue new file mode 100644 index 0000000..1ec0fa7 --- /dev/null +++ b/web/components/SelectBox.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/web/log-store.ts b/web/log-store.ts new file mode 100644 index 0000000..092b5b1 --- /dev/null +++ b/web/log-store.ts @@ -0,0 +1,116 @@ +/** + * 内核日志的常驻收集。 + * + * 住在模块级单例里而不是请求页组件里,是因为日志要跨页面留存——收集跟着组件 + * 走的话,切走一次列表就清空一次。外壳启动时点火,之后跟着 kernel.online 起落。 + * + * 节奏和缓冲:收到的行先进 pending,每 200ms 一批刷进 rows——日志密集时逐条 + * 触发响应式更新会把界面拖死。暂停就是不刷,缓冲照收(也设上限),恢复时补上。 + */ +import { ref, watch } from 'vue' +import { kernel } from './runtime' +import { LOG_CAP, pushCapped, streamLogs } from './logs' +import type { LogLevel, LogLine } from './logs' + +export interface LogRow extends LogLine { + id: number + /** 收到那一刻的时间。日志行本身不带时间戳 */ + time: string +} + +/** 日志级别。改它会断开重连:内核的级别是建立连接时定的,中途改不了。 */ +export const level = ref('info') +export const paused = ref(false) +export const rows = ref([]) +export const failure = ref('') +/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */ +export const ended = ref(false) +/** 每刷进一批加一。rows 是原地增改,组件要跟随滚动只能看这个信号 */ +export const revision = ref(0) + +let pending: LogRow[] = [] +let nextId = 0 +let aborter: AbortController | null = null +let retryTimer = 0 +let started = false + +const clock = (d: Date): string => + [d.getHours(), d.getMinutes(), d.getSeconds()] + .map((n) => String(n).padStart(2, '0')) + .join(':') + +function flush() { + if (paused.value) { + // 暂停时继续接收但不渲染。缓冲也设上限,暂停再久内存也不涨 + if (pending.length > LOG_CAP) pending.splice(0, pending.length - LOG_CAP) + return + } + if (pending.length === 0) return + pushCapped(rows.value, pending, LOG_CAP) + pending = [] + revision.value += 1 +} + +/** 只清界面上攒下的,内核那边的日志不受影响。 */ +export function clearLogs() { + rows.value = [] + pending = [] + revision.value += 1 +} + +/** + * 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。 + * 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等 + * online 翻转的 watch。 + */ +function scheduleRetry() { + window.clearTimeout(retryTimer) + retryTimer = window.setTimeout(() => { + if (kernel.value.online) void pump() + }, 5000) +} + +async function pump() { + // 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒 + aborter?.abort() + window.clearTimeout(retryTimer) + const controller = new AbortController() + aborter = controller + failure.value = '' + ended.value = false + try { + for await (const line of streamLogs(level.value, controller.signal)) { + pending.push({ ...line, id: nextId++, time: clock(new Date()) }) + } + if (!controller.signal.aborted) { + ended.value = true + scheduleRetry() + } + } catch (error) { + if (controller.signal.aborted) return + failure.value = error instanceof Error ? error.message : String(error) + scheduleRetry() + } +} + +/** 外壳启动时调一次。之后跟着内核在线状态和级别自己重连,应用关掉前不停。 */ +export function startLogStream(): void { + if (started) return + started = true + // 刷新节拍常驻,缓冲空转一次是零开销;应用关掉前不需要清 + window.setInterval(flush, 200) + if (kernel.value.online) void pump() + + // 挂载时内核不在线就不连;等它上线再开始。反方向(流到一半内核掉了) + // 由流自己报错或结束来呈现 + watch( + () => kernel.value.online, + (online) => { + if (online) void pump() + }, + ) + + watch(level, () => { + if (kernel.value.online) void pump() + }) +} diff --git a/web/views/InboundForm.vue b/web/views/InboundForm.vue index b1ed253..1237179 100644 --- a/web/views/InboundForm.vue +++ b/web/views/InboundForm.vue @@ -12,6 +12,8 @@ import { computed, ref, watch } from 'vue' import type { Inbound } from '@shared/inbounds.ts' import type { FieldSpec, ListenerFields } from '../api' import type { ConfigObject } from '../../vendor/config-editor/index.ts' +import SelectBox from '../components/SelectBox.vue' +import type { SelectItem } from '../components/SelectBox.vue' import IconClose from '~icons/ri/close-line' const props = defineProps<{ @@ -42,6 +44,11 @@ const extras = ref>({}) const allTypes = computed(() => Object.keys(props.fields?.types ?? {})) const serverTypes = computed(() => allTypes.value.filter((t) => !COMMON_TYPES.includes(t))) +const typeOptions = computed(() => [ + ...COMMON_TYPES.map((t) => ({ value: t, group: '客户端入站' })), + ...serverTypes.value.map((t) => ({ value: t, group: '服务端入站' })), +]) + /** 当前类型的必填字段,公共字段之外的那些。 */ const requiredExtras = computed( () => props.fields?.types[type.value]?.fields.filter((f) => f.required && !f.key.includes('.')) ?? [], @@ -139,14 +146,7 @@ function submit() { @@ -170,10 +170,12 @@ function submit() {