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 }