- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
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<Settings, 'listen_addresses'> {
|
|
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<NTPStatus | null> {
|
|
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<Pool[]> {
|
|
const r = await apiClient.get('/ntp/pools')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { pools?: Pool[] }).pools ?? []
|
|
}
|
|
async function getSettings(): Promise<Settings | null> {
|
|
const r = await apiClient.get('/ntp/settings')
|
|
return isEnvelope(r.data) ? (r.data.data as Settings) : null
|
|
}
|
|
async function listSystemInterfaces(): Promise<SystemIface[]> {
|
|
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 (
|
|
<div>
|
|
<PageHeader
|
|
icon={<ClockCircleOutlined />}
|
|
title={t('ntp.title')}
|
|
subtitle={t('ntp.intro')}
|
|
extra={chrony && (
|
|
<Tag
|
|
icon={chrony.active ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
|
color={chrony.active ? 'green' : 'red'}
|
|
>
|
|
<Space size={4}>
|
|
<span>chrony</span>
|
|
<span style={{ fontWeight: 400, opacity: 0.85 }}>{chrony.state}</span>
|
|
</Space>
|
|
</Tag>
|
|
)}
|
|
/>
|
|
|
|
<Card
|
|
size="small"
|
|
className="mb-12"
|
|
title={<><ClockCircleOutlined /> {t('ntp.statusCard.title')}</>}
|
|
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchStatus()}>{t('common.refresh')}</Button>}
|
|
>
|
|
{ntpStatus?.error ? (
|
|
<Alert type="warning" showIcon message={ntpStatus.error} />
|
|
) : ntpStatus ? (
|
|
<Row gutter={12}>
|
|
<Col xs={12} sm={6}>
|
|
<Statistic
|
|
title={t('ntp.statusCard.sync')}
|
|
value={ntpStatus.synced ? t('ntp.statusCard.synced') : t('ntp.statusCard.notSynced')}
|
|
valueStyle={{ color: ntpStatus.synced ? '#16a34a' : '#cf1322', fontSize: 14 }}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Statistic
|
|
title={t('ntp.statusCard.source')}
|
|
value={ntpStatus.reference || '—'}
|
|
valueStyle={{ fontSize: 13, fontFamily: 'monospace' }}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Statistic
|
|
title={t('ntp.statusCard.stratum')}
|
|
value={ntpStatus.stratum ?? '—'}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Tooltip title={t('ntp.statusCard.offsetHint')}>
|
|
<Statistic
|
|
title={t('ntp.statusCard.offset')}
|
|
value={ntpStatus.offset_ms != null
|
|
? (ntpStatus.offset_ms >= 0 ? '+' : '') + ntpStatus.offset_ms.toFixed(3) + ' ms'
|
|
: '—'}
|
|
valueStyle={{
|
|
fontSize: 13,
|
|
color: ntpStatus.offset_ms != null && Math.abs(ntpStatus.offset_ms) > 100
|
|
? '#d48806' : undefined,
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</Col>
|
|
{ntpStatus.freq_ppm != null && (
|
|
<Col xs={12} sm={6}>
|
|
<Tooltip title={t('ntp.statusCard.freqPpmHint')}>
|
|
<Statistic
|
|
title={t('ntp.statusCard.freqPpm')}
|
|
value={(ntpStatus.freq_ppm >= 0 ? '+' : '') + ntpStatus.freq_ppm.toFixed(3) + ' ppm'}
|
|
valueStyle={{
|
|
fontSize: 13,
|
|
color: Math.abs(ntpStatus.freq_ppm) > 100 ? '#d48806' : undefined,
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</Col>
|
|
)}
|
|
{ntpStatus.rms_offset_ms != null && ntpStatus.rms_offset_ms > 0 && (
|
|
<Col xs={12} sm={6}>
|
|
<Statistic
|
|
title={t('ntp.statusCard.rmsOffset')}
|
|
value={ntpStatus.rms_offset_ms.toFixed(3) + ' ms'}
|
|
valueStyle={{ fontSize: 13 }}
|
|
/>
|
|
</Col>
|
|
)}
|
|
</Row>
|
|
) : (
|
|
<Typography.Text type="secondary">{t('ntp.statusCard.loading')}</Typography.Text>
|
|
)}
|
|
</Card>
|
|
|
|
<Tabs
|
|
defaultActiveKey="pools"
|
|
items={[
|
|
{ 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>
|
|
)
|
|
}
|
|
|
|
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<Pool | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [form] = Form.useForm<Pool>()
|
|
|
|
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<Pool> = [
|
|
{ title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind',
|
|
render: (s: string) => <Tag color={s === 'pool' ? 'blue' : 'purple'}>{s}</Tag> },
|
|
{ title: t('ntp.pool.address'), dataIndex: 'address', key: 'address',
|
|
render: (s: string) => <code>{s}</code> },
|
|
{ title: t('ntp.pool.options'), key: 'options',
|
|
render: (_, row) => (
|
|
<Space size={4}>
|
|
{row.iburst && <Tag>iburst</Tag>}
|
|
{row.prefer && <Tag color="gold">prefer</Tag>}
|
|
{row.minpoll != null && <Text type="secondary">minpoll {row.minpoll}</Text>}
|
|
{row.maxpoll != null && <Text type="secondary">maxpoll {row.maxpoll}</Text>}
|
|
</Space>
|
|
) },
|
|
{ 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) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: t('common.actions'), key: 'actions',
|
|
render: (_, row) => (
|
|
<ActionButtons
|
|
onEdit={() => {
|
|
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 (
|
|
<>
|
|
<DataTable
|
|
rowKey="id"
|
|
loading={isLoading}
|
|
dataSource={data ?? []}
|
|
columns={cols}
|
|
extraActions={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
|
{t('ntp.pool.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
emptyContent={
|
|
<EmptyState
|
|
icon={<ClockCircleOutlined />}
|
|
title={t('ntp.pool.emptyTitle')}
|
|
description={t('ntp.pool.emptyDesc')}
|
|
action={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
|
{t('ntp.pool.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? t('ntp.pool.edit') : t('ntp.pool.add')}
|
|
open={editing !== null || creating}
|
|
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
|
onOk={() => { void form.submit() }}
|
|
confirmLoading={upsert.isPending}
|
|
width={580}
|
|
destroyOnClose
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
|
<Form.Item label={t('ntp.pool.kind')} name="kind" rules={[{ required: true }]}>
|
|
<Select options={[
|
|
{ value: 'pool', label: t('ntp.pool.kindPool') },
|
|
{ value: 'server', label: t('ntp.pool.kindServer') },
|
|
]} />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.pool.address')} name="address" rules={[{ required: true }]}
|
|
extra={t('ntp.pool.addressExtra')}>
|
|
<Input placeholder={t('ntp.pool.addressPlaceholder')} />
|
|
</Form.Item>
|
|
<Space>
|
|
<Form.Item label={t('ntp.pool.iburst')} name="iburst" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.pool.prefer')} name="prefer" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Space>
|
|
<Space>
|
|
<Form.Item label={t('ntp.pool.minpoll')} name="minpoll">
|
|
<InputNumber min={0} max={17} style={{ width: 100 }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.pool.maxpoll')} name="maxpoll">
|
|
<InputNumber min={0} max={17} style={{ width: 100 }} />
|
|
</Form.Item>
|
|
</Space>
|
|
<Form.Item label={t('ntp.pool.description')} name="description">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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<SettingsForm>()
|
|
|
|
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 (
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={initial}
|
|
onFinish={(v) => save.mutate(v)}
|
|
style={{ maxWidth: 720 }}
|
|
>
|
|
<Alert type="info" showIcon className="mb-12" message={t('ntp.settings.intro')} />
|
|
<Form.Item label={t('ntp.settings.serveClients')} name="serve_clients" valuePropName="checked"
|
|
extra={t('ntp.settings.serveClientsExtra')}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.settings.listenAddresses')} name="listen_addresses"
|
|
rules={[{ required: true, type: 'array', min: 1 }]}
|
|
extra={t('ntp.settings.listenAddressesExtra')}>
|
|
<Select mode="tags" options={ipOptions} showSearch optionFilterProp="value"
|
|
placeholder={t('ntp.settings.listenAddressesPlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.settings.allowACL')} name="allow_acl" rules={[{ required: true }]}
|
|
extra={t('ntp.settings.allowACLExtra')}>
|
|
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
|
|
</Form.Item>
|
|
<Space>
|
|
<Form.Item label={t('ntp.settings.makestepSecs')} name="makestep_secs"
|
|
extra={t('ntp.settings.makestepSecsExtra')}>
|
|
<InputNumber min={0} step={0.1} style={{ width: 120 }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.settings.makestepLimit')} name="makestep_limit">
|
|
<InputNumber min={-1} max={100} style={{ width: 120 }} />
|
|
</Form.Item>
|
|
</Space>
|
|
<Form.Item label={t('ntp.settings.rtcsync')} name="rtcsync" valuePropName="checked"
|
|
extra={t('ntp.settings.rtcsyncExtra')}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('ntp.settings.leapsectz')} name="leapsectz"
|
|
extra={t('ntp.settings.leapsectzExtra')}>
|
|
<Input placeholder="right/UTC" allowClear />
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Space>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" htmlType="submit" disabled={isViewer} loading={save.isPending}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title={t('ntp.settings.forceSyncTooltip')}>
|
|
<Button
|
|
icon={<ThunderboltOutlined />}
|
|
loading={forceSync.isPending}
|
|
onClick={() => forceSync.mutate()}
|
|
>
|
|
{t('ntp.settings.forceSyncBtn')}
|
|
</Button>
|
|
</Tooltip>
|
|
</Space>
|
|
</Form.Item>
|
|
</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>
|
|
)
|
|
}
|