仪表盘细节修整,内核卡片搬左列并加重启
- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容 - 路由行去掉行间分割线:它会吃掉 hover 高亮的圆角 - 每路走势线先合并成 28 桶再画。3 秒一个采样直接全画,在一条 百来像素宽的线上只剩噪声;没采集的桶画断口,不画假零 - 环形图两段加圆角,交界用底色描边让出间隙 - 内核卡片从右列搬到服务器和路由之间(左列本来就矮一截),加 「重启内核」:POST /restart 后内核自我 exec、PID 不变,恢复没有 任何通知,只能每 2 秒探一次,半分钟探不到交还给外壳心跳
This commit is contained in:
@@ -141,6 +141,25 @@ export async function selectProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让内核重启进程(POST /restart,内核源码 hub/route/restart.go:20-43)。
|
||||||
|
*
|
||||||
|
* 内核先回 {"status":"ok"} 再自我 exec。Linux 上用的是 syscall.Exec,PID 不变,
|
||||||
|
* systemd 托管不受影响;策略组的选中靠 profile.store-selected 恢复。
|
||||||
|
*/
|
||||||
|
export async function restartKernel(env: Env): Promise<ValidateResult> {
|
||||||
|
try {
|
||||||
|
const res = await callApi(env, '/restart', {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
})
|
||||||
|
if (res.ok) return { ok: true, message: '' }
|
||||||
|
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: `连不上管理接口:${String(error)}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 透传一个管理接口的 GET 请求。
|
* 透传一个管理接口的 GET 请求。
|
||||||
*
|
*
|
||||||
|
|||||||
+9
-1
@@ -12,7 +12,7 @@
|
|||||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||||
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||||
import { hostInfo, probeExit } from './host.ts'
|
import { hostInfo, probeExit } from './host.ts'
|
||||||
import { kernelVersion, proxyGet, proxyWrite, selectProxy } from './kernel.ts'
|
import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts'
|
||||||
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import type { Env } from './env.ts'
|
import type { Env } from './env.ts'
|
||||||
@@ -214,6 +214,14 @@ function makeRoute(env: Env, token: string) {
|
|||||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子
|
||||||
|
if (path === '/api/kernel/restart' && req.method === 'POST') {
|
||||||
|
const upstream = await restartKernel(env)
|
||||||
|
return upstream.ok
|
||||||
|
? json({ ok: true })
|
||||||
|
: json({ message: upstream.message || '内核拒绝了重启请求' }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) {
|
if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) {
|
||||||
const target = path.slice('/api/kernel'.length)
|
const target = path.slice('/api/kernel'.length)
|
||||||
const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target))
|
const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
|
bucketRates,
|
||||||
lastRate,
|
lastRate,
|
||||||
pushWindow,
|
pushWindow,
|
||||||
routeDeltas,
|
routeDeltas,
|
||||||
@@ -79,6 +80,36 @@ describe('routeDeltas', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('bucketRates', () => {
|
||||||
|
test('样本按桶平均,桶的时间戳在桶中央', () => {
|
||||||
|
const samples: RouteSample[] = [
|
||||||
|
{ t: 1_000, ms: 1_000, up: 100, down: 0 },
|
||||||
|
{ t: 2_000, ms: 1_000, up: 300, down: 0 },
|
||||||
|
{ t: 6_000, ms: 1_000, up: 50, down: 50 },
|
||||||
|
]
|
||||||
|
// 0-10s 分两桶:前桶 400 字节 / 2 秒 = 200 B/s,后桶 100 字节 / 1 秒 = 100 B/s
|
||||||
|
expect(bucketRates(samples, 0, 10_000, 2)).toEqual([
|
||||||
|
[2_500, 200],
|
||||||
|
[7_500, 100],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('开头没数据的桶被裁掉,中间的留成 null', () => {
|
||||||
|
const samples: RouteSample[] = [
|
||||||
|
{ t: 3_000, ms: 1_000, up: 100, down: 0 },
|
||||||
|
{ t: 9_000, ms: 1_000, up: 100, down: 0 },
|
||||||
|
]
|
||||||
|
const points = bucketRates(samples, 0, 10_000, 5)
|
||||||
|
expect(points).toHaveLength(4)
|
||||||
|
expect(points[0]).toEqual([3_000, 100])
|
||||||
|
expect(points[1]![1]).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有任何样本返回空数组', () => {
|
||||||
|
expect(bucketRates([], 0, 10_000, 5)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('windowBytes / lastRate', () => {
|
describe('windowBytes / lastRate', () => {
|
||||||
const samples: RouteSample[] = [
|
const samples: RouteSample[] = [
|
||||||
{ t: 0, ms: 3_000, up: 100, down: 100 },
|
{ t: 0, ms: 3_000, up: 100, down: 100 },
|
||||||
|
|||||||
@@ -90,3 +90,36 @@ export function lastRate(samples: RouteSample[]): { up: number; down: number } |
|
|||||||
if (!last || last.ms <= 0) return null
|
if (!last || last.ms <= 0) return null
|
||||||
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
|
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把窗口内的样本合并成固定数量的桶,每桶是平均速率(字节每秒)。
|
||||||
|
*
|
||||||
|
* 走势线要的是形状,不是每一次采样:3 秒一个点、5 分钟窗口是一百个点,
|
||||||
|
* 全画在一条几十像素宽的线上只剩噪声。没有采样的桶是 null(「没在采集」,
|
||||||
|
* 不是「速率为 0」——零样本是采集层补出来的真零),开头的 null 直接裁掉,
|
||||||
|
* 采集是从中途开始的,不该画出一段假的静止期。
|
||||||
|
*/
|
||||||
|
export function bucketRates(
|
||||||
|
samples: RouteSample[],
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
buckets: number,
|
||||||
|
): Array<[number, number | null]> {
|
||||||
|
if (end <= start || buckets <= 0) return []
|
||||||
|
const width = (end - start) / buckets
|
||||||
|
const sums = Array.from({ length: buckets }, () => ({ bytes: 0, ms: 0 }))
|
||||||
|
for (const s of samples) {
|
||||||
|
if (s.t < start || s.t > end) continue
|
||||||
|
const i = Math.min(buckets - 1, Math.floor((s.t - start) / width))
|
||||||
|
sums[i]!.bytes += s.up + s.down
|
||||||
|
sums[i]!.ms += s.ms
|
||||||
|
}
|
||||||
|
const points = sums.map(
|
||||||
|
(b, i): [number, number | null] => [
|
||||||
|
Math.round(start + (i + 0.5) * width),
|
||||||
|
b.ms > 0 ? (b.bytes / b.ms) * 1000 : null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const first = points.findIndex(([, v]) => v !== null)
|
||||||
|
return first === -1 ? [] : points.slice(first)
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ export const api = {
|
|||||||
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
||||||
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启内核进程。所有连接会断开,几秒后自动恢复;
|
||||||
|
* 策略组的选中靠配置里的 profile.store-selected 找回来。
|
||||||
|
*/
|
||||||
|
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
||||||
*
|
*
|
||||||
|
|||||||
+137
-40
@@ -11,13 +11,13 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
|
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
|
||||||
import { formatBytes, formatUptime } from '@shared/format.ts'
|
import { formatBytes, formatUptime } from '@shared/format.ts'
|
||||||
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, lastRate, windowBytes } from '@shared/telemetry.ts'
|
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
||||||
import type { RouteSample } from '@shared/telemetry.ts'
|
import type { RouteSample } from '@shared/telemetry.ts'
|
||||||
import type { HostInfo } from '@shared/host-info.ts'
|
import type { HostInfo } from '@shared/host-info.ts'
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import type { EChartsCoreOption } from 'echarts/core'
|
import type { EChartsCoreOption } from 'echarts/core'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { kernel, providerLines } from '../runtime'
|
import { kernel, providerLines, refreshKernel, reloadRuntime } from '../runtime'
|
||||||
import {
|
import {
|
||||||
connCount,
|
connCount,
|
||||||
exitProbes,
|
exitProbes,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { firstPort, kernelPorts, modeLabel } from '../dashboard'
|
import { firstPort, kernelPorts, modeLabel } from '../dashboard'
|
||||||
import { cssColor, themeTick } from '../theme'
|
import { cssColor, themeTick } from '../theme'
|
||||||
import EChart from '../components/EChart.vue'
|
import EChart from '../components/EChart.vue'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import IconNet from '~icons/ri/line-chart-line'
|
import IconNet from '~icons/ri/line-chart-line'
|
||||||
import IconServer from '~icons/ri/server-line'
|
import IconServer from '~icons/ri/server-line'
|
||||||
import IconRoute from '~icons/ri/route-line'
|
import IconRoute from '~icons/ri/route-line'
|
||||||
@@ -184,12 +185,40 @@ function sparkOption(samples: RouteSample[], end: number, color: string): EChart
|
|||||||
silent: true,
|
silent: true,
|
||||||
lineStyle: { width: 1, color },
|
lineStyle: { width: 1, color },
|
||||||
areaStyle: { color, opacity: 0.08 },
|
areaStyle: { color, opacity: 0.08 },
|
||||||
data: samples.map((s) => [s.t, ((s.up + s.down) / Math.max(s.ms, 1)) * 1000]),
|
// 合并成 28 桶再画:3 秒一个采样直接全画,在一条百来像素宽的线上只剩噪声
|
||||||
|
data: bucketRates(samples, end - ROUTE_WINDOW_MS, end, 28),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 内核 ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
const confirmRestart = ref(false)
|
||||||
|
const restarting = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启内核进程。回话之后它就退了,恢复不会有任何通知,只能探——
|
||||||
|
* 每 2 秒一次,最多半分钟;到点还没回来就交还给外壳 10 秒一次的心跳。
|
||||||
|
*/
|
||||||
|
async function restartKernel() {
|
||||||
|
confirmRestart.value = false
|
||||||
|
if (restarting.value) return
|
||||||
|
restarting.value = true
|
||||||
|
try {
|
||||||
|
await api.restartKernel()
|
||||||
|
} catch {
|
||||||
|
// 内核可能在响应送达前就退出了,这不算失败,接下来看它回不回来
|
||||||
|
}
|
||||||
|
kernel.value = { online: false }
|
||||||
|
for (let i = 0; i < 15 && !kernel.value.online; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||||
|
await refreshKernel()
|
||||||
|
}
|
||||||
|
if (kernel.value.online) await reloadRuntime()
|
||||||
|
restarting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 服务器 ───────────────────────────────────── */
|
/* ── 服务器 ───────────────────────────────────── */
|
||||||
|
|
||||||
const host = ref<HostInfo | null>(null)
|
const host = ref<HostInfo | null>(null)
|
||||||
@@ -225,12 +254,14 @@ const donutOption = computed<EChartsCoreOption>(() => {
|
|||||||
void themeTick.value
|
void themeTick.value
|
||||||
const t = totals.value
|
const t = totals.value
|
||||||
const empty = !t || t.up + t.down === 0
|
const empty = !t || t.up + t.down === 0
|
||||||
|
// 两段之间的间隙用底色描边做:borderWidth 在段与段的交界处各让出一半
|
||||||
|
const gap = { borderColor: cssColor('--c-surface'), borderWidth: 3 }
|
||||||
return {
|
return {
|
||||||
animation: false,
|
animation: false,
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['64%', '86%'],
|
radius: ['58%', '86%'],
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
labelLine: { show: false },
|
labelLine: { show: false },
|
||||||
emphasis: { scale: false },
|
emphasis: { scale: false },
|
||||||
@@ -238,8 +269,16 @@ const donutOption = computed<EChartsCoreOption>(() => {
|
|||||||
data: empty
|
data: empty
|
||||||
? [{ value: 1, itemStyle: { color: cssColor('--c-surface-sunken') } }]
|
? [{ value: 1, itemStyle: { color: cssColor('--c-surface-sunken') } }]
|
||||||
: [
|
: [
|
||||||
{ value: t.up, name: '上传', itemStyle: { color: cssColor('--c-text-faint') } },
|
{
|
||||||
{ value: t.down, name: '下载', itemStyle: { color: cssColor('--c-accent') } },
|
value: t.up,
|
||||||
|
name: '上传',
|
||||||
|
itemStyle: { color: cssColor('--c-text-faint'), borderRadius: 8, ...gap },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: t.down,
|
||||||
|
name: '下载',
|
||||||
|
itemStyle: { color: cssColor('--c-accent'), borderRadius: 8, ...gap },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -302,7 +341,8 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="sect-b">
|
<div class="sect-b">
|
||||||
<p v-if="traffic.length === 0" class="empty">
|
<div class="plot-area">
|
||||||
|
<p v-if="traffic.length === 0" class="empty plot-empty">
|
||||||
{{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }}
|
{{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }}
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -314,6 +354,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
<span v-else-if="!live" class="stale">内核未连接,显示的是断开前的历史</span>
|
<span v-else-if="!live" class="stale">内核未连接,显示的是断开前的历史</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
<dl class="strip">
|
<dl class="strip">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<dt>累计上传</dt>
|
<dt>累计上传</dt>
|
||||||
@@ -379,6 +420,43 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="sect">
|
||||||
|
<header class="sect-h">
|
||||||
|
<IconKernel class="sect-i" />
|
||||||
|
<h2>内核</h2>
|
||||||
|
<button
|
||||||
|
v-if="kernel.online || restarting"
|
||||||
|
type="button"
|
||||||
|
class="restart"
|
||||||
|
:disabled="restarting"
|
||||||
|
@click="confirmRestart = true"
|
||||||
|
>
|
||||||
|
{{ restarting ? '重启中' : '重启内核' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="sect-b">
|
||||||
|
<p v-if="!kernel.online && !restarting" class="empty">内核未连接</p>
|
||||||
|
<p v-else-if="!kernel.online" class="empty">正在等待内核回来</p>
|
||||||
|
<div v-else class="gauges">
|
||||||
|
<div class="gauge">
|
||||||
|
<span class="g-k">版本</span>
|
||||||
|
<span class="g-v mono">{{ kernel.version ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<span class="g-k">运行模式</span>
|
||||||
|
<span class="g-v">{{ mode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<span class="g-k">代理端口</span>
|
||||||
|
<span class="g-v mono ports">
|
||||||
|
<span v-for="p in ports" :key="p.name">{{ p.name }} {{ p.port }}</span>
|
||||||
|
<span v-if="ports.length === 0">—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="sect">
|
<section class="sect">
|
||||||
<header class="sect-h">
|
<header class="sect-h">
|
||||||
<IconRoute class="sect-i" />
|
<IconRoute class="sect-i" />
|
||||||
@@ -398,8 +476,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
@keydown.enter="emit('open-inbound', row.inbound.id)"
|
@keydown.enter="emit('open-inbound', row.inbound.id)"
|
||||||
>
|
>
|
||||||
<span :class="['dot', row.active ? 'dot-on' : 'dot-idle']" aria-hidden="true"></span>
|
<span :class="['dot', row.active ? 'dot-on' : 'dot-idle']" aria-hidden="true"></span>
|
||||||
|
<span class="r-id">
|
||||||
<span class="r-name">{{ row.inbound.name }}</span>
|
<span class="r-name">{{ row.inbound.name }}</span>
|
||||||
<span class="r-port mono">{{ row.inbound.port }}</span>
|
<span class="r-port mono">{{ row.inbound.port }}</span>
|
||||||
|
</span>
|
||||||
<EChart class="spark" :option="row.spark" />
|
<EChart class="spark" :option="row.spark" />
|
||||||
<span class="r-rate mono">
|
<span class="r-rate mono">
|
||||||
<template v-if="row.rate">↑ {{ rate(row.rate.up) }} ↓ {{ rate(row.rate.down) }}</template>
|
<template v-if="row.rate">↑ {{ rate(row.rate.up) }} ↓ {{ rate(row.rate.down) }}</template>
|
||||||
@@ -515,34 +595,16 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="sect">
|
|
||||||
<header class="sect-h">
|
|
||||||
<IconKernel class="sect-i" />
|
|
||||||
<h2>内核</h2>
|
|
||||||
</header>
|
|
||||||
<div class="sect-b">
|
|
||||||
<p v-if="!kernel.online" class="empty">内核未连接</p>
|
|
||||||
<dl v-else class="kv">
|
|
||||||
<div class="row">
|
|
||||||
<dt>版本</dt>
|
|
||||||
<dd class="mono">{{ kernel.version ?? '—' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<dt>运行模式</dt>
|
|
||||||
<dd>{{ mode }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<dt>代理端口</dt>
|
|
||||||
<dd class="mono ports">
|
|
||||||
<span v-for="p in ports" :key="p.name">{{ p.name }} {{ p.port }}</span>
|
|
||||||
<span v-if="ports.length === 0">—</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmRestart"
|
||||||
|
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
|
||||||
|
action="重启"
|
||||||
|
@close="confirmRestart = false"
|
||||||
|
@confirm="restartKernel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -662,6 +724,18 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
color: var(--c-accent-text);
|
color: var(--c-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空态和「图 + 图例」共用一个定高容器,数据到来时下面的内容不跳位 */
|
||||||
|
.plot-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot-empty {
|
||||||
|
margin: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.plot {
|
.plot {
|
||||||
height: 220px;
|
height: 220px;
|
||||||
}
|
}
|
||||||
@@ -774,27 +848,51 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 内核 ─────────────────────────────────────── */
|
||||||
|
.restart {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.restart:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--c-danger) 45%, var(--c-border));
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.restart:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 路由 ─────────────────────────────────────── */
|
/* ── 路由 ─────────────────────────────────────── */
|
||||||
.routes {
|
.routes {
|
||||||
padding-top: var(--s-2);
|
padding-top: var(--s-2);
|
||||||
padding-bottom: var(--s-2);
|
padding-bottom: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 名字占弹性位,走势线定宽靠右。行间不画分割线:划线会吃掉 hover 高亮的圆角 */
|
||||||
.route {
|
.route {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8px minmax(72px, max-content) max-content minmax(48px, 1fr) max-content max-content 16px;
|
grid-template-columns: 8px minmax(0, 1fr) 150px max-content max-content 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
padding: var(--s-2) var(--s-1);
|
padding: var(--s-2) var(--s-2);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.route:hover {
|
.route:hover {
|
||||||
background: var(--c-surface-sunken);
|
background: var(--c-surface-sunken);
|
||||||
}
|
}
|
||||||
.route + .route {
|
|
||||||
border-top: 1px solid var(--c-border);
|
.r-id {
|
||||||
border-radius: 0;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-2);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
@@ -823,7 +921,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
|
|
||||||
.spark {
|
.spark {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
min-width: 48px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.r-rate {
|
.r-rate {
|
||||||
@@ -956,6 +1054,5 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s-3);
|
gap: var(--s-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user