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:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.100"
|
var version = "1.1.101"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.100"
|
var version = "1.1.101"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.GET("/settings", h.GetSettings)
|
g.GET("/settings", h.GetSettings)
|
||||||
g.PUT("/settings", h.UpdateSettings)
|
g.PUT("/settings", h.UpdateSettings)
|
||||||
g.GET("/status", h.Status)
|
g.GET("/status", h.Status)
|
||||||
|
g.GET("/sources", h.Sources)
|
||||||
g.POST("/force-sync", h.ForceSync)
|
g.POST("/force-sync", h.ForceSync)
|
||||||
|
|
||||||
p := g.Group("/pools")
|
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)})
|
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 {
|
func validateNTPPool(p *models.NTPPool) error {
|
||||||
if p.Address == "" {
|
if p.Address == "" {
|
||||||
return errors.New("address required")
|
return errors.New("address required")
|
||||||
|
|||||||
@@ -808,7 +808,24 @@
|
|||||||
"ntp": {
|
"ntp": {
|
||||||
"title": "Zeitserver (Chrony)",
|
"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.",
|
"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": {
|
"statusCard": {
|
||||||
"title": "Sync-Status (chronyc tracking)",
|
"title": "Sync-Status (chronyc tracking)",
|
||||||
"sync": "Synchronisiert",
|
"sync": "Synchronisiert",
|
||||||
|
|||||||
@@ -808,7 +808,24 @@
|
|||||||
"ntp": {
|
"ntp": {
|
||||||
"title": "Time server (Chrony)",
|
"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.",
|
"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": {
|
"statusCard": {
|
||||||
"title": "Sync status (chronyc tracking)",
|
"title": "Sync status (chronyc tracking)",
|
||||||
"sync": "Synchronized",
|
"sync": "Synchronized",
|
||||||
|
|||||||
@@ -170,8 +170,9 @@ export default function NTPPage() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="pools"
|
defaultActiveKey="pools"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'pools', label: <span><DatabaseOutlined /> {t('ntp.tabs.pools')}</span>, children: <PoolsTab /> },
|
{ 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: 'peers', label: <span><ClockCircleOutlined /> {t('ntp.tabs.peers')}</span>, children: <SourcesTab /> },
|
||||||
|
{ key: 'settings', label: <span><SettingOutlined /> {t('ntp.tabs.settings')}</span>, children: <SettingsTab /> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -449,3 +450,85 @@ function SettingsTab() {
|
|||||||
</Form>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user