合并仪表盘页(task/dashboard)
This commit is contained in:
+23
@@ -127,6 +127,29 @@ export const api = {
|
|||||||
|
|
||||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||||
|
|
||||||
|
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
||||||
|
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
||||||
|
*
|
||||||
|
* 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。
|
||||||
|
* 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。
|
||||||
|
*/
|
||||||
|
openStream: async (path: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const res = await fetch(`/api/kernel${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||||
|
throw new ApiError(res.status, payload)
|
||||||
|
}
|
||||||
|
if (!res.body) throw new Error('响应没有内容')
|
||||||
|
return res.body
|
||||||
|
},
|
||||||
|
|
||||||
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||||||
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { WINDOW, chartPeak, kernelPorts, linePoints, modeLabel, pushSample } from './dashboard.ts'
|
||||||
|
import type { TrafficSample } from './dashboard.ts'
|
||||||
|
|
||||||
|
const sample = (up: number, down: number): TrafficSample => ({
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
upTotal: 0,
|
||||||
|
downTotal: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('流量样本窗口', () => {
|
||||||
|
it('追加样本', () => {
|
||||||
|
expect(pushSample([sample(1, 2)], sample(3, 4))).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('窗口满了挤掉最旧的', () => {
|
||||||
|
const full = Array.from({ length: WINDOW }, (_, i) => sample(i, i))
|
||||||
|
const next = pushSample(full, sample(999, 999))
|
||||||
|
expect(next).toHaveLength(WINDOW)
|
||||||
|
expect(next.at(-1)?.up).toBe(999)
|
||||||
|
expect(next[0]?.up).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('纵轴上限', () => {
|
||||||
|
it('空窗口和全 0 都有下限 1,不会除出 NaN', () => {
|
||||||
|
expect(chartPeak([])).toBe(1)
|
||||||
|
expect(chartPeak([sample(0, 0)])).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取上行下行的共同峰值', () => {
|
||||||
|
expect(chartPeak([sample(10, 500), sample(800, 20)])).toBe(800)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('折线坐标', () => {
|
||||||
|
it('最新样本钉在右缘,最旧的往左排', () => {
|
||||||
|
const points = linePoints([5, 10], 10, 590, 100).split(' ')
|
||||||
|
expect(points).toHaveLength(2)
|
||||||
|
// 窗口 60,步长 10:两条样本占最右边两格
|
||||||
|
expect(points[0]).toBe('580.0,50.0')
|
||||||
|
expect(points[1]).toBe('590.0,0.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('窗口不满不拉伸,左边留空', () => {
|
||||||
|
const points = linePoints([7], 10, 590, 100).split(' ')
|
||||||
|
expect(points).toEqual(['590.0,30.0'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全是 0 时整条贴在底线上', () => {
|
||||||
|
for (const point of linePoints([0, 0, 0], 1, 590, 100).split(' ')) {
|
||||||
|
expect(point.endsWith(',100.0')).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('运行模式', () => {
|
||||||
|
it('三个已知模式有中文名', () => {
|
||||||
|
expect(modeLabel('rule')).toBe('规则')
|
||||||
|
expect(modeLabel('global')).toBe('全局')
|
||||||
|
expect(modeLabel('direct')).toBe('直连')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没见过的值原样显示,缺省是破折号', () => {
|
||||||
|
expect(modeLabel('script')).toBe('script')
|
||||||
|
expect(modeLabel(undefined)).toBe('—')
|
||||||
|
expect(modeLabel('')).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('代理端口', () => {
|
||||||
|
it('挑出大于 0 的端口,按固定顺序', () => {
|
||||||
|
expect(kernelPorts({ 'mixed-port': 7890, port: 7891, 'socks-port': 0 })).toEqual([
|
||||||
|
{ name: 'port', port: 7891 },
|
||||||
|
{ name: 'mixed-port', port: 7890 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('0、缺省和非数字都不算启用', () => {
|
||||||
|
expect(kernelPorts({ port: 0, 'socks-port': 'abc' })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('字符串端口号也认得', () => {
|
||||||
|
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 仪表盘页的数据整理。
|
||||||
|
*
|
||||||
|
* 流量窗口、折线坐标、端口列表这些是「拿到内核数据之后怎么摆」的纯逻辑,
|
||||||
|
* 放在这里而不是 DashboardPage.vue 的 computed 里,才能被 bun test 直接测到。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 流量曲线保留的秒数。接口每秒吐一条,所以窗口长度就是样本数。 */
|
||||||
|
export const WINDOW = 60
|
||||||
|
|
||||||
|
export interface TrafficSample {
|
||||||
|
/** 当前每秒上行字节数 */
|
||||||
|
up: number
|
||||||
|
/** 当前每秒下行字节数 */
|
||||||
|
down: number
|
||||||
|
/** 累计上传字节数 */
|
||||||
|
upTotal: number
|
||||||
|
/** 累计下载字节数 */
|
||||||
|
downTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加一条样本,窗口满了就把最旧的挤掉。 */
|
||||||
|
export function pushSample(samples: TrafficSample[], sample: TrafficSample): TrafficSample[] {
|
||||||
|
return [...samples, sample].slice(-WINDOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两条曲线共用的纵轴上限。
|
||||||
|
*
|
||||||
|
* 共用一个刻度,上行下行的差距才看得出来;各画各的刻度会让一条几乎不动的线
|
||||||
|
* 看起来像在剧烈波动。下限取 1 只为避免全 0 时除以 0。
|
||||||
|
*/
|
||||||
|
export function chartPeak(samples: TrafficSample[]): number {
|
||||||
|
let peak = 1
|
||||||
|
for (const s of samples) {
|
||||||
|
if (s.up > peak) peak = s.up
|
||||||
|
if (s.down > peak) peak = s.down
|
||||||
|
}
|
||||||
|
return peak
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条曲线的 SVG polyline 点串。0 映射到 height,peak 映射到 0。
|
||||||
|
*
|
||||||
|
* 最新样本钉在右缘,窗口不满时曲线从左边空白处长出来,而不是把已有数据拉伸到
|
||||||
|
* 整个宽度——拉伸会让「刚开始采集」和「一直很闲」长得一样。
|
||||||
|
*/
|
||||||
|
export function linePoints(values: number[], peak: number, width: number, height: number): string {
|
||||||
|
const scale = Math.max(peak, 1)
|
||||||
|
const step = width / (WINDOW - 1)
|
||||||
|
return values
|
||||||
|
.map((value, i) => {
|
||||||
|
const x = width - (values.length - 1 - i) * step
|
||||||
|
const y = height - (value / scale) * height
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内核运行模式在界面上怎么念。不在表里的原样显示,不猜它的意思。 */
|
||||||
|
export function modeLabel(mode: unknown): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'rule':
|
||||||
|
return '规则'
|
||||||
|
case 'global':
|
||||||
|
return '全局'
|
||||||
|
case 'direct':
|
||||||
|
return '直连'
|
||||||
|
default:
|
||||||
|
return typeof mode === 'string' && mode ? mode : '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */
|
||||||
|
export interface KernelPort {
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT_KEYS = ['port', 'socks-port', 'mixed-port', 'redir-port', 'tproxy-port'] as const
|
||||||
|
|
||||||
|
/** 从内核 /configs 的运行时设置里挑出正在监听的代理端口。0 和缺省都表示没启用。 */
|
||||||
|
export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
|
||||||
|
const result: KernelPort[] = []
|
||||||
|
for (const key of PORT_KEYS) {
|
||||||
|
const raw = configs[key]
|
||||||
|
const port = typeof raw === 'number' ? raw : Number(raw)
|
||||||
|
if (Number.isFinite(port) && port > 0) result.push({ name: key, port })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 仪表盘:打开 Patchbay 第一眼看到的页面,现在什么状况。
|
||||||
|
*
|
||||||
|
* 全部只读。流量和内存是内核的流式接口(每秒一行 JSON),离开页面时中止;
|
||||||
|
* 概览的数字数自配置文件。两边来源不同:配置读得到不代表内核在跑,反过来也
|
||||||
|
* 一样,所以内核未连接时概览照常显示,只有流量、内存和内核那栏换说法。
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { collectInbounds } from '@shared/inbounds.ts'
|
||||||
|
import { formatBytes } from '@shared/format.ts'
|
||||||
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { readLines } from '../stream'
|
||||||
|
import { kernel, providerLines } from '../runtime'
|
||||||
|
import { chartPeak, kernelPorts, linePoints, modeLabel, pushSample } from '../dashboard'
|
||||||
|
import type { TrafficSample } from '../dashboard'
|
||||||
|
|
||||||
|
const props = defineProps<{ data: ConfigObject }>()
|
||||||
|
|
||||||
|
const samples = ref<TrafficSample[]>([])
|
||||||
|
const memory = ref<number | null>(null)
|
||||||
|
const configs = ref<Record<string, unknown> | null>(null)
|
||||||
|
/** 流读失败或中途断掉(比如页面开着时内核停了)。流量和内存整块换说法。 */
|
||||||
|
const streamFailed = ref(false)
|
||||||
|
|
||||||
|
const unreachable = computed(() => !kernel.value.online || streamFailed.value)
|
||||||
|
|
||||||
|
let abort: AbortController | null = null
|
||||||
|
|
||||||
|
/** 流里的数字字段。缺了、不是数字都按 0 算,第一行经常全是 0,那不是故障。 */
|
||||||
|
const num = (value: unknown): number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||||
|
|
||||||
|
async function consume(
|
||||||
|
path: string,
|
||||||
|
ctrl: AbortController,
|
||||||
|
onLine: (payload: Record<string, unknown>) => void,
|
||||||
|
) {
|
||||||
|
const body = await api.openStream(path, ctrl.signal)
|
||||||
|
for await (const line of readLines(body)) {
|
||||||
|
onLine(JSON.parse(line) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStreams() {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abort = ctrl
|
||||||
|
const run = (path: string, onLine: (payload: Record<string, unknown>) => void) =>
|
||||||
|
void consume(path, ctrl, onLine)
|
||||||
|
.catch(() => {})
|
||||||
|
// 正常结束也是断了:这两个流本身永不结束
|
||||||
|
.finally(() => {
|
||||||
|
if (!ctrl.signal.aborted) streamFailed.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
run('/traffic', (p) => {
|
||||||
|
samples.value = pushSample(samples.value, {
|
||||||
|
up: num(p.up),
|
||||||
|
down: num(p.down),
|
||||||
|
upTotal: num(p.upTotal),
|
||||||
|
downTotal: num(p.downTotal),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
run('/memory', (p) => {
|
||||||
|
memory.value = num(p.inuse)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStreams() {
|
||||||
|
abort?.abort()
|
||||||
|
abort = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigs() {
|
||||||
|
configs.value = await api.kernelConfigs().catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (kernel.value.online) {
|
||||||
|
streamFailed.value = false
|
||||||
|
startStreams()
|
||||||
|
void loadConfigs()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 内核从离线恢复(比如路由页保存配置触发的重载)时重新接上;掉线时收掉
|
||||||
|
watch(
|
||||||
|
() => kernel.value.online,
|
||||||
|
(online) => {
|
||||||
|
stopStreams()
|
||||||
|
samples.value = []
|
||||||
|
memory.value = null
|
||||||
|
configs.value = null
|
||||||
|
if (online) {
|
||||||
|
streamFailed.value = false
|
||||||
|
startStreams()
|
||||||
|
void loadConfigs()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(stopStreams)
|
||||||
|
|
||||||
|
const latest = computed(() => samples.value.at(-1) ?? null)
|
||||||
|
|
||||||
|
/** 当前值还没到时显示破折号,不是 0——0 是个真值,破折号是没数据。 */
|
||||||
|
const current = computed(() => ({
|
||||||
|
up: latest.value ? `${formatBytes(latest.value.up)}/s` : '—',
|
||||||
|
down: latest.value ? `${formatBytes(latest.value.down)}/s` : '—',
|
||||||
|
upTotal: latest.value ? formatBytes(latest.value.upTotal) : '—',
|
||||||
|
downTotal: latest.value ? formatBytes(latest.value.downTotal) : '—',
|
||||||
|
memory: memory.value !== null ? formatBytes(memory.value) : '—',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 曲线的作图区域。viewBox 上下各留 6px,峰值的描边不顶边、底线描边不被裁掉
|
||||||
|
const CHART_W = 600
|
||||||
|
const CHART_H = 112
|
||||||
|
|
||||||
|
const peak = computed(() => chartPeak(samples.value))
|
||||||
|
const upPoints = computed(() =>
|
||||||
|
linePoints(samples.value.map((s) => s.up), peak.value, CHART_W, CHART_H),
|
||||||
|
)
|
||||||
|
const downPoints = computed(() =>
|
||||||
|
linePoints(samples.value.map((s) => s.down), peak.value, CHART_W, CHART_H),
|
||||||
|
)
|
||||||
|
|
||||||
|
const inboundCount = computed(() => collectInbounds(props.data).length)
|
||||||
|
const groupCount = computed(() =>
|
||||||
|
Array.isArray(props.data['proxy-groups']) ? (props.data['proxy-groups'] as unknown[]).length : 0,
|
||||||
|
)
|
||||||
|
const ruleCount = computed(() =>
|
||||||
|
Array.isArray(props.data.rules) ? (props.data.rules as unknown[]).length : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mode = computed(() => modeLabel(configs.value?.mode))
|
||||||
|
const ports = computed(() => kernelPorts(configs.value ?? {}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<template v-if="!unreachable">
|
||||||
|
<div class="sums">
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ current.up }}</span>
|
||||||
|
<span class="sum-k">上行</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ current.down }}</span>
|
||||||
|
<span class="sum-k">下行</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ current.upTotal }}</span>
|
||||||
|
<span class="sum-k">累计上传</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ current.downTotal }}</span>
|
||||||
|
<span class="sum-k">累计下载</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ current.memory }}</span>
|
||||||
|
<span class="sum-k">内存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart">
|
||||||
|
<div class="legend">
|
||||||
|
<span class="key"><span class="sw sw-up" aria-hidden="true"></span>上行</span>
|
||||||
|
<span class="key"><span class="sw sw-down" aria-hidden="true"></span>下行</span>
|
||||||
|
</div>
|
||||||
|
<!-- preserveAspectRatio=none 让曲线跟随容器宽度,描边宽度由 vector-effect 保住 -->
|
||||||
|
<svg
|
||||||
|
class="plot"
|
||||||
|
:viewBox="`0 0 ${CHART_W} ${CHART_H + 12}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label="最近 60 秒的上行下行流量"
|
||||||
|
>
|
||||||
|
<g transform="translate(0, 6)">
|
||||||
|
<line class="axis" x1="0" :x2="CHART_W" :y1="CHART_H" :y2="CHART_H" />
|
||||||
|
<polyline v-if="samples.length" class="ln ln-up" :points="upPoints" />
|
||||||
|
<polyline v-if="samples.length" class="ln ln-down" :points="downPoints" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="offline">内核未连接</p>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<section class="panel">
|
||||||
|
<h2 class="panel-t">概览</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<div class="row">
|
||||||
|
<dt>入站</dt>
|
||||||
|
<dd>{{ inboundCount }} 路</dd>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<dt>策略组</dt>
|
||||||
|
<dd>{{ groupCount }} 个</dd>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<dt>订阅线路</dt>
|
||||||
|
<dd>{{ providerLines.length }} 条</dd>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<dt>规则</dt>
|
||||||
|
<dd>{{ ruleCount }} 条</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2 class="panel-t">内核</h2>
|
||||||
|
<p v-if="unreachable" class="panel-off">内核未连接</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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sums {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s-6);
|
||||||
|
margin-bottom: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-n {
|
||||||
|
font-size: var(--t-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-k {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
margin-bottom: var(--s-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-4);
|
||||||
|
margin-bottom: var(--s-2);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw {
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.sw-up {
|
||||||
|
background: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
.sw-down {
|
||||||
|
background: var(--c-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis {
|
||||||
|
stroke: var(--c-border);
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下行是主视角,用唯一的强调色;上行压成中性色,不和任何状态色沾边 */
|
||||||
|
.ln-up {
|
||||||
|
stroke: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
.ln-down {
|
||||||
|
stroke: var(--c-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
margin-bottom: var(--s-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--s-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 900px) {
|
||||||
|
.split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--c-surface);
|
||||||
|
padding: var(--s-4) var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-t {
|
||||||
|
font-size: var(--t-base);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--s-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-off {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
padding: var(--s-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s-4);
|
||||||
|
padding: var(--s-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row dt {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ports {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user