feat(ntp): Live peer status tab (chronyc sources)

Adds GET /ntp/sources endpoint (runs chronyc sources, parses tabular
output) and a new "Peer status" tab in the NTP page showing all
configured peers with mode, state badge, stratum, poll interval,
reach register, last-rx and offset/error sample.

v1.1.101

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-25 11:57:04 +02:00
parent 57b9cd89b2
commit 4629679ba9
8 changed files with 194 additions and 8 deletions

View File

@@ -170,8 +170,9 @@ export default function NTPPage() {
<Tabs
defaultActiveKey="pools"
items={[
{ key: 'pools', label: <span><DatabaseOutlined /> {t('ntp.tabs.pools')}</span>, children: <PoolsTab /> },
{ key: 'settings', label: <span><SettingOutlined /> {t('ntp.tabs.settings')}</span>, children: <SettingsTab /> },
{ key: 'pools', label: <span><DatabaseOutlined /> {t('ntp.tabs.pools')}</span>, children: <PoolsTab /> },
{ key: 'peers', label: <span><ClockCircleOutlined /> {t('ntp.tabs.peers')}</span>, children: <SourcesTab /> },
{ key: 'settings', label: <span><SettingOutlined /> {t('ntp.tabs.settings')}</span>, children: <SettingsTab /> },
]}
/>
</div>
@@ -449,3 +450,85 @@ function SettingsTab() {
</Form>
)
}
interface NTPSource {
mode: string
state: string
active: boolean
name: string
stratum: number
poll: number
reach: string
last_rx: string
sample: string
}
function reachColor(reach: string): 'success' | 'warning' | 'error' | 'default' {
if (reach === '377') return 'success'
if (reach === '0') return 'error'
return 'warning'
}
function stateColor(state: string): string {
if (state === 'synced') return '#16a34a'
if (state === 'combined') return '#2563eb'
if (state === 'unreachable' || state === 'error') return '#dc2626'
return '#78716c'
}
function SourcesTab() {
const { t } = useTranslation()
const { data, isFetching, refetch } = useQuery({
queryKey: ['ntp', 'sources'],
queryFn: async () => {
const r = await apiClient.get('/ntp/sources')
if (!isEnvelope(r.data)) return { sources: [] as NTPSource[], error: '' }
return r.data.data as { sources: NTPSource[]; error?: string }
},
refetchInterval: 30_000,
})
const cols: ColumnsType<NTPSource> = [
{
title: t('ntp.sourcesCard.name'),
dataIndex: 'name',
render: (v: string, row) => (
<Space size={6}>
<span style={{ color: stateColor(row.state), fontWeight: row.active ? 600 : 400, fontFamily: 'monospace', fontSize: 12 }}>
{v}
</span>
{row.active && <Tag color="green" style={{ marginLeft: 2 }}>{t(`ntp.sourcesCard.state_${row.state}`)}</Tag>}
</Space>
),
},
{ title: t('ntp.sourcesCard.stratum'), dataIndex: 'stratum', width: 80, align: 'center' as const },
{ title: t('ntp.sourcesCard.poll'), dataIndex: 'poll', width: 60, align: 'center' as const, render: (v: number) => `2^${v}s` },
{
title: <Tooltip title={t('ntp.sourcesCard.reachHint')}>{t('ntp.sourcesCard.reach')}</Tooltip>,
dataIndex: 'reach',
width: 90,
align: 'center' as const,
render: (v: string) => <Tag color={reachColor(v)}>{v}</Tag>,
},
{ title: t('ntp.sourcesCard.lastRx'), dataIndex: 'last_rx', width: 80, align: 'center' as const },
{ title: t('ntp.sourcesCard.sample'), dataIndex: 'sample', render: (v: string) => <code style={{ fontSize: 11 }}>{v}</code> },
]
return (
<Card
size="small"
title={<><ClockCircleOutlined /> {t('ntp.sourcesCard.title')}</>}
extra={<Button size="small" icon={<ReloadOutlined />} loading={isFetching} onClick={() => refetch()}>{t('common.refresh')}</Button>}
>
{data?.error && <Alert type="warning" showIcon message={data.error} className="mb-12" />}
<DataTable<NTPSource>
dataSource={data?.sources ?? []}
columns={cols}
rowKey="name"
loading={isFetching}
size="small"
emptyContent={<Typography.Text type="secondary">{t('ntp.sourcesCard.empty')}</Typography.Text>}
/>
</Card>
)
}