日志改常驻收集,加清空按钮;原生 select 统一换成 SelectBox
日志收集从请求页组件搬进模块级单例(web/log-store.ts,模式同 telemetry), 切走页面不再清空;离线时保留已收的日志,只提示不再更新。清空只清界面。 五处原生 <select> 在三个文件里各自重复同一套自绘箭头样式,而且原生控件 没有「展开中」状态可读,箭头做不了开合动画——提成 SelectBox 组件统一替换: 箭头随开合旋转 180 度,弹层带出入场动画,下方视口不够时向上弹。背景色 留 --select-bg 口子,对话框里沉一档、页面上浮一档,一个值满足不了两边。
This commit is contained in:
+3
-1
@@ -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()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 单选下拉,替代原生 <select>。
|
||||
*
|
||||
* 换成自绘只为一件事:原生控件没有「展开中」这个可读状态,右侧箭头做不了
|
||||
* 开合动画,弹层样式也管不到。交互仿 SourcePicker(同一族控件):点开列出
|
||||
* 全部选项、勾标当前选中;上下键移动、Enter 选中、Esc 只收弹层不关对话框。
|
||||
*
|
||||
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
|
||||
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import IconCheck from '~icons/ri/check-line'
|
||||
import IconChevron from '~icons/ri/arrow-down-s-line'
|
||||
|
||||
export interface SelectItem {
|
||||
value: string
|
||||
/** 界面上显示的字,缺省用 value 原文 */
|
||||
label?: string
|
||||
/** 分组标题。相邻同组的选项归在一个标题下 */
|
||||
group?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
options: SelectItem[]
|
||||
/** 当前值不在选项里(含空串)时显示的占位文案 */
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const chosen = defineModel<string>({ default: '' })
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
|
||||
const openUp = ref(false)
|
||||
const active = ref(0)
|
||||
|
||||
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
function toggleOpen() {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
active.value = Math.max(
|
||||
0,
|
||||
props.options.findIndex((o) => o.value === chosen.value),
|
||||
)
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
openUp.value = !!rect && window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function pick(value: string) {
|
||||
chosen.value = value
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const count = props.options.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const item = props.options[active.value]
|
||||
if (item) pick(item.value)
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
// 对话框里按 Esc 只收弹层,不能顺手把整个对话框关了
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 焦点离开整块时收起。新焦点还在里面就不收,否则点选项会先把自己关掉。 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/** 这个下标前面要不要画组标题:是组里第一项才画。 */
|
||||
function groupAt(index: number): string | null {
|
||||
const group = props.options[index]?.group
|
||||
if (!group) return null
|
||||
return props.options[index - 1]?.group === group ? null : group
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="select" @focusout="onFocusOut">
|
||||
<button
|
||||
type="button"
|
||||
class="face"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-label="ariaLabel"
|
||||
@click="toggleOpen"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span :class="['face-text', !current && 'is-empty']">
|
||||
{{ current ? (current.label ?? current.value) : (placeholder ?? '请选择') }}
|
||||
</span>
|
||||
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Transition name="menu">
|
||||
<ul v-if="open" :class="['menu', openUp && 'is-up']" role="listbox">
|
||||
<li v-if="options.length === 0" class="none">没有可选项</li>
|
||||
<template v-for="(item, i) in options" :key="item.value">
|
||||
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="item.value === chosen"
|
||||
:class="['opt', item.value === chosen && 'is-on', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="pick(item.value)"
|
||||
>
|
||||
<span class="mark"><IconCheck v-if="item.value === chosen" /></span>
|
||||
{{ item.label ?? item.value }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.face {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--select-bg, var(--c-bg));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
|
||||
.face:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.face-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.face-text.is-empty {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-faint);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chev.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: calc(100% + var(--s-1));
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.menu.is-up {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--s-1));
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: var(--s-2) var(--s-3) var(--s-1);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.opt.is-on {
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -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<LogLevel>('info')
|
||||
export const paused = ref(false)
|
||||
export const rows = ref<LogRow[]>([])
|
||||
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()
|
||||
})
|
||||
}
|
||||
+15
-23
@@ -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<Record<string, string>>({})
|
||||
const allTypes = computed(() => Object.keys(props.fields?.types ?? {}))
|
||||
const serverTypes = computed(() => allTypes.value.filter((t) => !COMMON_TYPES.includes(t)))
|
||||
|
||||
const typeOptions = computed<SelectItem[]>(() => [
|
||||
...COMMON_TYPES.map((t) => ({ value: t, group: '客户端入站' })),
|
||||
...serverTypes.value.map((t) => ({ value: t, group: '服务端入站' })),
|
||||
])
|
||||
|
||||
/** 当前类型的必填字段,公共字段之外的那些。 */
|
||||
const requiredExtras = computed<FieldSpec[]>(
|
||||
() => props.fields?.types[type.value]?.fields.filter((f) => f.required && !f.key.includes('.')) ?? [],
|
||||
@@ -139,14 +146,7 @@ function submit() {
|
||||
<label class="row">
|
||||
<span class="k">类型</span>
|
||||
<span class="v">
|
||||
<select v-model="type">
|
||||
<optgroup label="客户端入站">
|
||||
<option v-for="t in COMMON_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="服务端入站">
|
||||
<option v-for="t in serverTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<SelectBox v-model="type" :options="typeOptions" />
|
||||
<span v-if="typeNote" class="note">{{ typeNote }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -170,10 +170,12 @@ function submit() {
|
||||
<label v-for="f in requiredExtras" :key="f.key" class="row">
|
||||
<span class="k">{{ f.key }}</span>
|
||||
<span class="v">
|
||||
<select v-if="f.enum" v-model="extras[f.key]">
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="opt in f.enum" :key="String(opt)" :value="String(opt)">{{ opt }}</option>
|
||||
</select>
|
||||
<SelectBox
|
||||
v-if="f.enum"
|
||||
v-model="extras[f.key]"
|
||||
:options="f.enum.map((opt) => ({ value: String(opt) }))"
|
||||
placeholder="请选择"
|
||||
/>
|
||||
<input v-else v-model="extras[f.key]" class="mono" type="text" autocomplete="off" />
|
||||
<span v-if="f.note" class="note">{{ f.note }}</span>
|
||||
</span>
|
||||
@@ -262,8 +264,7 @@ function submit() {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
@@ -271,15 +272,6 @@ select {
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
/* 自绘下拉箭头:原生箭头贴着右边缘,位置调不了。灰色取两套主题中间值 */
|
||||
select {
|
||||
appearance: none;
|
||||
padding-right: var(--s-6);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--s-3) center;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-faint);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { parseIniRulesets, planIniImport } from '@shared/rules.ts'
|
||||
import type { IniImportPlan } from '@shared/rules.ts'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -42,6 +43,8 @@ const mapping = ref<Record<string, string>>({})
|
||||
|
||||
const entries = computed(() => parseIniRulesets(text.value))
|
||||
|
||||
const outboundOptions = computed(() => props.outboundNames.map((name) => ({ value: name })))
|
||||
|
||||
/** ini 里出现过的目标组名,按出现顺序去重。每个都要映射到一个实际出站。 */
|
||||
const targets = computed(() => [...new Set(entries.value.map((e) => e.target))])
|
||||
|
||||
@@ -141,10 +144,7 @@ function submit() {
|
||||
<label v-for="target in targets" :key="target" class="row">
|
||||
<span class="k map-name" :title="target">{{ target }}</span>
|
||||
<span class="v">
|
||||
<select v-model="mapping[target]">
|
||||
<option value="" disabled>映射到…</option>
|
||||
<option v-for="name in outboundNames" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
<SelectBox v-model="mapping[target]" :options="outboundOptions" placeholder="映射到…" />
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
@@ -246,19 +246,6 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
padding-right: var(--s-6);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-bg);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--s-3) center;
|
||||
}
|
||||
|
||||
.found {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
+50
-125
@@ -2,33 +2,21 @@
|
||||
/**
|
||||
* 请求页:内核的日志流,一条一条「这次请求匹配了哪条规则、走了哪个出站」。
|
||||
*
|
||||
* 编排只有三件事:
|
||||
* 1. 流的生命周期——进页面开始读,离开页面中止;切换级别断开重连,
|
||||
* 因为内核的级别是建立连接时定的,中途改不了
|
||||
* 2. 渲染节奏——收到的行先进缓冲,定时一批批刷进列表;暂停就是不刷,
|
||||
* 缓冲照收(也设上限),恢复时补上
|
||||
* 3. 滚动——默认跟着最新条目,用户往上滚就停,滚回底部恢复
|
||||
* 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。
|
||||
* 这个组件只管呈现:筛选、暂停、清空、跟随滚动。
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { kernel } from '../runtime'
|
||||
import { LEVELS, LOG_CAP, pushCapped, streamLogs } from '../logs'
|
||||
import type { LogLevel, LogLine } from '../logs'
|
||||
import { LEVELS } from '../logs'
|
||||
import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import IconPause from '~icons/ri/pause-line'
|
||||
import IconPlay from '~icons/ri/play-line'
|
||||
import IconClear from '~icons/ri/delete-bin-line'
|
||||
|
||||
interface Row extends LogLine {
|
||||
id: number
|
||||
/** 收到那一刻的时间。日志行本身不带时间戳 */
|
||||
time: string
|
||||
}
|
||||
|
||||
const level = ref<LogLevel>('info')
|
||||
const query = ref('')
|
||||
const paused = ref(false)
|
||||
const rows = ref<Row[]>([])
|
||||
const failure = ref('')
|
||||
/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */
|
||||
const ended = ref(false)
|
||||
|
||||
const levelOptions = LEVELS.map((l) => ({ value: l }))
|
||||
|
||||
/** 级别标签的着色。只染级别那一小块,整行染色在低密度的设计里很吵 */
|
||||
const LEVEL_CLASS: Record<string, string> = {
|
||||
@@ -38,16 +26,6 @@ const LEVEL_CLASS: Record<string, string> = {
|
||||
error: 'lv-error',
|
||||
}
|
||||
|
||||
let pending: Row[] = []
|
||||
let nextId = 0
|
||||
let aborter: AbortController | null = null
|
||||
let timer = 0
|
||||
|
||||
const clock = (d: Date): string =>
|
||||
[d.getHours(), d.getMinutes(), d.getSeconds()]
|
||||
.map((n) => String(n).padStart(2, '0'))
|
||||
.join(':')
|
||||
|
||||
const listEl = ref<HTMLElement | null>(null)
|
||||
/** 是否跟随最新条目。用户手动上滚时关掉,滚回底部再开 */
|
||||
let follow = true
|
||||
@@ -58,84 +36,18 @@ function onScroll() {
|
||||
follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!follow) return
|
||||
async function scrollToBottom(force = false) {
|
||||
if (!follow && !force) return
|
||||
await nextTick()
|
||||
const el = listEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
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 = []
|
||||
void scrollToBottom()
|
||||
}
|
||||
// 收集在后台持续进行,新一批刷进来时跟着滚
|
||||
watch(revision, () => void scrollToBottom())
|
||||
|
||||
/**
|
||||
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。
|
||||
* 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等
|
||||
* online 翻转的 watch。
|
||||
*/
|
||||
let retryTimer = 0
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = 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()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
aborter?.abort()
|
||||
window.clearInterval(timer)
|
||||
window.clearTimeout(retryTimer)
|
||||
})
|
||||
// 切回这一页时接上离开时的进度,直接落到最新一条
|
||||
onMounted(() => void scrollToBottom(true))
|
||||
|
||||
const visible = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
@@ -147,17 +59,20 @@ const visible = computed(() => {
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<select v-model="level" class="ctl" aria-label="日志级别">
|
||||
<option v-for="l in LEVELS" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
<SelectBox v-model="level" class="lvl" :options="levelOptions" aria-label="日志级别" />
|
||||
|
||||
<input v-model="query" class="ctl search" type="search" placeholder="筛选" />
|
||||
|
||||
<button class="ctl pause" type="button" @click="paused = !paused">
|
||||
<button class="ctl act" type="button" @click="paused = !paused">
|
||||
<IconPlay v-if="paused" />
|
||||
<IconPause v-else />
|
||||
<span>{{ paused ? '继续' : '暂停' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="ctl act" type="button" @click="clearLogs">
|
||||
<IconClear />
|
||||
<span>清空日志</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="failure" class="failure">
|
||||
@@ -165,22 +80,21 @@ const visible = computed(() => {
|
||||
</div>
|
||||
|
||||
<div ref="listEl" class="log" @scroll="onScroll">
|
||||
<p v-if="!kernel.online" class="state">内核未连接,无法读取日志。</p>
|
||||
<div v-for="row in visible" :key="row.id" class="row">
|
||||
<span class="time mono">{{ row.time }}</span>
|
||||
<span :class="['lv', LEVEL_CLASS[row.type] ?? 'lv-info']">{{ row.type }}</span>
|
||||
<span class="msg mono">{{ row.payload }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="row in visible" :key="row.id" class="row">
|
||||
<span class="time mono">{{ row.time }}</span>
|
||||
<span :class="['lv', LEVEL_CLASS[row.type] ?? 'lv-info']">{{ row.type }}</span>
|
||||
<span class="msg mono">{{ row.payload }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="rows.length === 0 && !failure && !ended" class="state">
|
||||
暂无日志。没有流量经过内核时不会产生请求记录。
|
||||
</p>
|
||||
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
|
||||
没有包含「{{ query.trim() }}」的日志。
|
||||
</p>
|
||||
</template>
|
||||
<p v-if="!kernel.online" class="state">
|
||||
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
|
||||
</p>
|
||||
<p v-else-if="rows.length === 0 && !failure && !ended" class="state">
|
||||
暂无日志。没有流量经过内核时不会产生请求记录。
|
||||
</p>
|
||||
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
|
||||
没有包含「{{ query.trim() }}」的日志。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="ended && !failure" class="notice">日志流已中断,将自动重连。</p>
|
||||
@@ -202,6 +116,13 @@ const visible = computed(() => {
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
/* 页面底色上的控件要比底色浮一档 */
|
||||
.lvl {
|
||||
width: 130px;
|
||||
flex: none;
|
||||
--select-bg: var(--c-surface);
|
||||
}
|
||||
|
||||
.ctl {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
@@ -214,18 +135,22 @@ const visible = computed(() => {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.pause {
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pause:hover {
|
||||
.act:hover {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* 两个动作靠右,和左边的筛选拉开 */
|
||||
.act:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.failure {
|
||||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
border-radius: var(--r-md);
|
||||
|
||||
+13
-21
@@ -9,6 +9,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -27,10 +28,14 @@ const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const name = ref('')
|
||||
const source = ref('')
|
||||
const behavior = ref('classical')
|
||||
const format = ref<'yaml' | 'text' | 'mrs'>('yaml')
|
||||
// 值域就是选项里那三个,SelectBox 的 v-model 是宽的 string,这里不再收窄
|
||||
const format = ref<string>('yaml')
|
||||
/** 用户手选过格式就不再跟着来源推断 */
|
||||
const formatTouched = ref(false)
|
||||
|
||||
const BEHAVIOR_OPTIONS = [{ value: 'classical' }, { value: 'domain' }, { value: 'ipcidr' }]
|
||||
const FORMAT_OPTIONS = [{ value: 'yaml' }, { value: 'text' }, { value: 'mrs' }]
|
||||
|
||||
const isUrl = computed(() => /^https?:\/\//i.test(source.value.trim()))
|
||||
|
||||
watch(source, (value) => {
|
||||
@@ -122,11 +127,7 @@ function submit() {
|
||||
<label class="row">
|
||||
<span class="k">行为</span>
|
||||
<span class="v">
|
||||
<select v-model="behavior">
|
||||
<option value="classical">classical</option>
|
||||
<option value="domain">domain</option>
|
||||
<option value="ipcidr">ipcidr</option>
|
||||
</select>
|
||||
<SelectBox v-model="behavior" :options="BEHAVIOR_OPTIONS" />
|
||||
<span class="note">
|
||||
清单每行带 DOMAIN,、IP-CIDR, 这类前缀的是 classical;纯域名列表是 domain;
|
||||
纯 IP 网段列表是 ipcidr
|
||||
@@ -137,11 +138,11 @@ function submit() {
|
||||
<label class="row">
|
||||
<span class="k">格式</span>
|
||||
<span class="v">
|
||||
<select v-model="format" @change="formatTouched = true">
|
||||
<option value="yaml">yaml</option>
|
||||
<option value="text">text</option>
|
||||
<option value="mrs">mrs</option>
|
||||
</select>
|
||||
<SelectBox
|
||||
v-model="format"
|
||||
:options="FORMAT_OPTIONS"
|
||||
@update:model-value="formatTouched = true"
|
||||
/>
|
||||
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -227,8 +228,7 @@ function submit() {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
@@ -236,14 +236,6 @@ select {
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
padding-right: var(--s-6);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--s-3) center;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-faint);
|
||||
|
||||
Reference in New Issue
Block a user