- 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.
477 lines
11 KiB
Go
477 lines
11 KiB
Go
package logger
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Imena fajlova: dnevni fajl je "2026-07-25.log", a kad prebaci granicu
|
|
// velicine nastavlja se "2026-07-25.log.001", ".002" ...
|
|
//
|
|
// Nastavci su nula-dopunjeni namerno: fajlovi se brisu redom po IMENU, a
|
|
// leksikografski redosled ovakvih imena je i hronoloski (".log" < ".log.001"
|
|
// < ".log.002" < sledeci dan). Bez dopune bi ".log.10" doslo pre ".log.2".
|
|
const (
|
|
dayLayout = "2006-01-02"
|
|
logExt = ".log"
|
|
maxOpenFiles = 64
|
|
tailReadCap = 2 << 20 // koliko najvise bajtova citamo iz jednog fajla pri prikazu
|
|
)
|
|
|
|
// StoreConfig su granice pisanja. Sve u bajtovima osim KeepDays.
|
|
type StoreConfig struct {
|
|
Dir string
|
|
MaxFileBytes int64 // preko ovoga se rotira na sledeci nastavak
|
|
MaxDeviceBytes int64 // ukupno po uredjaju
|
|
MaxTotalBytes int64 // ukupno za sve uredjaje
|
|
KeepDays int // stariji dnevnici se brisu
|
|
}
|
|
|
|
// Store pise linije dnevnika u fajlove i pazi da ne napuni disk.
|
|
type Store struct {
|
|
cfg StoreConfig
|
|
|
|
mu sync.Mutex
|
|
open map[string]*openFile
|
|
}
|
|
|
|
type openFile struct {
|
|
f *os.File
|
|
w *bufio.Writer
|
|
path string
|
|
day string
|
|
seq int
|
|
size int64
|
|
used time.Time
|
|
}
|
|
|
|
func NewStore(cfg StoreConfig) (*Store, error) {
|
|
if cfg.Dir == "" {
|
|
return nil, fmt.Errorf("store: prazna putanja direktorijuma")
|
|
}
|
|
if cfg.MaxFileBytes <= 0 {
|
|
cfg.MaxFileBytes = 10 << 20
|
|
}
|
|
if cfg.MaxDeviceBytes <= 0 {
|
|
cfg.MaxDeviceBytes = 50 << 20
|
|
}
|
|
if cfg.MaxTotalBytes <= 0 {
|
|
cfg.MaxTotalBytes = 1 << 30
|
|
}
|
|
if cfg.KeepDays <= 0 {
|
|
cfg.KeepDays = 14
|
|
}
|
|
if err := os.MkdirAll(cfg.Dir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("store: kreiranje %s: %w", cfg.Dir, err)
|
|
}
|
|
return &Store{cfg: cfg, open: make(map[string]*openFile)}, nil
|
|
}
|
|
|
|
// Write dopisuje linije u dnevnik uredjaja. Kljuc MORA biti vec sanitizovan
|
|
// (DeviceKey), ali se sanitizuje jos jednom - pisanje je poslednja odbrana.
|
|
func (s *Store) Write(key string, when time.Time, lines []string) error {
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
key = SanitizeSegment(key)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
of, err := s.fileForLocked(key, when)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stamp := when.Format("2006-01-02 15:04:05")
|
|
for _, ln := range lines {
|
|
n, err := fmt.Fprintf(of.w, "%s %s\n", stamp, ln)
|
|
if err != nil {
|
|
return fmt.Errorf("store: upis u %s: %w", of.path, err)
|
|
}
|
|
of.size += int64(n)
|
|
}
|
|
if err := of.w.Flush(); err != nil {
|
|
return fmt.Errorf("store: pranjenje bafera %s: %w", of.path, err)
|
|
}
|
|
of.used = when
|
|
if of.size >= s.cfg.MaxFileBytes {
|
|
s.closeLocked(key) // sledeci upis otvara naredni nastavak
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) fileForLocked(key string, when time.Time) (*openFile, error) {
|
|
day := when.Format(dayLayout)
|
|
if of, ok := s.open[key]; ok {
|
|
if of.day == day && of.size < s.cfg.MaxFileBytes {
|
|
return of, nil
|
|
}
|
|
s.closeLocked(key)
|
|
}
|
|
if len(s.open) >= maxOpenFiles {
|
|
s.closeIdlestLocked()
|
|
}
|
|
dir := filepath.Join(s.cfg.Dir, key)
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("store: kreiranje %s: %w", dir, err)
|
|
}
|
|
path, size, seq, err := nextPath(dir, day, s.cfg.MaxFileBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: otvaranje %s: %w", path, err)
|
|
}
|
|
of := &openFile{f: f, w: bufio.NewWriter(f), path: path, day: day, seq: seq, size: size, used: when}
|
|
s.open[key] = of
|
|
return of, nil
|
|
}
|
|
|
|
// nextPath bira u koji fajl se pise za dati dan: osnovni ako jos nije pun,
|
|
// inace prvi slobodan nastavak.
|
|
func nextPath(dir, day string, maxBytes int64) (string, int64, int, error) {
|
|
base := filepath.Join(dir, day+logExt)
|
|
st, err := os.Stat(base)
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
return base, 0, 0, nil
|
|
case err != nil:
|
|
return "", 0, 0, fmt.Errorf("store: stat %s: %w", base, err)
|
|
case st.Size() < maxBytes:
|
|
return base, st.Size(), 0, nil
|
|
}
|
|
for seq := 1; seq < 1000; seq++ {
|
|
p := fmt.Sprintf("%s.%03d", base, seq)
|
|
st, err := os.Stat(p)
|
|
if os.IsNotExist(err) {
|
|
return p, 0, seq, nil
|
|
}
|
|
if err != nil {
|
|
return "", 0, 0, fmt.Errorf("store: stat %s: %w", p, err)
|
|
}
|
|
if st.Size() < maxBytes {
|
|
return p, st.Size(), seq, nil
|
|
}
|
|
}
|
|
return "", 0, 0, fmt.Errorf("store: previse nastavaka za %s", base)
|
|
}
|
|
|
|
func (s *Store) closeLocked(key string) {
|
|
if of, ok := s.open[key]; ok {
|
|
of.w.Flush()
|
|
of.f.Close()
|
|
delete(s.open, key)
|
|
}
|
|
}
|
|
|
|
func (s *Store) closeIdlestLocked() {
|
|
var key string
|
|
var oldest time.Time
|
|
for k, of := range s.open {
|
|
if key == "" || of.used.Before(oldest) {
|
|
key, oldest = k, of.used
|
|
}
|
|
}
|
|
if key != "" {
|
|
s.closeLocked(key)
|
|
}
|
|
}
|
|
|
|
// CloseIdle zatvara fajlove u koje se duze nista ne pise (da se ne drze
|
|
// otvoreni deskriptori i da rotacija/brisanje ne nailazi na njih).
|
|
func (s *Store) CloseIdle(now time.Time, idle time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for k, of := range s.open {
|
|
if now.Sub(of.used) > idle {
|
|
s.closeLocked(k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close zatvara sve otvorene fajlove.
|
|
func (s *Store) Close() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for k := range s.open {
|
|
s.closeLocked(k)
|
|
}
|
|
}
|
|
|
|
type logFile struct {
|
|
key string
|
|
path string
|
|
name string
|
|
day string
|
|
size int64
|
|
}
|
|
|
|
// list nabraja sve dnevnicke fajlove, sortirane od najstarijeg ka najnovijem.
|
|
func (s *Store) list() ([]logFile, error) {
|
|
dirs, err := os.ReadDir(s.cfg.Dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: citanje %s: %w", s.cfg.Dir, err)
|
|
}
|
|
var out []logFile
|
|
for _, d := range dirs {
|
|
if !d.IsDir() {
|
|
continue
|
|
}
|
|
key := d.Name()
|
|
files, err := os.ReadDir(filepath.Join(s.cfg.Dir, key))
|
|
if err != nil {
|
|
continue // uredjaj bez foldera nije razlog za pad prijemnika
|
|
}
|
|
for _, f := range files {
|
|
name := f.Name()
|
|
if f.IsDir() || !strings.Contains(name, logExt) {
|
|
continue
|
|
}
|
|
day := name
|
|
if i := strings.Index(name, logExt); i > 0 {
|
|
day = name[:i]
|
|
}
|
|
if _, err := time.Parse(dayLayout, day); err != nil {
|
|
continue // tudji fajl u folderu - ne diramo ga
|
|
}
|
|
info, err := f.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, logFile{
|
|
key: key,
|
|
path: filepath.Join(s.cfg.Dir, key, name),
|
|
name: name,
|
|
day: day,
|
|
size: info.Size(),
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].name != out[j].name {
|
|
return out[i].name < out[j].name
|
|
}
|
|
return out[i].key < out[j].key
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// Cleanup sprovodi tri granice redom: starost, kvota po uredjaju, ukupna
|
|
// kvota. Vraca broj obrisanih fajlova.
|
|
//
|
|
// Fajl koji je trenutno otvoren za pisanje se NIKAD ne brise: na Linuxu
|
|
// brisanje otvorenog fajla ne oslobodi prostor dok se ne zatvori, a upisi
|
|
// posle toga tiho odlaze u nepostojeci fajl.
|
|
func (s *Store) Cleanup(now time.Time) (int, error) {
|
|
files, err := s.list()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
s.mu.Lock()
|
|
busy := make(map[string]bool, len(s.open))
|
|
for _, of := range s.open {
|
|
busy[of.path] = true
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
deleted := 0
|
|
remove := func(f logFile) bool {
|
|
if busy[f.path] {
|
|
return false
|
|
}
|
|
if err := os.Remove(f.path); err != nil {
|
|
return false
|
|
}
|
|
deleted++
|
|
return true
|
|
}
|
|
|
|
// 1) starost
|
|
cutoff := now.AddDate(0, 0, -s.cfg.KeepDays)
|
|
kept := files[:0]
|
|
for _, f := range files {
|
|
d, err := time.Parse(dayLayout, f.day)
|
|
if err == nil && d.Before(cutoff) && remove(f) {
|
|
continue
|
|
}
|
|
kept = append(kept, f)
|
|
}
|
|
files = kept
|
|
|
|
// 2) kvota po uredjaju
|
|
perDevice := map[string]int64{}
|
|
for _, f := range files {
|
|
perDevice[f.key] += f.size
|
|
}
|
|
kept = files[:0]
|
|
for _, f := range files {
|
|
if perDevice[f.key] > s.cfg.MaxDeviceBytes && remove(f) {
|
|
perDevice[f.key] -= f.size
|
|
continue
|
|
}
|
|
kept = append(kept, f)
|
|
}
|
|
files = kept
|
|
|
|
// 3) ukupna kvota
|
|
var total int64
|
|
for _, f := range files {
|
|
total += f.size
|
|
}
|
|
for _, f := range files {
|
|
if total <= s.cfg.MaxTotalBytes {
|
|
break
|
|
}
|
|
if remove(f) {
|
|
total -= f.size
|
|
}
|
|
}
|
|
|
|
s.pruneEmptyDirs()
|
|
return deleted, nil
|
|
}
|
|
|
|
// pruneEmptyDirs brise foldere uredjaja koji su ostali bez ijednog dnevnika.
|
|
func (s *Store) pruneEmptyDirs() {
|
|
dirs, err := os.ReadDir(s.cfg.Dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, d := range dirs {
|
|
if !d.IsDir() {
|
|
continue
|
|
}
|
|
p := filepath.Join(s.cfg.Dir, d.Name())
|
|
if entries, err := os.ReadDir(p); err == nil && len(entries) == 0 {
|
|
os.Remove(p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Usage vraca ukupno zauzece na disku (bajtova) i broj fajlova.
|
|
func (s *Store) Usage() (int64, int) {
|
|
files, err := s.list()
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
var total int64
|
|
for _, f := range files {
|
|
total += f.size
|
|
}
|
|
return total, len(files)
|
|
}
|
|
|
|
// Tail vraca poslednjih n linija dnevnika jednog uredjaja, opciono filtriranih
|
|
// po tekstu (bez razlike u velicini slova). Redosled je hronoloski.
|
|
func (s *Store) Tail(key string, n int, query string) ([]string, error) {
|
|
key = SanitizeSegment(key)
|
|
if n <= 0 || n > 5000 {
|
|
n = 200
|
|
}
|
|
// Bafer se prazni da bi i linije primljene pre sekund bile vidljive.
|
|
s.mu.Lock()
|
|
if of, ok := s.open[key]; ok {
|
|
of.w.Flush()
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
files, err := s.list()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var mine []logFile
|
|
for _, f := range files {
|
|
if f.key == key {
|
|
mine = append(mine, f)
|
|
}
|
|
}
|
|
q := strings.ToLower(strings.TrimSpace(query))
|
|
|
|
out := make([]string, 0, n)
|
|
// od najnovijeg fajla unazad, dok se ne skupi n linija
|
|
for i := len(mine) - 1; i >= 0 && len(out) < n; i-- {
|
|
lines, err := readTailLines(mine[i].path, tailReadCap)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for j := len(lines) - 1; j >= 0 && len(out) < n; j-- {
|
|
if q != "" && !strings.Contains(strings.ToLower(lines[j]), q) {
|
|
continue
|
|
}
|
|
out = append(out, lines[j])
|
|
}
|
|
}
|
|
// out je od najnovije ka najstarijoj - okrecemo
|
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// readTailLines cita poslednjih najvise cap bajtova fajla i vraca cele linije.
|
|
func readTailLines(path string, cap int64) ([]string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
size := st.Size()
|
|
off := int64(0)
|
|
if size > cap {
|
|
off = size - cap
|
|
}
|
|
if _, err := f.Seek(off, io.SeekStart); err != nil {
|
|
return nil, err
|
|
}
|
|
buf := make([]byte, size-off)
|
|
if _, err := io.ReadFull(f, buf); err != nil && err != io.ErrUnexpectedEOF {
|
|
return nil, err
|
|
}
|
|
text := string(buf)
|
|
if off > 0 {
|
|
// prva linija je verovatno presecena na pola - odbacujemo je
|
|
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
|
text = text[i+1:]
|
|
}
|
|
}
|
|
lines := strings.Split(text, "\n")
|
|
out := lines[:0]
|
|
for _, ln := range lines {
|
|
if strings.TrimSpace(ln) != "" {
|
|
out = append(out, ln)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Days vraca datume za koje postoji dnevnik datog uredjaja (najnoviji prvi).
|
|
func (s *Store) Days(key string) []string {
|
|
key = SanitizeSegment(key)
|
|
files, err := s.list()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for i := len(files) - 1; i >= 0; i-- {
|
|
if files[i].key != key || seen[files[i].day] {
|
|
continue
|
|
}
|
|
seen[files[i].day] = true
|
|
out = append(out, files[i].day)
|
|
}
|
|
return out
|
|
}
|