- UDP prijemnik (podrazumevano 46465, NE 46464 gde uredjaji slusaju discovery): dnevnik rada sa zaglavljem #TERMINIA, heartbeat JSON, sve ostalo se odbacuje - paket bez zaglavlja (FW < 0.13.4) se vodi kao "nepoznat uredjaj" po izvornoj IP adresi i tako je i oznacen u prikazu - dnevnik u fajlove po uredjaju i danu, rotacija po velicini + tri kvote (starost, po uredjaju, ukupno); otvoren fajl se nikad ne brise - heartbeat u memoriji; interval se meri iz razmaka javljanja, "tih" = 3x interval - web stranica (podrazumevano 127.0.0.1:8081): spisak uredjaja, crveno za tihe, poslednjih N linija sa pretragom, /dnevnik.txt i /api/uredjaji - bez baze i bez spoljnih zavisnosti; ne dira licencni servis, njegovu bazu, rute ni nginx - sanitizacija svega iz paketa (duzine, kontrolni znaci, ime foldera) — bez izlaska iz foldera dnevnika - systemd unit + primer env fajla + README sa uputstvom za pustanje na server - testovi: parsiranje (zaglavlje/JSON/bez zaglavlja/smece), registar, rotacija i kvote, web rute Nije pusteno na server — puštanje radi vlasnik po logger/README.md.
366 lines
12 KiB
Go
366 lines
12 KiB
Go
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(`<!doctype html>
|
||
<html lang="sr"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta http-equiv="refresh" content="10">
|
||
<title>Prijemnik dijagnostike — TERMINIA</title>
|
||
<style>` + cssCommon + `</style></head>
|
||
<body>
|
||
<header><h1>Prijemnik dijagnostike</h1>
|
||
<span class="mali">UDP {{.UDP}} · dnevnici: {{.Dir}} ({{.DiskMB}} MB / {{.Fajlova}} fajlova)</span>
|
||
<span class="mali">osveženo {{.Vreme}}</span></header>
|
||
<main>
|
||
<p class="mali">Uređaja: <b>{{.Ukupno}}</b> · primljeno paketa: {{.Paketa}}
|
||
(heartbeat {{.Heartbeata}}, dnevnik {{.Dnevnika}}, odbačeno {{.Smece}}) · greške upisa: {{.Greske}}</p>
|
||
{{if .Uredjaji}}
|
||
<table>
|
||
<tr><th>Uređaj</th><th>MAC</th><th>IP</th><th>FW</th><th>Stanje</th><th>Poslednji put</th><th>Heartbeat</th><th>Linija</th><th>Poslednja linija</th></tr>
|
||
{{range .Uredjaji}}
|
||
<tr {{if .Tih}}class="tih"{{end}}>
|
||
<td><a href="/uredjaj?k={{.Key}}{{if $.Token}}&t={{$.Token}}{{end}}"><b>{{.Naziv}}</b></a>
|
||
{{if not .Poznat}}<br><span class="znak nepoznat">nepoznat uređaj</span>
|
||
<span class="mali">FW stariji od 0.13.4 — prepoznat samo po adresi {{.IzvornaIP}}</span>{{end}}</td>
|
||
<td>{{if .MAC}}{{.MAC}}{{else}}<span class="mali">—</span>{{end}}</td>
|
||
<td>{{if .IP}}{{.IP}}{{else}}{{.IzvornaIP}}{{end}}</td>
|
||
<td>{{if .FW}}{{.FW}}{{else}}<span class="mali">—</span>{{end}}</td>
|
||
<td>{{if .Tih}}<span class="znak lose">tih</span>{{else}}<span class="znak ok">javlja se</span>{{end}}</td>
|
||
<td>{{.Vidjen}}</td>
|
||
<td>{{.Heartbeat}}<br><span class="mali">interval ~{{.Interval}} s</span></td>
|
||
<td>{{.Linija}}</td>
|
||
<td class="mali">{{.Poslednja}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</table>
|
||
<p class="mali">Crveno = uređaj nije poslao ništa duže od 3× svog intervala javljanja.</p>
|
||
{{else}}
|
||
<div class="prazno">Nijedan uređaj se još nije javio.<br><br>
|
||
Na uređaju: <b>Podešavanja → Servis</b> → <code>svcLogHost</code> = <code><IP servera>:{{.Port}}</code>,
|
||
uključi „Šalji dnevnik rada na server", heartbeat npr. 60 s.</div>
|
||
{{end}}
|
||
</main></body></html>`))
|
||
|
||
var tmplDevice = template.Must(template.New("device").Parse(`<!doctype html>
|
||
<html lang="sr"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{{.U.Naziv}} — dnevnik</title>
|
||
<style>` + cssCommon + `</style></head>
|
||
<body>
|
||
<header><h1>{{.U.Naziv}}</h1>
|
||
<span class="mali">{{if .U.MAC}}{{.U.MAC}} · {{end}}{{if .U.FW}}FW {{.U.FW}} · {{end}}{{if .U.IP}}{{.U.IP}}{{end}}</span>
|
||
<a href="/{{if .Token}}?t={{.Token}}{{end}}">← svi uređaji</a>
|
||
<span class="mali">{{.Vreme}}</span></header>
|
||
<main>
|
||
{{if .Nadjen}}
|
||
<p class="mali">
|
||
{{if .U.Tih}}<span class="znak lose">tih</span>{{else}}<span class="znak ok">javlja se</span>{{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}}<br><span class="znak nepoznat">nepoznat uređaj</span>
|
||
identifikovan samo po izvornoj adresi {{.U.IzvornaIP}} — paket nije imao zaglavlje (FW stariji od 0.13.4){{end}}
|
||
</p>
|
||
{{else}}
|
||
<p class="mali">Uređaj se nije javio od pokretanja prijemnika — prikazano je ono što je ostalo na disku.</p>
|
||
{{end}}
|
||
<form class="pretraga" method="get" action="/uredjaj">
|
||
<input type="hidden" name="k" value="{{.U.Key}}">
|
||
{{if .Token}}<input type="hidden" name="t" value="{{.Token}}">{{end}}
|
||
<input type="text" name="q" value="{{.Q}}" placeholder="pretraga po tekstu (npr. stampac)" size="40">
|
||
<select name="n">
|
||
<option value="100" {{if eq .N 100}}selected{{end}}>100 linija</option>
|
||
<option value="200" {{if eq .N 200}}selected{{end}}>200 linija</option>
|
||
<option value="500" {{if eq .N 500}}selected{{end}}>500 linija</option>
|
||
<option value="2000" {{if eq .N 2000}}selected{{end}}>2000 linija</option>
|
||
</select>
|
||
<button type="submit">Prikaži</button>
|
||
</form>
|
||
<p class="mali">Dani sa dnevnikom: {{if .Dani}}{{range .Dani}}{{.}} {{end}}{{else}}nema zapisa{{end}}
|
||
· <a href="/dnevnik.txt?k={{.U.Key}}&n={{.N}}&q={{.Q}}{{if .Token}}&t={{.Token}}{{end}}">preuzmi kao tekst</a></p>
|
||
{{if .Linije}}<pre>{{range .Linije}}{{.}}
|
||
{{end}}</pre>{{else}}<div class="prazno">Nema linija za zadate uslove.</div>{{end}}
|
||
</main></body></html>`))
|