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}}
| Uređaj | MAC | IP | FW | Stanje | Poslednji put | Heartbeat | Linija | Poslednja linija |
{{range .Uredjaji}}
{{.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}} |
{{end}}
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 → Servis → svcLogHost = <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}}
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}}
`))