import { useState } from 'react' import { Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, DatabaseOutlined, PlusOutlined, ReloadOutlined, SettingOutlined, ThunderboltOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import PageHeader from '../../components/PageHeader' import ActionButtons from '../../components/ActionButtons' const { Text } = Typography interface Pool { id: number kind: 'pool' | 'server' address: string iburst: boolean prefer: boolean minpoll?: number | null maxpoll?: number | null active: boolean description?: string | null } interface Settings { listen_addresses: string allow_acl: string serve_clients: boolean makestep_secs: number makestep_limit: number rtcsync: boolean leapsectz?: string | null } interface SettingsForm extends Omit { listen_addresses: string[] } interface SystemIface { ifname: string addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }> } interface NTPStatus { synced: boolean reference?: string stratum?: number offset_ms?: number freq_ppm?: number rms_offset_ms?: number error?: string } async function fetchNTPStatus(): Promise { try { const r = await apiClient.get('/ntp/status') if (!isEnvelope(r.data)) return null return r.data.data as NTPStatus } catch { return null } } async function listPools(): Promise { const r = await apiClient.get('/ntp/pools') if (!isEnvelope(r.data)) return [] return (r.data.data as { pools?: Pool[] }).pools ?? [] } async function getSettings(): Promise { const r = await apiClient.get('/ntp/settings') return isEnvelope(r.data) ? (r.data.data as Settings) : null } async function listSystemInterfaces(): Promise { const r = await apiClient.get('/system/interfaces') if (!isEnvelope(r.data)) return [] return (r.data.data as { interfaces?: SystemIface[] }).interfaces ?? [] } export default function NTPPage() { const { t } = useTranslation() const { data: ntpStatus, refetch: refetchStatus } = useQuery({ queryKey: ['ntp', 'status'], queryFn: fetchNTPStatus, refetchInterval: 30_000, }) const { data: services } = useQuery({ queryKey: ['system', 'services'], queryFn: async () => { const r = await apiClient.get('/system/services') return isEnvelope(r.data) ? (r.data.data as { services: Array<{ unit: string; active: boolean; state: string }> }).services ?? [] : [] }, refetchInterval: 30_000, }) const chrony = services?.find(s => s.unit === 'chrony' || s.unit === 'chronyd') return (
} title={t('ntp.title')} subtitle={t('ntp.intro')} extra={chrony && ( : } color={chrony.active ? 'green' : 'red'} > chrony {chrony.state} )} /> {t('ntp.statusCard.title')}} extra={} > {ntpStatus?.error ? ( ) : ntpStatus ? ( = 0 ? '+' : '') + ntpStatus.offset_ms.toFixed(3) + ' ms' : '—'} valueStyle={{ fontSize: 13, color: ntpStatus.offset_ms != null && Math.abs(ntpStatus.offset_ms) > 100 ? '#d48806' : undefined, }} /> {ntpStatus.freq_ppm != null && ( = 0 ? '+' : '') + ntpStatus.freq_ppm.toFixed(3) + ' ppm'} valueStyle={{ fontSize: 13, color: Math.abs(ntpStatus.freq_ppm) > 100 ? '#d48806' : undefined, }} /> )} {ntpStatus.rms_offset_ms != null && ntpStatus.rms_offset_ms > 0 && ( )} ) : ( {t('ntp.statusCard.loading')} )} {t('ntp.tabs.pools')}, children: }, { key: 'peers', label: {t('ntp.tabs.peers')}, children: }, { key: 'settings', label: {t('ntp.tabs.settings')}, children: }, ]} />
) } function PoolsTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data, isLoading } = useQuery({ queryKey: ['ntp', 'pools'], queryFn: listPools }) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const upsert = useMutation({ mutationFn: async (v: Pool) => { if (editing) return (await apiClient.put(`/ntp/pools/${editing.id}`, v)).data return (await apiClient.post('/ntp/pools', v)).data }, onSuccess: () => { message.success(t('common.save')) setEditing(null); setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/ntp/pools/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) }, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: Pool; checked: boolean }) => { const { id: _id, ...body } = row await apiClient.put(`/ntp/pools/${id}`, { ...body, active: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) }, onError: (e: Error) => message.error(e.message), }) const cols: ColumnsType = [ { title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind', render: (s: string) => {s} }, { title: t('ntp.pool.address'), dataIndex: 'address', key: 'address', render: (s: string) => {s} }, { title: t('ntp.pool.options'), key: 'options', render: (_, row) => ( {row.iburst && iburst} {row.prefer && prefer} {row.minpoll != null && minpoll {row.minpoll}} {row.maxpoll != null && maxpoll {row.maxpoll}} ) }, { title: t('ntp.pool.description'), dataIndex: 'description', key: 'description', render: (v?: string | null) => v ?? '—' }, { title: t('common.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: Pool) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( { setEditing(row) form.setFieldsValue(row) }} onDelete={() => del.mutate(row.id)} deleteConfirm={t('ntp.pool.deleteConfirm', { addr: row.address })} /> ), }, ] const openCreate = () => { setCreating(true); form.resetFields() form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool) } return ( <> } emptyContent={ } title={t('ntp.pool.emptyTitle')} description={t('ntp.pool.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false); form.resetFields() }} onOk={() => { void form.submit() }} confirmLoading={upsert.isPending} width={580} destroyOnClose >
upsert.mutate(v)}>
) } function SettingsTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data, isLoading } = useQuery({ queryKey: ['ntp', 'settings'], queryFn: getSettings }) const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces }) const [form] = Form.useForm() const ipOptions: { value: string; label: string }[] = [ { value: '0.0.0.0', label: `0.0.0.0 — ${t('dns.settings.allIPv4')}` }, { value: '::', label: `:: — ${t('dns.settings.allIPv6')}` }, { value: '127.0.0.1', label: `127.0.0.1 — ${t('dns.settings.loopback')} IPv4` }, { value: '::1', label: `::1 — ${t('dns.settings.loopback')} IPv6` }, ] for (const i of sys ?? []) { if (i.ifname === 'lo') continue for (const a of i.addr_info ?? []) { if (a.local.startsWith('fe80:')) continue ipOptions.push({ value: a.local, label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`, }) } } const initial: SettingsForm | undefined = data ? { ...data, listen_addresses: data.listen_addresses.split(',').map(s => s.trim()).filter(Boolean), } : undefined const save = useMutation({ mutationFn: async (v: SettingsForm) => { const body: Settings = { ...v, listen_addresses: v.listen_addresses.join(', ') } return (await apiClient.put('/ntp/settings', body)).data }, onSuccess: () => { message.success(t('common.save')) void qc.invalidateQueries({ queryKey: ['ntp', 'settings'] }) }, onError: (e: Error) => message.error(e.message), }) const forceSync = useMutation({ mutationFn: async () => { await apiClient.post('/ntp/force-sync') }, onSuccess: () => message.success(t('ntp.settings.forceSyncOk')), onError: (e: Error) => message.error(t('ntp.settings.forceSyncFailed') + ': ' + e.message), }) if (isLoading) return null return (
save.mutate(v)} style={{ maxWidth: 720 }} > ) } 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 = [ { title: t('ntp.sourcesCard.name'), dataIndex: 'name', render: (v: string, row) => ( {v} {row.active && {t(`ntp.sourcesCard.state_${row.state}`)}} ), }, { 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: {t('ntp.sourcesCard.reach')}, dataIndex: 'reach', width: 90, align: 'center' as const, render: (v: string) => {v}, }, { title: t('ntp.sourcesCard.lastRx'), dataIndex: 'last_rx', width: 80, align: 'center' as const }, { title: t('ntp.sourcesCard.sample'), dataIndex: 'sample', render: (v: string) => {v} }, ] return ( {t('ntp.sourcesCard.title')}} extra={} > {data?.error && } dataSource={data?.sources ?? []} columns={cols} rowKey="name" loading={isFetching} size="small" emptyContent={{t('ntp.sourcesCard.empty')}} /> ) }