package logger import ( "encoding/json" "fmt" "html/template" "net/http" "strconv" "strings" "time" ) // Handler vraca ceo web sloj prijemnika (bez baze, bez spoljnih biblioteka). func (s *Service) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", s.pageIndex) mux.HandleFunc("GET /uredjaj", s.pageDevice) mux.HandleFunc("GET /dnevnik.txt", s.pageRaw) mux.HandleFunc("GET /api/uredjaji", s.apiDevices) return s.withToken(mux) } // withToken je minimalna zastita kad web nije samo na 127.0.0.1. Token se // prihvata iz ?t= ili iz zaglavlja X-Token; posle prvog uspesnog upita pamti se // u kolacicu da linkovi ostanu kratki. // // Ovo NIJE zamena za nginx + TLS + lozinku - dnevnik sadrzi brojeve karata. func (s *Service) withToken(next http.Handler) http.Handler { if s.cfg.Token == "" { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ok := r.URL.Query().Get("t") == s.cfg.Token || r.Header.Get("X-Token") == s.cfg.Token if !ok { if c, err := r.Cookie("logger_token"); err == nil && c.Value == s.cfg.Token { ok = true } } if !ok { http.Error(w, "Pristup odbijen: nedostaje token.", http.StatusForbidden) return } http.SetCookie(w, &http.Cookie{ Name: "logger_token", Value: s.cfg.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 12 * 3600, }) next.ServeHTTP(w, r) }) } type deviceView struct { Key string Naziv string MAC string IP string FW string Serijski string Poznat bool Tih bool Vidjen string Heartbeat string Interval int Paketa uint64 Linija uint64 Poslednja string IzvornaIP string PrviPutStr string } type indexData struct { Uredjaji []deviceView Ukupno int Paketa uint64 Heartbeata uint64 Dnevnika uint64 Smece uint64 Greske uint64 DiskMB string Fajlova int UDP string Port int Dir string Vreme string Token string } type deviceData struct { U deviceView Linije []string Q string N int Dani []string Vreme string Token string Nadjen bool } func (s *Service) view(d Device, now time.Time) deviceView { naziv := d.Name if naziv == "" { naziv = "(bez naziva)" } hb := "nikad" if !d.LastHeartbeat.IsZero() { hb = humanAgo(now.Sub(d.LastHeartbeat)) } return deviceView{ Key: d.Key, Naziv: naziv, MAC: d.MAC, IP: d.IP, FW: d.FW, Serijski: d.Serial, Poznat: d.Identified, Tih: s.reg.Stale(&d, now), Vidjen: humanAgo(now.Sub(d.LastSeen)), Heartbeat: hb, Interval: s.reg.Interval(&d), Paketa: d.LogPackets + d.Heartbeats, Linija: d.LogLines, Poslednja: d.LastLine, IzvornaIP: d.SourceIP, PrviPutStr: d.FirstSeen.Format("02.01.2006. 15:04:05"), } } // humanAgo pise razmak vremena na srpskom, kratko. func humanAgo(d time.Duration) string { switch { case d < 2*time.Second: return "upravo sada" case d < time.Minute: return fmt.Sprintf("pre %d s", int(d.Seconds())) case d < time.Hour: return fmt.Sprintf("pre %d min", int(d.Minutes())) case d < 48*time.Hour: return fmt.Sprintf("pre %d h", int(d.Hours())) default: return fmt.Sprintf("pre %d dana", int(d.Hours()/24)) } } func (s *Service) pageIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } now := s.now() devs := s.reg.Snapshot(now) views := make([]deviceView, 0, len(devs)) for _, d := range devs { views = append(views, s.view(d, now)) } bytes, files := s.store.Usage() data := indexData{ Uredjaji: views, Ukupno: len(views), Paketa: s.stats.Packets.Load(), Heartbeata: s.stats.Heartbeat.Load(), Dnevnika: s.stats.LogPkts.Load(), Smece: s.stats.Garbage.Load(), Greske: s.stats.WriteErrs.Load(), DiskMB: fmt.Sprintf("%.1f", float64(bytes)/(1<<20)), Fajlova: files, UDP: fmt.Sprintf("%s:%d", orAll(s.cfg.UDPHost), s.cfg.UDPPort), Port: s.cfg.UDPPort, Dir: s.cfg.Dir, Vreme: now.Format("02.01.2006. 15:04:05"), Token: s.cfg.Token, } render(w, tmplIndex, data) } func (s *Service) pageDevice(w http.ResponseWriter, r *http.Request) { key := SanitizeSegment(r.URL.Query().Get("k")) q := r.URL.Query().Get("q") if len(q) > 200 { q = q[:200] } n := 200 if v := r.URL.Query().Get("n"); v != "" { if x, err := strconv.Atoi(v); err == nil && x > 0 && x <= 5000 { n = x } } now := s.now() data := deviceData{Q: q, N: n, Vreme: now.Format("02.01.2006. 15:04:05"), Token: s.cfg.Token} if d, ok := s.reg.Get(key); ok { data.U = s.view(d, now) data.Nadjen = true } else { // Uredjaj moze postojati samo na disku (posle restarta prijemnika). data.U = deviceView{Key: key, Naziv: "(nije se javio od pokretanja prijemnika)"} } lines, err := s.store.Tail(key, n, q) if err != nil { lines = []string{"Greska pri citanju dnevnika: " + err.Error()} } data.Linije = lines data.Dani = s.store.Days(key) render(w, tmplDevice, data) } // pageRaw daje isti sadrzaj kao stranica, ali kao cist tekst - za curl sa // servera i za lepljenje u prepisku. func (s *Service) pageRaw(w http.ResponseWriter, r *http.Request) { key := SanitizeSegment(r.URL.Query().Get("k")) n := 500 if v := r.URL.Query().Get("n"); v != "" { if x, err := strconv.Atoi(v); err == nil && x > 0 && x <= 5000 { n = x } } lines, err := s.store.Tail(key, n, r.URL.Query().Get("q")) if err != nil { http.Error(w, "Greska: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte(strings.Join(lines, "\n") + "\n")) } func (s *Service) apiDevices(w http.ResponseWriter, r *http.Request) { now := s.now() devs := s.reg.Snapshot(now) out := make([]map[string]any, 0, len(devs)) for _, d := range devs { out = append(out, map[string]any{ "kljuc": d.Key, "naziv": d.Name, "mac": d.MAC, "ip": d.IP, "izvorna_ip": d.SourceIP, "fw": d.FW, "serijski": d.Serial, "identifikovan": d.Identified, "tih": s.reg.Stale(&d, now), "interval_sek": s.reg.Interval(&d), "poslednji_put": d.LastSeen.Format(time.RFC3339), "heartbeata": d.Heartbeats, "paketa_dnevnik": d.LogPackets, "linija": d.LogLines, }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetIndent("", " ") enc.Encode(map[string]any{"uredjaji": out, "vreme": now.Format(time.RFC3339)}) } func orAll(h string) string { if h == "" { return "0.0.0.0" } return h } func render(w http.ResponseWriter, t *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.Execute(w, data); err != nil { http.Error(w, "Greska pri prikazu: "+err.Error(), http.StatusInternalServerError) } } const cssCommon = ` body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:0;background:#f4f6f8;color:#1c2733} header{background:#12354f;color:#fff;padding:12px 18px;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap} header h1{font-size:18px;margin:0} header a{color:#9fd3ff;text-decoration:none} main{padding:16px 18px;max-width:1200px} table{border-collapse:collapse;width:100%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.12)} th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e3e8ee;font-size:14px;vertical-align:top} th{background:#eef2f6;font-weight:600} tr.tih td{background:#ffe9e9} .znak{display:inline-block;padding:1px 7px;border-radius:9px;font-size:12px;font-weight:600} .ok{background:#d6f5dd;color:#0f6b2b} .lose{background:#ffd2d2;color:#a11} .nepoznat{background:#ffeccc;color:#8a5300} .mali{color:#5a6b7b;font-size:12px} pre{background:#0f1b26;color:#dbe7f0;padding:12px;border-radius:4px;overflow:auto;font-size:13px;line-height:1.45;max-height:70vh} form.pretraga{margin:12px 0;display:flex;gap:8px;flex-wrap:wrap} input,select{padding:6px 8px;border:1px solid #c6d0da;border-radius:4px;font-size:14px} button{padding:6px 14px;border:0;border-radius:4px;background:#12354f;color:#fff;font-size:14px;cursor:pointer} .prazno{padding:24px;background:#fff;color:#5a6b7b} ` var tmplIndex = template.Must(template.New("index").Parse(` Prijemnik dijagnostike — TERMINIA

Prijemnik dijagnostike

UDP {{.UDP}} · dnevnici: {{.Dir}} ({{.DiskMB}} MB / {{.Fajlova}} fajlova) osveženo {{.Vreme}}

Uređaja: {{.Ukupno}} · primljeno paketa: {{.Paketa}} (heartbeat {{.Heartbeata}}, dnevnik {{.Dnevnika}}, odbačeno {{.Smece}}) · greške upisa: {{.Greske}}

{{if .Uredjaji}} {{range .Uredjaji}} {{end}}
UređajMACIPFWStanjePoslednji putHeartbeatLinijaPoslednja linija
{{.Naziv}} {{if not .Poznat}}
nepoznat uređaj FW stariji od 0.13.4 — prepoznat samo po adresi {{.IzvornaIP}}{{end}}
{{if .MAC}}{{.MAC}}{{else}}{{end}} {{if .IP}}{{.IP}}{{else}}{{.IzvornaIP}}{{end}} {{if .FW}}{{.FW}}{{else}}{{end}} {{if .Tih}}tih{{else}}javlja se{{end}} {{.Vidjen}} {{.Heartbeat}}
interval ~{{.Interval}} s
{{.Linija}} {{.Poslednja}}

Crveno = uređaj nije poslao ništa duže od 3× svog intervala javljanja.

{{else}}
Nijedan uređaj se još nije javio.

Na uređaju: Podešavanja → ServissvcLogHost = <IP servera>:{{.Port}}, uključi „Šalji dnevnik rada na server", heartbeat npr. 60 s.
{{end}}
`)) var tmplDevice = template.Must(template.New("device").Parse(` {{.U.Naziv}} — dnevnik

{{.U.Naziv}}

{{if .U.MAC}}{{.U.MAC}} · {{end}}{{if .U.FW}}FW {{.U.FW}} · {{end}}{{if .U.IP}}{{.U.IP}}{{end}} ← svi uređaji {{.Vreme}}
{{if .Nadjen}}

{{if .U.Tih}}tih{{else}}javlja se{{end}} · poslednji paket {{.U.Vidjen}} · heartbeat {{.U.Heartbeat}} (interval ~{{.U.Interval}} s) · linija dnevnika: {{.U.Linija}} · prvi put viđen {{.U.PrviPutStr}} {{if not .U.Poznat}}
nepoznat uređaj identifikovan samo po izvornoj adresi {{.U.IzvornaIP}} — paket nije imao zaglavlje (FW stariji od 0.13.4){{end}}

{{else}}

Uređaj se nije javio od pokretanja prijemnika — prikazano je ono što je ostalo na disku.

{{end}}
{{if .Token}}{{end}}

Dani sa dnevnikom: {{if .Dani}}{{range .Dani}}{{.}} {{end}}{{else}}nema zapisa{{end}} · preuzmi kao tekst

{{if .Linije}}
{{range .Linije}}{{.}}
{{end}}
{{else}}
Nema linija za zadate uslove.
{{end}}
`))