From 4629679ba9ade50dabefda52aa4e190b55ff2b0a Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 25 May 2026 11:57:04 +0200 Subject: [PATCH] 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 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/ntp.go | 69 +++++++++++++++ management-ui/src/i18n/locales/de/common.json | 19 +++- management-ui/src/i18n/locales/en/common.json | 19 +++- management-ui/src/pages/NTP/index.tsx | 87 ++++++++++++++++++- 8 files changed, 194 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 72d97e3..2088b5b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.100 +1.1.101 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 6afd85a..eab2fb1 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.100" +var version = "1.1.101" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index d0c54a1..f5bcc41 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.100" +var version = "1.1.101" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 811d20c..282983f 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -35,7 +35,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.100" +var version = "1.1.101" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/ntp.go b/internal/handlers/ntp.go index 4aa71ee..8641c98 100644 --- a/internal/handlers/ntp.go +++ b/internal/handlers/ntp.go @@ -41,6 +41,7 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) { g.GET("/settings", h.GetSettings) g.PUT("/settings", h.UpdateSettings) g.GET("/status", h.Status) + g.GET("/sources", h.Sources) g.POST("/force-sync", h.ForceSync) p := g.Group("/pools") @@ -250,6 +251,74 @@ func (h *NTPHandler) ForceSync(c *gin.Context) { response.OK(c, gin.H{"message": "clock stepped", "output": string(out)}) } +// Sources liefert die aktuellen NTP-Quellen via `chronyc sources`. +// Jede Zeile wird in ein NTPSource-Objekt geparst und als Array zurückgegeben. +func (h *NTPHandler) Sources(c *gin.Context) { + out, err := exec.Command("chronyc", "sources").Output() + if err != nil { + response.OK(c, gin.H{ + "sources": []any{}, + "error": "chronyc nicht verfügbar: " + err.Error(), + }) + return + } + response.OK(c, gin.H{"sources": parseChronymSources(string(out))}) +} + +type ntpSource struct { + Mode string `json:"mode"` + State string `json:"state"` + Active bool `json:"active"` + Name string `json:"name"` + Stratum int `json:"stratum"` + Poll int `json:"poll"` + Reach string `json:"reach"` + LastRx string `json:"last_rx"` + Sample string `json:"sample"` +} + +func parseChronymSources(out string) []ntpSource { + modeMap := map[byte]string{'^': "server", '=': "peer", '#': "local"} + stateMap := map[byte]string{ + '*': "synced", '+': "combined", '-': "not_combined", + '?': "unreachable", 'x': "error", '~': "variable", + } + var srcs []ntpSource + for _, line := range strings.Split(out, "\n") { + if len(line) < 2 { + continue + } + mode, ok := modeMap[line[0]] + if !ok { + continue + } + stateChar := line[1] + stateStr, ok := stateMap[stateChar] + if !ok { + stateStr = string(stateChar) + } + fields := strings.Fields(strings.TrimSpace(line[2:])) + if len(fields) < 5 { + continue + } + src := ntpSource{ + Mode: mode, + State: stateStr, + Active: stateChar == '*' || stateChar == '+', + Name: fields[0], + Reach: fields[3], + LastRx: fields[4], + } + fmt.Sscanf(fields[1], "%d", &src.Stratum) + fmt.Sscanf(fields[2], "%d", &src.Poll) + if len(fields) >= 6 { + src.Sample = strings.Join(fields[5:], " ") + } + srcs = append(srcs, src) + } + return srcs +} + func validateNTPPool(p *models.NTPPool) error { if p.Address == "" { return errors.New("address required") diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index e062f3b..5f4e316 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -808,7 +808,24 @@ "ntp": { "title": "Zeitserver (Chrony)", "intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.", - "tabs": { "pools": "Quellen", "settings": "Settings" }, + "tabs": { "pools": "Quellen", "settings": "Settings", "peers": "Peer-Status" }, + "sourcesCard": { + "title": "Live Peer-Status (chronyc sources)", + "name": "Name / IP", + "stratum": "Stratum", + "poll": "Poll", + "reach": "Erreichbarkeit", + "lastRx": "Letzter Empfang", + "sample": "Offset ± Fehler", + "reachHint": "8-Bit-Schieberegister (oktal) — 377 = alle 8 letzten Abfragen beantwortet. 0 = nicht erreichbar.", + "empty": "Keine Peer-Daten — läuft chrony?", + "state_synced": "Synchronisiert", + "state_combined": "Kombiniert", + "state_not_combined": "Nicht kombiniert", + "state_unreachable": "Nicht erreichbar", + "state_error": "Fehler", + "state_variable": "Variabel" + }, "statusCard": { "title": "Sync-Status (chronyc tracking)", "sync": "Synchronisiert", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index d86b59c..7bfbddd 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -808,7 +808,24 @@ "ntp": { "title": "Time server (Chrony)", "intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.", - "tabs": { "pools": "Sources", "settings": "Settings" }, + "tabs": { "pools": "Sources", "settings": "Settings", "peers": "Peer status" }, + "sourcesCard": { + "title": "Live peer status (chronyc sources)", + "name": "Name / IP", + "stratum": "Stratum", + "poll": "Poll", + "reach": "Reach", + "lastRx": "Last rx", + "sample": "Offset ± error", + "reachHint": "8-bit shift register (octal) — 377 = all 8 recent polls replied. 0 = unreachable.", + "empty": "No peer data — is chrony running?", + "state_synced": "Synced", + "state_combined": "Combined", + "state_not_combined": "Not combined", + "state_unreachable": "Unreachable", + "state_error": "Error", + "state_variable": "Variable" + }, "statusCard": { "title": "Sync status (chronyc tracking)", "sync": "Synchronized", diff --git a/management-ui/src/pages/NTP/index.tsx b/management-ui/src/pages/NTP/index.tsx index 537f960..510677c 100644 --- a/management-ui/src/pages/NTP/index.tsx +++ b/management-ui/src/pages/NTP/index.tsx @@ -170,8 +170,9 @@ export default function NTPPage() { {t('ntp.tabs.pools')}, children: }, - { key: 'settings', label: {t('ntp.tabs.settings')}, children: }, + { key: 'pools', label: {t('ntp.tabs.pools')}, children: }, + { key: 'peers', label: {t('ntp.tabs.peers')}, children: }, + { key: 'settings', label: {t('ntp.tabs.settings')}, children: }, ]} /> @@ -449,3 +450,85 @@ function SettingsTab() { ) } + +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')}} + /> + + ) +}