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
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:
1 parent
388619b8b6
commit
9bcef56497
37 files changed
+928
-101
No files matched your search
+151
-55
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user