Resolve host IPs through DNS, add purge, French date format by default

- Reverse DNS (cached PTR lookups): IP hosts are stored with their name
  in 'host' and the IP in 'host_ip'; older IP-only logs are resolved on
  display and the host filter shows 'name (IP)'. RDNS / DNS_SERVER env.
- Settings > Danger zone: delete all logs (type PURGE to confirm) through
  VictoriaLogs /delete/run_task; -delete.enable added to docker-compose.
  ALLOW_PURGE env to disable it.
- Remove the custom YYYY-MM-DD - HH:MM:SS:mmm format; default is now the
  usual French display DD/MM/YYYY HH:MM:SS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-09-28 15:39:53 +02:00
1 parent 85701a9119
commit 7a706eeb82
11 files changed
+455 -21

No files matched your search

+6
View File
@@ -7,3 +7,9 @@ RETENTION=30d
# Web UI authentication (empty = disabled) # Web UI authentication (empty = disabled)
AUTH_USER= AUTH_USER=
AUTH_PASS= AUTH_PASS=
# Reverse DNS: show host names instead of IP addresses (on/off)
RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
DNS_SERVER=
# Allow "Delete all logs" in Settings (true/false)
ALLOW_PURGE=true
+23 -2
View File
@@ -58,8 +58,25 @@ The gear icon opens the settings panel:
- **Language**: English or French. The choice is remembered in the browser. - **Language**: English or French. The choice is remembered in the browser.
- **Date & time**: time zone (browser zone, UTC, or about 80 common zones) and the display - **Date & time**: time zone (browser zone, UTC, or about 80 common zones) and the display
format of the reception time, e.g. `YYYY-MM-DD - HH:MM:SS:mmm` (default), ISO 8601, format of the reception time: `DD/MM/YYYY HH:MM:SS` (default, the usual French display),
12-hour clock or Unix epoch. The time zone applies to every date shown. Remembered in the browser. with milliseconds, `YYYY-MM-DD`, 12-hour clock, ISO 8601 or Unix epoch. The time zone
applies to every date shown. Remembered in the browser.
- **Danger zone**: "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature.
Anyone who can open the UI can purge: set `AUTH_USER` / `AUTH_PASS` if the UI is reachable
by others.
## Host names (reverse DNS)
When a device sends its IP address as host name (or no host name at all), Logstream looks up
its DNS name (PTR record) and stores the name in `host` and the IP in `host_ip`. Results are
cached (1 hour, 10 minutes when there is no name). Logs stored earlier with an IP are resolved
on display, and the host filter shows `name (IP)`.
The container uses Docker's DNS, which forwards to the host's resolvers. If your local names
are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
`DNS_SERVER=192.168.1.1` (its address). Set `RDNS=off` to disable lookups.
- **Color tags**: each tag has a keyword, a background color (the text automatically - **Color tags**: each tag has a keyword, a background color (the text automatically
switches to black or white to stay readable) and options: whole word, match case, switches to black or white to stay readable) and options: whole word, match case,
regular expression, active. Tags are stored in `/data/tags.json` (`logstream-data` volume). regular expression, active. Tags are stored in `/data/tags.json` (`logstream-data` volume).
@@ -73,6 +90,9 @@ The gear icon opens the settings panel:
| `HTTP_PORT` | `8080` | web UI port | | `HTTP_PORT` | `8080` | web UI port |
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs | | `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI | | `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI |
| `RDNS` | `on` | resolve IP hosts to DNS names |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) | | `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning | | `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning |
@@ -98,6 +118,7 @@ The gear icon opens the settings panel:
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries | | `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
| `query.go` | turns UI filters into LogsQL; live-view filter | | `query.go` | turns UI filters into LogsQL; live-view filter |
| `hub.go` | pushes new messages to browsers (SSE) | | `hub.go` | pushes new messages to browsers (SSE) |
| `rdns.go` | cached reverse DNS lookups |
| `tags.go` | color tag storage | | `tags.go` | color tag storage |
| `api.go` | `/api/*` HTTP routes | | `api.go` | `/api/*` HTTP routes |
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) |
+96 -6
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
@@ -11,9 +13,11 @@ import (
) )
type API struct { type API struct {
store *Store store *Store
hub *Hub hub *Hub
tags *TagStore tags *TagStore
rdns *ReverseDNS
allowPurge bool
} }
func (a *API) Routes(mux *http.ServeMux) { func (a *API) Routes(mux *http.ServeMux) {
@@ -28,6 +32,8 @@ func (a *API) Routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/tags/reset", a.resetTags) mux.HandleFunc("POST /api/tags/reset", a.resetTags)
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag) mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag)
mux.HandleFunc("GET /api/purge", a.purgeStatus)
mux.HandleFunc("POST /api/purge", a.purge)
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -70,6 +76,7 @@ func (a *API) logs(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadGateway, err) writeErr(w, http.StatusBadGateway, err)
return return
} }
a.annotateHosts(rows)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"query": q, "query": q,
"rows": rows, "rows": rows,
@@ -122,9 +129,39 @@ func toInt(v any) int64 {
return 0 return 0
} }
// annotateHosts adds "host_name" to rows whose host is still an IP address
// (logs stored before DNS resolution, or resolved too slowly at reception).
func (a *API) annotateHosts(rows []map[string]any) {
seen := map[string]bool{}
var ips []string
for _, row := range rows {
if h, _ := row["host"].(string); h != "" && !seen[h] && net.ParseIP(h) != nil {
seen[h] = true
ips = append(ips, h)
}
}
if len(ips) == 0 {
return
}
if len(ips) > 100 {
ips = ips[:100]
}
names := a.rdns.LookupMany(ips, time.Second)
for _, row := range rows {
if h, _ := row["host"].(string); names[h] != "" {
row["host_name"] = names[h]
}
}
}
type facet struct {
Value string `json:"value"` // stored value, used for filtering
Label string `json:"label"` // displayed text ("name (ip)" for resolved IPs)
}
// GET /api/facets: hosts and apps seen over 7 days, for the filter dropdowns. // GET /api/facets: hosts and apps seen over 7 days, for the filter dropdowns.
func (a *API) facets(w http.ResponseWriter, r *http.Request) { func (a *API) facets(w http.ResponseWriter, r *http.Request) {
res := map[string][]string{} res := map[string][]facet{}
for _, field := range []string{"host", "app"} { for _, field := range []string{"host", "app"} {
q := "_time:7d | stats by (" + field + ") count() hits | sort by (hits desc) | limit 300" q := "_time:7d | stats by (" + field + ") count() hits | sort by (hits desc) | limit 300"
rows, err := a.store.Query(r.Context(), q) rows, err := a.store.Query(r.Context(), q)
@@ -133,13 +170,26 @@ func (a *API) facets(w http.ResponseWriter, r *http.Request) {
return return
} }
vals := make([]string, 0, len(rows)) vals := make([]string, 0, len(rows))
var ips []string
for _, row := range rows { for _, row := range rows {
if v, _ := row[field].(string); v != "" { if v, _ := row[field].(string); v != "" {
vals = append(vals, v) vals = append(vals, v)
if field == "host" && net.ParseIP(v) != nil {
ips = append(ips, v)
}
} }
} }
sort.Strings(vals) names := a.rdns.LookupMany(ips, time.Second)
res[field] = vals list := make([]facet, 0, len(vals))
for _, v := range vals {
label := v
if n := names[v]; n != "" {
label = n + " (" + v + ")"
}
list = append(list, facet{Value: v, Label: label})
}
sort.Slice(list, func(i, j int) bool { return list[i].Label < list[j].Label })
res[field] = list
} }
writeJSON(w, http.StatusOK, res) writeJSON(w, http.StatusOK, res)
} }
@@ -266,6 +316,46 @@ func (a *API) deleteTag(w http.ResponseWriter, r *http.Request) {
} }
} }
// GET /api/purge: whether purging is allowed and how many deletions are running.
func (a *API) purgeStatus(w http.ResponseWriter, r *http.Request) {
res := map[string]any{"allowed": a.allowPurge, "running": 0}
if a.allowPurge {
n, err := a.store.PurgeRunning(r.Context())
if err != nil {
res["error"] = err.Error()
var ce *codedError
if errors.As(err, &ce) {
res["code"] = ce.code
}
}
res["running"] = n
}
writeJSON(w, http.StatusOK, res)
}
// POST /api/purge {"confirm":"PURGE"}: deletes every stored log.
func (a *API) purge(w http.ResponseWriter, r *http.Request) {
if !a.allowPurge {
writeErr(w, http.StatusForbidden, &codedError{code: "purge_forbidden", msg: "purging is disabled (ALLOW_PURGE=false)"})
return
}
var body struct {
Confirm string `json:"confirm"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body)
if body.Confirm != "PURGE" {
writeErr(w, http.StatusBadRequest, &codedError{code: "purge_confirm", msg: `type "PURGE" to confirm`})
return
}
id, err := a.store.Purge(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, err)
return
}
log.Printf("purge of all logs requested from %s (task %s)", r.RemoteAddr, id)
writeJSON(w, http.StatusOK, map[string]string{"task_id": id})
}
func (a *API) resetTags(w http.ResponseWriter, r *http.Request) { func (a *API) resetTags(w http.ResponseWriter, r *http.Request) {
tags, err := a.tags.Reset() tags, err := a.tags.Reset()
if err != nil { if err != nil {
+4
View File
@@ -14,6 +14,9 @@ services:
TZ: ${TZ:-Europe/Paris} TZ: ${TZ:-Europe/Paris}
AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication
AUTH_PASS: ${AUTH_PASS:-} AUTH_PASS: ${AUTH_PASS:-}
RDNS: ${RDNS:-on} # replace IP hosts with their DNS name (PTR)
DNS_SERVER: ${DNS_SERVER:-} # e.g. 192.168.1.1 to query your LAN DNS; empty = system resolver
ALLOW_PURGE: ${ALLOW_PURGE:-true}
volumes: volumes:
- logstream-data:/data # tags.json - logstream-data:/data # tags.json
@@ -26,6 +29,7 @@ services:
- -storageDataPath=/vlogs - -storageDataPath=/vlogs
- -retentionPeriod=${RETENTION:-30d} - -retentionPeriod=${RETENTION:-30d}
- -httpListenAddr=:9428 - -httpListenAddr=:9428
- -delete.enable # required by "Delete all logs" in Settings
volumes: volumes:
- vlogs-data:/vlogs - vlogs-data:/vlogs
ports: ports:
+24 -1
View File
@@ -15,6 +15,7 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
_ "time/tzdata" // embedded time zones: TZ works without a system package _ "time/tzdata" // embedded time zones: TZ works without a system package
@@ -30,6 +31,9 @@ type config struct {
dataDir string dataDir string
authUser string authUser string
authPass string authPass string
rdns bool
dnsServer string
allowPurge bool
batchSize int batchSize int
queueSize int queueSize int
flushEvery time.Duration flushEvery time.Duration
@@ -49,6 +53,16 @@ func getenvInt(key string, def int) int {
return def return def
} }
func getenvBool(key string, def bool) bool {
switch strings.ToLower(os.Getenv(key)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
return def
}
func main() { func main() {
cfg := config{ cfg := config{
syslogAddr: getenv("SYSLOG_ADDR", ":5514"), syslogAddr: getenv("SYSLOG_ADDR", ":5514"),
@@ -57,6 +71,9 @@ func main() {
dataDir: getenv("DATA_DIR", "/data"), dataDir: getenv("DATA_DIR", "/data"),
authUser: os.Getenv("AUTH_USER"), authUser: os.Getenv("AUTH_USER"),
authPass: os.Getenv("AUTH_PASS"), authPass: os.Getenv("AUTH_PASS"),
rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true),
batchSize: getenvInt("BATCH_SIZE", 1000), batchSize: getenvInt("BATCH_SIZE", 1000),
queueSize: getenvInt("QUEUE_SIZE", 100000), queueSize: getenvInt("QUEUE_SIZE", 100000),
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond, flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
@@ -78,7 +95,13 @@ func main() {
}() }()
hub := NewHub() hub := NewHub()
rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer)
sink := func(e *Entry) { sink := func(e *Entry) {
// Host sent as an IP (or no host in the header): replace it with its DNS name.
// A new IP waits at most 300 ms; slower lookups finish in the background.
if name := rdns.Lookup(e.Host, 300*time.Millisecond); name != "" {
e.HostIP, e.Host = e.Host, name
}
store.Enqueue(e) store.Enqueue(e)
hub.Publish(e) hub.Publish(e)
} }
@@ -91,7 +114,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
api := &API{store: store, hub: hub, tags: tags} api := &API{store: store, hub: hub, tags: tags, rdns: rdns, allowPurge: cfg.allowPurge}
api.Routes(mux) api.Routes(mux)
mux.Handle("GET /", http.FileServer(http.FS(static))) mux.Handle("GET /", http.FileServer(http.FS(static)))
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"context"
"net"
"strings"
"sync"
"time"
)
// ReverseDNS resolves IP addresses to host names (PTR records), with a cache.
type ReverseDNS struct {
enabled bool
r *net.Resolver
posTTL time.Duration // cache duration of a found name
negTTL time.Duration // cache duration of "no name"
mu sync.Mutex
cache map[string]*rdnsEntry
}
type rdnsEntry struct {
name string
expires time.Time
done chan struct{} // closed once the lookup has finished
}
const rdnsMaxEntries = 10000
// NewReverseDNS uses the system resolver, or `server` ("ip" or "ip:port") when set.
func NewReverseDNS(enabled bool, server string) *ReverseDNS {
r := net.DefaultResolver
if server != "" {
if _, _, err := net.SplitHostPort(server); err != nil {
server = net.JoinHostPort(server, "53")
}
dialer := net.Dialer{Timeout: 2 * time.Second}
r = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, server)
},
}
}
return &ReverseDNS{
enabled: enabled,
r: r,
posTTL: time.Hour,
negTTL: 10 * time.Minute,
cache: make(map[string]*rdnsEntry),
}
}
func isClosed(ch chan struct{}) bool {
select {
case <-ch:
return true
default:
return false
}
}
// Lookup returns the name of ip, or "" when ip is not an IP address, has no
// PTR record, or is not resolved within `wait`. A lookup that takes longer
// keeps running in the background and fills the cache for later calls.
func (d *ReverseDNS) Lookup(ip string, wait time.Duration) string {
if !d.enabled || net.ParseIP(ip) == nil {
return ""
}
d.mu.Lock()
e := d.cache[ip]
if e == nil || (isClosed(e.done) && time.Now().After(e.expires)) {
if len(d.cache) >= rdnsMaxEntries {
d.cache = make(map[string]*rdnsEntry)
}
e = &rdnsEntry{done: make(chan struct{})}
d.cache[ip] = e
go d.resolve(ip, e)
}
d.mu.Unlock()
if !isClosed(e.done) {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-e.done:
case <-timer.C:
return ""
}
}
return e.name
}
// LookupMany resolves several addresses in parallel; unresolved ones are absent.
func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]string {
out := make(map[string]string)
var mu sync.Mutex
var wg sync.WaitGroup
for _, ip := range ips {
wg.Add(1)
go func(ip string) {
defer wg.Done()
if name := d.Lookup(ip, wait); name != "" {
mu.Lock()
out[ip] = name
mu.Unlock()
}
}(ip)
}
wg.Wait()
return out
}
func (d *ReverseDNS) resolve(ip string, e *rdnsEntry) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ttl := d.negTTL
if names, err := d.r.LookupAddr(ctx, ip); err == nil && len(names) > 0 {
e.name = strings.TrimSuffix(names[0], ".")
ttl = d.posTTL
}
e.expires = time.Now().Add(ttl)
close(e.done)
}
+54
View File
@@ -171,6 +171,60 @@ func (s *Store) Query(ctx context.Context, query string) ([]map[string]any, erro
return rows, sc.Err() return rows, sc.Err()
} }
// Purge starts a VictoriaLogs task that deletes every stored log, and resets
// the counters. VictoriaLogs must run with -delete.enable.
func (s *Store) Purge(ctx context.Context) (string, error) {
body, err := s.deleteCall(ctx, "/delete/run_task?filter="+url.QueryEscape("*"))
if err != nil {
return "", err
}
var res struct {
TaskID string `json:"task_id"`
}
if err := json.Unmarshal(body, &res); err != nil {
return "", fmt.Errorf("unexpected VictoriaLogs answer: %s", strings.TrimSpace(string(body)))
}
s.received.Store(0)
s.ingested.Store(0)
s.dropped.Store(0)
s.lastErr.Store("")
return res.TaskID, nil
}
// PurgeRunning returns the number of deletion tasks still running in VictoriaLogs.
func (s *Store) PurgeRunning(ctx context.Context) (int, error) {
body, err := s.deleteCall(ctx, "/delete/active_tasks")
if err != nil {
return 0, err
}
var tasks []json.RawMessage
if err := json.Unmarshal(body, &tasks); err != nil {
return 0, fmt.Errorf("unexpected VictoriaLogs answer: %s", strings.TrimSpace(string(body)))
}
return len(tasks), nil
}
func (s *Store) deleteCall(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+path, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusNotFound || strings.Contains(msg, "delete.enable") {
return nil, &codedError{code: "purge_unavailable", msg: "VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)", detail: msg}
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
}
return body, nil
}
func (s *Store) Stats() map[string]any { func (s *Store) Stats() map[string]any {
return map[string]any{ return map[string]any{
"received": s.received.Load(), "received": s.received.Load(),
+4
View File
@@ -34,6 +34,7 @@ type Entry struct {
Message string Message string
Source string // sender IP address Source string // sender IP address
Proto string // udp or tcp Proto string // udp or tcp
HostIP string // original host value when it was an IP resolved through DNS
} }
// Record returns the entry in the shape stored in VictoriaLogs and returned by the API. // Record returns the entry in the shape stored in VictoriaLogs and returned by the API.
@@ -56,6 +57,9 @@ func (e *Entry) Record() map[string]string {
if e.SD != "" { if e.SD != "" {
r["sd"] = e.SD r["sd"] = e.SD
} }
if e.HostIP != "" {
r["host_ip"] = e.HostIP
}
return r return r
} }
+103 -12
View File
@@ -64,6 +64,18 @@ const I18N = {
fReceived: 'received', fReceived: 'received',
rcvTitle: 'Received by the server', rcvTitle: 'Received by the server',
msgTimeTitle: 'Timestamp from the message', msgTimeTitle: 'Timestamp from the message',
fHostName: 'host name (DNS)', fHostIp: 'host IP',
dangerZone: 'Danger zone',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
purgeBtn: 'Delete all logs…',
purgePrompt: 'This permanently deletes ALL stored logs.\n\nType PURGE to confirm:',
purgeStarted: 'Deletion started',
purgeRunning: 'Deletion in progress… logs disappear gradually.',
purgeDone: 'All logs have been deleted.',
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
err_purge_confirm: 'Type PURGE to confirm',
}, },
fr: { fr: {
locale: 'fr-FR', locale: 'fr-FR',
@@ -126,6 +138,18 @@ const I18N = {
fReceived: 'réception', fReceived: 'réception',
rcvTitle: 'Reçu par le serveur', rcvTitle: 'Reçu par le serveur',
msgTimeTitle: 'Horodatage contenu dans le message', msgTimeTitle: 'Horodatage contenu dans le message',
fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte',
dangerZone: 'Zone de danger',
purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.',
purgeBtn: 'Supprimer tous les logs…',
purgePrompt: 'Cette action supprime définitivement TOUS les logs stockés.\n\nTapez PURGE pour confirmer :',
purgeStarted: 'Suppression lancée',
purgeRunning: 'Suppression en cours… les logs disparaissent progressivement.',
purgeDone: 'Tous les logs ont été supprimés.',
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
err_purge_confirm: 'Tapez PURGE pour confirmer',
}, },
}; };
@@ -180,11 +204,10 @@ const TIME_ZONES = {
// Each format receives the date parts in the selected time zone (and the Date itself). // Each format receives the date parts in the selected time zone (and the Date itself).
const TIME_FORMATS = [ const TIME_FORMATS = [
{ id: 'ymd-dash', label: 'YYYY-MM-DD - HH:MM:SS:mmm', f: (p) => `${p.Y}-${p.M}-${p.D} - ${p.h}:${p.m}:${p.s}:${p.ms}` },
{ id: 'ymd-ms', label: 'YYYY-MM-DD HH:MM:SS.mmm', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'ymd', label: 'YYYY-MM-DD HH:MM:SS', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}` },
{ id: 'dmy-ms', label: 'DD/MM/YYYY HH:MM:SS.mmm', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'dmy', label: 'DD/MM/YYYY HH:MM:SS', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}` }, { id: 'dmy', label: 'DD/MM/YYYY HH:MM:SS', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}` },
{ id: 'dmy-ms', label: 'DD/MM/YYYY HH:MM:SS.mmm', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'ymd', label: 'YYYY-MM-DD HH:MM:SS', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}` },
{ id: 'ymd-ms', label: 'YYYY-MM-DD HH:MM:SS.mmm', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'mdy12', label: 'MM/DD/YYYY hh:MM:SS.mmm AM/PM', f: (p) => { { id: 'mdy12', label: 'MM/DD/YYYY hh:MM:SS.mmm AM/PM', f: (p) => {
const H = Number(p.h); const H = Number(p.h);
return `${p.M}/${p.D}/${p.Y} ${pad(H % 12 || 12)}:${p.m}:${p.s}.${p.ms} ${H < 12 ? 'AM' : 'PM'}`; return `${p.M}/${p.D}/${p.Y} ${pad(H % 12 || 12)}:${p.m}:${p.s}.${p.ms} ${H < 12 ? 'AM' : 'PM'}`;
@@ -194,7 +217,8 @@ const TIME_FORMATS = [
{ id: 'epoch', label: null, f: (p, d) => String(d.getTime()) }, { id: 'epoch', label: null, f: (p, d) => String(d.getTime()) },
]; ];
const timePrefs = { tz: '', fmt: 'ymd-dash' }; // tz '' = browser time zone const DEFAULT_TIME_FMT = 'dmy'; // usual French display: 28/09/2026 14:55:23
const timePrefs = { tz: '', fmt: DEFAULT_TIME_FMT }; // tz '' = browser time zone
const partsFormatters = new Map(); const partsFormatters = new Map();
function validTimeZone(tz) { function validTimeZone(tz) {
@@ -306,6 +330,7 @@ function setLang(next) {
renderStats(); renderStats();
renderTagList(); renderTagList();
renderTimeSettings(); renderTimeSettings();
renderPurge();
} }
$('#langSwitch').addEventListener('click', (ev) => { $('#langSwitch').addEventListener('click', (ev) => {
@@ -434,12 +459,19 @@ function rowHTML(r, isNew) {
+ `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</time>` + `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</time>`
+ `<time class="mt" title="${esc(t('msgTimeTitle'))}">${fmtTime(r._time)}</time>` + `<time class="mt" title="${esc(t('msgTimeTitle'))}">${fmtTime(r._time)}</time>`
+ `<span class="sev">${esc(sev || '—')}</span>` + `<span class="sev">${esc(sev || '—')}</span>`
+ `<span class="host" title="${esc(r.host)}">${esc(r.host)}</span>` + `<span class="host" title="${esc(hostTitle(r))}">${esc(r.host_name || r.host)}</span>`
+ `<span class="app" title="${esc(r.app)}">${esc(r.app)}</span>` + `<span class="app" title="${esc(r.app)}">${esc(r.app)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>` + `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>'; + '</article>';
} }
// Tooltip of the host cell: name and IP when both are known.
function hostTitle(r) {
const ip = r.host_ip || (r.host_name ? r.host : '');
const name = r.host_name || r.host;
return ip ? `${name} (${ip})` : name;
}
// Map the .row elements starting at `first` to their records. // Map the .row elements starting at `first` to their records.
function bindRows(first, rows) { function bindRows(first, rows) {
let el = first; let el = first;
@@ -483,11 +515,11 @@ function updateCount(tookMs) {
} }
function detailsHTML(r) { function detailsHTML(r) {
const order = ['received', '_time', 'host', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg']; const order = ['received', '_time', 'host', 'host_name', 'host_ip', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const hidden = new Set(['_stream_id', '_stream', 'sevnum']); const hidden = new Set(['_stream_id', '_stream', 'sevnum']);
const keys = order.filter((k) => r[k] != null && r[k] !== '') const keys = order.filter((k) => r[k] != null && r[k] !== '')
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort()); .concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
const label = { received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') }; const label = { host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time' || k === 'received' const val = (k) => (k === '_time' || k === 'received'
? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>` ? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>`
: esc(r[k])); : esc(r[k]));
@@ -709,7 +741,7 @@ async function loadFacets() {
const sel = $('#' + id); const sel = $('#' + id);
const cur = sel.value; const cur = sel.value;
const first = sel.options[0]; const first = sel.options[0];
sel.replaceChildren(first, ...(f[id] || []).map((v) => new Option(v, v))); sel.replaceChildren(first, ...(f[id] || []).map((x) => new Option(x.label || x.value, x.value)));
if (cur) setSelect(id, cur); if (cur) setSelect(id, cur);
} }
} catch { /* VictoriaLogs not ready yet: retried later */ } } catch { /* VictoriaLogs not ready yet: retried later */ }
@@ -775,6 +807,60 @@ $('#tzSelect').addEventListener('change', onTimePrefsChange);
$('#fmtSelect').addEventListener('change', onTimePrefsChange); $('#fmtSelect').addEventListener('change', onTimePrefsChange);
setInterval(() => { if ($('#settingsDlg').open) updateTimePreview(); }, 1000); setInterval(() => { if ($('#settingsDlg').open) updateTimePreview(); }, 1000);
/* ================= Purge ================= */
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
function renderPurge() {
const btn = $('#purgeBtn');
const status = $('#purgeStatus');
btn.disabled = !purge.allowed || purge.running > 0;
let msg = '';
let cls = 'muted';
if (!purge.allowed) msg = t('purgeForbidden');
else if (purge.code && I18N[lang]['err_' + purge.code]) { msg = t('err_' + purge.code); cls = 'bad'; }
else if (purge.running > 0) msg = t('purgeRunning');
else if (purge.wasRunning) { msg = t('purgeDone'); cls = 'good'; }
status.textContent = msg;
status.className = 'purge-status ' + cls;
status.hidden = !msg;
}
async function loadPurgeStatus() {
try {
const s = await api('/api/purge');
purge.allowed = s.allowed;
purge.code = s.code || null;
if (purge.running > 0 && !s.running) {
purge.wasRunning = true;
refresh();
loadFacets();
loadStats();
}
purge.running = s.running || 0;
} catch { /* shown on the next attempt */ }
renderPurge();
clearTimeout(purge.timer);
if (purge.running > 0) purge.timer = setTimeout(loadPurgeStatus, 2000);
}
$('#purgeBtn').addEventListener('click', async () => {
const answer = prompt(t('purgePrompt'));
if (answer === null) return;
try {
await api('/api/purge', { method: 'POST', body: { confirm: answer.trim() } });
toast(t('purgeStarted'));
purge.running = 1;
purge.wasRunning = false;
state.rows = [];
renderList();
updateCount();
loadPurgeStatus();
} catch (e) {
toast(e.message);
}
});
/* ================= Tag management ================= */ /* ================= Tag management ================= */
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316']; const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
@@ -875,7 +961,12 @@ $('#resetTags').addEventListener('click', async () => {
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
}); });
$('#settingsBtn').addEventListener('click', () => { renderTimeSettings(); renderTagList(); $('#settingsDlg').showModal(); }); $('#settingsBtn').addEventListener('click', () => {
renderTimeSettings();
renderTagList();
loadPurgeStatus();
$('#settingsDlg').showModal();
});
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close()); $('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); }); $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
@@ -884,8 +975,8 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
{ {
const tz = store.get('tz', ''); const tz = store.get('tz', '');
timePrefs.tz = validTimeZone(tz) ? tz : ''; timePrefs.tz = validTimeZone(tz) ? tz : '';
const fmt = store.get('timefmt', 'ymd-dash'); const fmt = store.get('timefmt', DEFAULT_TIME_FMT);
timePrefs.fmt = TIME_FORMATS.some((f) => f.id === fmt) ? fmt : 'ymd-dash'; timePrefs.fmt = TIME_FORMATS.some((f) => f.id === fmt) ? fmt : DEFAULT_TIME_FMT;
} }
applyLang(); applyLang();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
+9
View File
@@ -125,6 +125,15 @@
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button> <button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer> </footer>
</section> </section>
<section class="set-section danger">
<h3 data-i18n="dangerZone">Danger zone</h3>
<p class="muted small" data-i18n="purgeHelp"></p>
<div class="purge-row">
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
<span id="purgeStatus" class="purge-status muted" hidden></span>
</div>
</section>
</div> </div>
</dialog> </dialog>
+8
View File
@@ -332,6 +332,14 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
margin: 10px 0 6px !important; padding: 8px 12px; border-radius: 8px; margin: 10px 0 6px !important; padding: 8px 12px; border-radius: 8px;
background: var(--panel-2); font-family: var(--mono); font-size: 12.5px; background: var(--panel-2); font-family: var(--mono); font-size: 12.5px;
} }
.set-section.danger h3 { color: var(--sev-err); }
.purge-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
.btn.danger { color: var(--sev-err); border-color: color-mix(in srgb, var(--sev-err) 45%, var(--border)); }
.btn.danger:hover:not(:disabled) { background: var(--sev-err); border-color: var(--sev-err); color: #fff; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.purge-status { font-size: 12.5px; }
.purge-status.bad { color: var(--sev-err); }
.purge-status.good { color: var(--sev-info); }
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; } .set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } .seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }