feat: daily node restart controlled from the admin, connect timings as a default line task [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

Agents and Probes exit in order at a configured time (default 04:30 UTC+8, switch and time zone in the admin settings) and the service manager starts them again; a local restart_at or MOSHIRO_RESTART_AT wins. The connect kind (TCP connect and TLS handshake timings) is now a default continuous line template, with a section on the line page.
This commit is contained in:
justme committed 2026-10-07 14:56:50 +08:00
1 parent 388619b8b6
commit 9bcef56497
37 files changed
+928 -101

No files matched your search

+151 -55
View File
@@ -10,70 +10,166 @@ import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes']
/** 常用时区(固定偏移,不含夏令时):偏移是东经分钟数。 */
const TIME_ZONES: { offset: number; label: string }[] = [
{ offset: 480, label: '北京时间(UTC+8)' },
{ offset: 0, label: 'UTC' },
{ offset: 540, label: '东京、首尔(UTC+9)' },
{ offset: 60, label: '中欧(UTC+1)' },
{ offset: 180, label: '莫斯科(UTC+3)' },
{ offset: -300, label: '美东(UTC-5)' },
{ offset: -480, label: '美西(UTC-8)' },
]
const pad2 = (n: number) => String(n).padStart(2, '0')
const offsetLabel = (offset: number) => `UTC${offset < 0 ? '-' : '+'}${Math.floor(Math.abs(offset) / 60)}${Math.abs(offset) % 60 ? `:${pad2(Math.abs(offset) % 60)}` : ''}`
/** 定时重启:节点每天这个时刻有序退出一次,由系统服务拉起。设置保存后马上通知所有在线节点。 */
function RestartCard({ items, onSaved }: { items: SettingDto[]; onSaved: () => void }) {
const { busy, run } = useAction()
const value = (key: string, fallback: string) => items.find((s) => s.key === key)?.value ?? fallback
const saved = {
enabled: value('fleet.restart_enabled', 'true') === 'true',
hour: Number(value('fleet.restart_hour', '4')),
minute: Number(value('fleet.restart_minute', '30')),
offset: Number(value('fleet.restart_utc_offset_minutes', '480')),
}
const [draft, setDraft] = useState(saved)
useEffect(() => setDraft(saved), [saved.enabled, saved.hour, saved.minute, saved.offset]) // eslint-disable-line react-hooks/exhaustive-deps
const dirty = draft.enabled !== saved.enabled || draft.hour !== saved.hour || draft.minute !== saved.minute || draft.offset !== saved.offset
const zones = TIME_ZONES.some((z) => z.offset === draft.offset) ? TIME_ZONES : [...TIME_ZONES, { offset: draft.offset, label: offsetLabel(draft.offset) }]
const zoneName = zones.find((z) => z.offset === draft.offset)?.label ?? offsetLabel(draft.offset)
return (
<div className="card" style={{ marginBottom: 16 }}>
<div className="card-title">定时重启(Agent 和 Probe)</div>
<div className="row wrap" style={{ gap: 16 }}>
<label className="switch">
<input type="checkbox" checked={draft.enabled} onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })} />
{draft.enabled ? '开' : '关'}
</label>
<span className="row" style={{ gap: 6 }}>
<select className="select" aria-label="小时" value={draft.hour} disabled={!draft.enabled} onChange={(e) => setDraft({ ...draft, hour: Number(e.target.value) })}>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{pad2(h)}
</option>
))}
</select>
:
<select className="select" aria-label="分钟" value={draft.minute} disabled={!draft.enabled} onChange={(e) => setDraft({ ...draft, minute: Number(e.target.value) })}>
{Array.from({ length: 60 }, (_, m) => (
<option key={m} value={m}>
{pad2(m)}
</option>
))}
</select>
</span>
<select className="select" aria-label="时区" value={draft.offset} disabled={!draft.enabled} onChange={(e) => setDraft({ ...draft, offset: Number(e.target.value) })}>
{zones.map((z) => (
<option key={z.offset} value={z.offset}>
{z.label}
</option>
))}
</select>
<button
type="button"
className="btn primary"
disabled={!dirty || busy}
onClick={async () => {
await run(
() =>
api.patch('/api/admin/settings', {
'fleet.restart_enabled': draft.enabled,
'fleet.restart_hour': draft.hour,
'fleet.restart_minute': draft.minute,
'fleet.restart_utc_offset_minutes': draft.offset,
}),
'已保存,已通知在线节点',
)
onSaved()
}}
>
保存
</button>
</div>
<p className="muted" style={{ marginTop: 12 }}>
{draft.enabled ? `每天 ${pad2(draft.hour)}:${pad2(draft.minute)}(${zoneName})` : '已关闭'}
:节点自己有序退出一次,由系统服务(systemd、容器、procd)重新拉起,测量会中断几十秒。节点本地写了 restart_at 或环境变量 MOSHIRO_RESTART_AT 时以本地为准。
</p>
</div>
)
}
function Settings() {
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
const { busy, run } = useAction()
const [draft, setDraft] = useState<Record<string, string>>({})
useEffect(() => setDraft({}), [settings.data])
const items = settings.data?.items ?? []
const items = (settings.data?.items ?? []).filter((s) => !RESTART_KEYS.includes(s.key))
const dirty = Object.keys(draft).length > 0
return (
<div className="card">
<ErrorNotice error={settings.error} />
<table className="table">
<thead>
<tr>
<th>设置</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{items.map((s) => {
const value = draft[s.key] ?? s.value
return (
<tr key={s.key}>
<td className="mono">{s.key}</td>
<td style={{ width: 200 }}>
{s.type === 'bool' ? (
<label className="switch">
<input type="checkbox" checked={value === 'true'} onChange={(e) => setDraft({ ...draft, [s.key]: String(e.target.checked) })} />
{value === 'true' ? '开' : '关'}
</label>
) : (
<input className="input" type={s.type === 'int' ? 'number' : 'text'} value={value} onChange={(e) => setDraft({ ...draft, [s.key]: e.target.value })} />
)}
</td>
<td className="muted">
{translateSetting(s.key, s.description)}
{s.value !== s.default ? <span className="faint">(默认 {s.default})</span> : null}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="row" style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={!dirty || busy}
onClick={async () => {
const body: Record<string, unknown> = {}
for (const s of items) {
if (draft[s.key] === undefined) continue
body[s.key] = s.type === 'bool' ? draft[s.key] === 'true' : s.type === 'int' ? Number(draft[s.key]) : draft[s.key]
}
await run(() => api.patch('/api/admin/settings', body), '已保存')
settings.reload()
}}
>
保存
</button>
<span className="muted">「site.public」打开之后,展示页对匿名访客公开。</span>
<>
<RestartCard items={settings.data?.items ?? []} onSaved={settings.reload} />
<div className="card">
<ErrorNotice error={settings.error} />
<table className="table">
<thead>
<tr>
<th>设置</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{items.map((s) => {
const value = draft[s.key] ?? s.value
return (
<tr key={s.key}>
<td className="mono">{s.key}</td>
<td style={{ width: 200 }}>
{s.type === 'bool' ? (
<label className="switch">
<input type="checkbox" checked={value === 'true'} onChange={(e) => setDraft({ ...draft, [s.key]: String(e.target.checked) })} />
{value === 'true' ? '开' : '关'}
</label>
) : (
<input className="input" type={s.type === 'int' ? 'number' : 'text'} value={value} onChange={(e) => setDraft({ ...draft, [s.key]: e.target.value })} />
)}
</td>
<td className="muted">
{translateSetting(s.key, s.description)}
{s.value !== s.default ? <span className="faint">(默认 {s.default})</span> : null}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="row" style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={!dirty || busy}
onClick={async () => {
const body: Record<string, unknown> = {}
for (const s of items) {
if (draft[s.key] === undefined) continue
body[s.key] = s.type === 'bool' ? draft[s.key] === 'true' : s.type === 'int' ? Number(draft[s.key]) : draft[s.key]
}
await run(() => api.patch('/api/admin/settings', body), '已保存')
settings.reload()
}}
>
保存
</button>
<span className="muted">「site.public」打开之后,展示页对匿名访客公开。</span>
</div>
</div>
</div>
</>
)
}
+9 -3
View File
@@ -30,7 +30,7 @@ interface HostSeries {
}
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string }[] = [
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
@@ -43,6 +43,9 @@ const TILES: { label: string; key: string; fmt: (v: number | null | undefined) =
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
{ label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
{ label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
]
interface ChartDef {
@@ -52,7 +55,7 @@ interface ChartDef {
zero?: boolean
fill?: boolean
series: { key: string; label: string; color: string; dash?: number[] }[]
group: 'udp' | 'tcp' | 'score'
group: 'udp' | 'tcp' | 'connect' | 'score'
/** 占满一行。 */
wide?: boolean
}
@@ -60,6 +63,7 @@ interface ChartDef {
const GROUPS = [
{ id: 'udp', title: 'UDP(往返探测)' },
{ id: 'tcp', title: 'TCP' },
{ id: 'connect', title: '建连(TCP 握手、TLS 握手)' },
{ id: 'score', title: '评分' },
] as const
@@ -69,6 +73,8 @@ const CHARTS: ChartDef[] = [
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
{ id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
{ id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
]
@@ -347,7 +353,7 @@ export function Line() {
<span className="k">评分</span>
<ScoreChips metrics={m} />
</div>
{TILES.map((t) => (
{TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => (
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
))}
</div>
+1 -1
View File
@@ -173,7 +173,7 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
export function usePeriodSeries(lineId: string, ready: boolean, period: Period) {
return useQuery(
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,connect,scores' }) : Promise.resolve(undefined)),
[lineId, JSON.stringify(period.query), ready],
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
)