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

@@ -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")