Database statistics in Settings > Data
New GET /api/dbstats reads VictoriaLogs /metrics (stored lines, size on disk, raw size, free space, partitions, retention) and two LogsQL queries (period covered, distinct hosts and apps, lines of the last 24 h and hour), cached for 30 s. The danger zone shows them, sizes in KB/MB/GB or Ko/Mo/Go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e901de442c
commit
8b5ee73ee1
8 files changed
+381
-2
No files matched your search
+5
-1
@@ -261,7 +261,11 @@ couleur, est mémorisé par navigateur.
|
||||
européen de polices respectueux de la vie privée ; sans accès à internet, la police du
|
||||
système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
|
||||
quels. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
|
||||
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
|
||||
- **Données** : chiffres de la base lus dans VictoriaLogs (actualisés au plus toutes les 30 s) :
|
||||
lignes stockées, taille sur disque (index compris), taille brute et taux de compression,
|
||||
période couverte avec la rétention, lignes des dernières 24 h et de la dernière heure, hôtes
|
||||
et applications distincts, espace disque libre. Les tailles sont en Ko/Mo/Go (KB/MB/GB en
|
||||
anglais). « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
|
||||
taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit
|
||||
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`). La fonction est
|
||||
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
|
||||
|
||||
@@ -237,7 +237,10 @@ remembered per browser.
|
||||
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
|
||||
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as
|
||||
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
|
||||
- **Data**: "Delete all logs" permanently erases every stored log (you must type
|
||||
- **Data**: database figures read from VictoriaLogs (refreshed at most every 30 s): stored
|
||||
lines, size on disk (index included), raw size and compression ratio, period covered with
|
||||
the retention, lines of the last 24 h and last hour, distinct hosts and apps, free disk
|
||||
space. Sizes use KB/MB/GB (Ko/Mo/Go in French). "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`). The feature is off by default: set `ALLOW_PURGE=true`
|
||||
to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
|
||||
|
||||
@@ -23,6 +23,7 @@ type API struct {
|
||||
docker *DockerManager // nil when DOCKER_LOGS is off
|
||||
syslog *SyslogServer
|
||||
host *HostLogs
|
||||
dbstats dbStatsCache
|
||||
}
|
||||
|
||||
func (a *API) Routes(mux *http.ServeMux) {
|
||||
@@ -32,6 +33,7 @@ func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/facets", a.facets)
|
||||
mux.HandleFunc("GET /api/stream", a.stream)
|
||||
mux.HandleFunc("GET /api/stats", a.stats)
|
||||
mux.HandleFunc("GET /api/dbstats", a.dbStats)
|
||||
mux.HandleFunc("GET /api/tags", a.listTags)
|
||||
mux.HandleFunc("POST /api/tags", a.createTag)
|
||||
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
||||
@@ -314,6 +316,17 @@ func (a *API) stats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
// GET /api/dbstats[?refresh=1]: size and content of the VictoriaLogs base
|
||||
// (Settings > Data), cached for 30 s.
|
||||
func (a *API) dbStats(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := a.dbstats.get(r.Context(), a.store, r.URL.Query().Get("refresh") == "1")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.tags.List())
|
||||
}
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DBStats describes what VictoriaLogs holds (Settings > Data). Sizes are in
|
||||
// bytes; a field VictoriaLogs did not give stays at its zero value.
|
||||
type DBStats struct {
|
||||
Rows int64 `json:"rows"` // stored log lines
|
||||
DiskBytes int64 `json:"diskBytes"` // compressed data + index, on disk
|
||||
IndexBytes int64 `json:"indexBytes"` // index part of DiskBytes
|
||||
RawBytes int64 `json:"rawBytes"` // data before compression
|
||||
FreeBytes int64 `json:"freeBytes"` // free space on the VictoriaLogs volume
|
||||
Days int64 `json:"days"` // per-day partitions
|
||||
Retention string `json:"retention"` // -retentionPeriod, e.g. "30d"
|
||||
Oldest string `json:"oldest,omitempty"`
|
||||
Newest string `json:"newest,omitempty"`
|
||||
Last1h int64 `json:"last1h"`
|
||||
Last24h int64 `json:"last24h"`
|
||||
Hosts int64 `json:"hosts"`
|
||||
Apps int64 `json:"apps"`
|
||||
QueryError string `json:"queryError,omitempty"` // the LogsQL part failed, the metrics are still valid
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// dbStatsCache keeps the last answer for a while: the LogsQL part reads the
|
||||
// whole base, which is too heavy to repeat each time the tab is opened.
|
||||
type dbStatsCache struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
last *DBStats
|
||||
}
|
||||
|
||||
const dbStatsTTL = 30 * time.Second
|
||||
|
||||
func (c *dbStatsCache) get(ctx context.Context, s *Store, force bool) (*DBStats, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.last != nil && !force && time.Since(c.at) < dbStatsTTL {
|
||||
return c.last, nil
|
||||
}
|
||||
st, err := s.DBStats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.last, c.at = st, time.Now()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// DBStats reads the storage metrics of VictoriaLogs (/metrics), then counts
|
||||
// with LogsQL what the metrics do not tell (period, recent volume, sources).
|
||||
func (s *Store) DBStats(ctx context.Context) (*DBStats, error) {
|
||||
st, err := s.storageMetrics(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.Updated = time.Now().UTC().Format(time.RFC3339)
|
||||
if st.Rows == 0 {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
qctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := s.Query(qctx, "* | stats min(_time) oldest, max(_time) newest, count_uniq(host) hosts, count_uniq(app) apps")
|
||||
if err == nil && len(rows) > 0 {
|
||||
r := rows[0]
|
||||
st.Oldest, _ = r["oldest"].(string)
|
||||
st.Newest, _ = r["newest"].(string)
|
||||
st.Hosts, st.Apps = toInt(r["hosts"]), toInt(r["apps"])
|
||||
rows, err = s.Query(qctx, "_time:24h | stats count() last24h, count() if (_time:1h) last1h")
|
||||
if err == nil && len(rows) > 0 {
|
||||
st.Last24h, st.Last1h = toInt(rows[0]["last24h"]), toInt(rows[0]["last1h"])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
st.QueryError = err.Error()
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Store) storageMetrics(ctx context.Context) (*DBStats, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/metrics", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("VictoriaLogs /metrics: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return parseStorageMetrics(resp.Body)
|
||||
}
|
||||
|
||||
// parseStorageMetrics picks the storage figures out of the Prometheus text
|
||||
// format of VictoriaLogs' /metrics (app/vlstorage).
|
||||
func parseStorageMetrics(r io.Reader) (*DBStats, error) {
|
||||
st := &DBStats{}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
name, labels, value := splitMetric(line)
|
||||
switch name {
|
||||
case "vl_storage_rows":
|
||||
st.Rows += int64(value)
|
||||
case "vl_data_size_bytes":
|
||||
st.DiskBytes += int64(value)
|
||||
if labels["type"] == "indexdb" {
|
||||
st.IndexBytes += int64(value)
|
||||
}
|
||||
case "vl_uncompressed_data_size_bytes":
|
||||
st.RawBytes += int64(value)
|
||||
case "vl_free_disk_space_bytes":
|
||||
st.FreeBytes = int64(value)
|
||||
case "vl_partitions":
|
||||
st.Days = int64(value)
|
||||
case "flag":
|
||||
if labels["name"] == "retentionPeriod" {
|
||||
st.Retention = labels["value"]
|
||||
}
|
||||
}
|
||||
}
|
||||
return st, sc.Err()
|
||||
}
|
||||
|
||||
// splitMetric splits `name{a="x",b="y"} 12` into its parts. Label values with
|
||||
// escaped quotes are not expected in the metrics read here.
|
||||
func splitMetric(line string) (string, map[string]string, float64) {
|
||||
sp := strings.LastIndexByte(line, ' ')
|
||||
if sp < 0 {
|
||||
return "", nil, 0
|
||||
}
|
||||
value, _ := strconv.ParseFloat(line[sp+1:], 64)
|
||||
head := line[:sp]
|
||||
name, rest, ok := strings.Cut(head, "{")
|
||||
if !ok {
|
||||
return head, nil, value
|
||||
}
|
||||
labels := map[string]string{}
|
||||
rest = strings.TrimSuffix(rest, "}")
|
||||
for rest != "" {
|
||||
k, v, ok := strings.Cut(rest, `="`)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
val, after, _ := strings.Cut(v, `"`)
|
||||
labels[strings.TrimSpace(k)] = val
|
||||
rest = strings.TrimPrefix(after, ",")
|
||||
}
|
||||
return name, labels, value
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Excerpt of VictoriaLogs v1.52 /metrics.
|
||||
const vlMetrics = `# a comment
|
||||
vl_free_disk_space_bytes{path="/vlogs"} 52428800000
|
||||
vl_storage_rows{type="storage/inmemory"} 120
|
||||
vl_storage_rows{type="storage/small"} 3000
|
||||
vl_storage_rows{type="storage/big"} 1000000
|
||||
vl_partitions 12
|
||||
vl_data_size_bytes{type="indexdb"} 2048
|
||||
vl_data_size_bytes{type="storage"} 1048576
|
||||
vl_compressed_data_size_bytes{type="storage/big"} 1000000
|
||||
vl_uncompressed_data_size_bytes{type="storage/inmemory"} 4096
|
||||
vl_uncompressed_data_size_bytes{type="storage/small"} 100000
|
||||
vl_uncompressed_data_size_bytes{type="storage/big"} 20000000
|
||||
flag{name="retentionPeriod", value="30d", is_set="true"} 1
|
||||
flag{name="httpListenAddr", value=":9428", is_set="true"} 1
|
||||
`
|
||||
|
||||
func TestParseStorageMetrics(t *testing.T) {
|
||||
st, err := parseStorageMetrics(strings.NewReader(vlMetrics))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := DBStats{Rows: 1003120, DiskBytes: 1050624, IndexBytes: 2048, RawBytes: 20104096, FreeBytes: 52428800000, Days: 12, Retention: "30d"}
|
||||
if *st != want {
|
||||
t.Fatalf("got %+v\nwant %+v", *st, want)
|
||||
}
|
||||
}
|
||||
|
||||
// statsVL answers /metrics and the LogsQL queries of DBStats.
|
||||
type statsVL struct{ queries int }
|
||||
|
||||
func (v *statsVL) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
body := vlMetrics
|
||||
if r.URL.Path == "/select/logsql/query" {
|
||||
v.queries++
|
||||
_ = r.ParseForm()
|
||||
if strings.HasPrefix(r.PostForm.Get("query"), "_time:24h") {
|
||||
body = `{"last24h":"5000","last1h":"300"}` + "\n"
|
||||
} else {
|
||||
body = `{"oldest":"2026-09-21T08:00:00Z","newest":"2026-10-03T15:00:00Z","hosts":"4","apps":"17"}` + "\n"
|
||||
}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
|
||||
}
|
||||
|
||||
func TestDBStatsHandler(t *testing.T) {
|
||||
vl := &statsVL{}
|
||||
store := NewStore("http://vl", 10, 10, time.Second, nil)
|
||||
store.client.Transport = vl
|
||||
store.streamClient.Transport = vl
|
||||
a := &API{store: store}
|
||||
|
||||
get := func(url string) DBStats {
|
||||
rec := httptest.NewRecorder()
|
||||
a.dbStats(rec, httptest.NewRequest("GET", url, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var st DBStats
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
st := get("/api/dbstats")
|
||||
if st.Rows != 1003120 || st.Oldest != "2026-09-21T08:00:00Z" || st.Hosts != 4 || st.Apps != 17 || st.Last24h != 5000 || st.Last1h != 300 || st.QueryError != "" {
|
||||
t.Fatalf("unexpected stats: %+v", st)
|
||||
}
|
||||
get("/api/dbstats")
|
||||
if vl.queries != 2 {
|
||||
t.Fatalf("second call within the TTL should be cached, got %d queries", vl.queries)
|
||||
}
|
||||
get("/api/dbstats?refresh=1")
|
||||
if vl.queries != 4 {
|
||||
t.Fatalf("refresh=1 should query again, got %d queries", vl.queries)
|
||||
}
|
||||
}
|
||||
+87
@@ -126,6 +126,16 @@ const I18N = {
|
||||
fontHelp: 'Free fonts. The built-in ones (Inconsolata Condensed, the narrowest, Iosevka and Ubuntu Mono) are served by LogStream itself and work offline; they are narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
|
||||
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
||||
dangerZone: 'Danger zone',
|
||||
dbTitle: 'Database', dbRefresh: 'Refresh', dbLoading: 'Loading…',
|
||||
dbUnits: ['B', 'KB', 'MB', 'GB', 'TB'],
|
||||
dbRows: 'Stored lines', dbDisk: 'Size on disk', dbIndex: (x) => `including ${x} of index`,
|
||||
dbRaw: 'Raw size', dbRatio: (x) => `compressed ×${x}`,
|
||||
dbPeriod: 'Period', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} day${n > 1 ? 's' : ''}`,
|
||||
dbRetention: (r) => `retention ${r}`,
|
||||
dbRecent: 'Last 24 h', dbLastHour: (n) => `${n} in the last hour`,
|
||||
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} host${p.hn > 1 ? 's' : ''} · ${p.a} app${p.an > 1 ? 's' : ''}`,
|
||||
dbFree: 'Free disk space', dbEmpty: 'The database is empty.',
|
||||
dbQueryErr: 'Some figures could not be computed: ',
|
||||
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:',
|
||||
@@ -285,6 +295,16 @@ const I18N = {
|
||||
fontHelp: 'Polices libres. Les polices intégrées (Inconsolata Condensed, la plus étroite, Iosevka et Ubuntu Mono) sont servies par LogStream lui-même et fonctionnent hors ligne ; elles sont étroites, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
|
||||
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
||||
dangerZone: 'Zone de danger',
|
||||
dbTitle: 'Base de données', dbRefresh: 'Actualiser', dbLoading: 'Chargement…',
|
||||
dbUnits: ['o', 'Ko', 'Mo', 'Go', 'To'],
|
||||
dbRows: 'Lignes stockées', dbDisk: 'Taille sur disque', dbIndex: (x) => `dont ${x} d'index`,
|
||||
dbRaw: 'Taille brute', dbRatio: (x) => `compression ×${x}`,
|
||||
dbPeriod: 'Période', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} jour${n > 1 ? 's' : ''}`,
|
||||
dbRetention: (r) => `rétention ${r}`,
|
||||
dbRecent: 'Dernières 24 h', dbLastHour: (n) => `${n} sur la dernière heure`,
|
||||
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} hôte${p.hn > 1 ? 's' : ''} · ${p.a} application${p.an > 1 ? 's' : ''}`,
|
||||
dbFree: 'Espace disque libre', dbEmpty: 'La base est vide.',
|
||||
dbQueryErr: 'Certains chiffres n\'ont pas pu être calculés : ',
|
||||
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 :',
|
||||
@@ -509,6 +529,7 @@ function setLang(next) {
|
||||
renderPresets();
|
||||
renderTimeSettings();
|
||||
renderPurge();
|
||||
renderDbStats();
|
||||
renderInterface();
|
||||
renderSyslog();
|
||||
renderHost();
|
||||
@@ -2010,6 +2031,69 @@ setInterval(() => {
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
/* ================= Database statistics ================= */
|
||||
|
||||
// 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes).
|
||||
function fmtBytes(n) {
|
||||
const units = t('dbUnits');
|
||||
let i = 0;
|
||||
n = Number(n || 0);
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
const digits = i === 0 || n >= 100 ? 0 : 1;
|
||||
return `${n.toLocaleString(t('locale'), { maximumFractionDigits: digits })} ${units[i]}`;
|
||||
}
|
||||
|
||||
const db = { stats: null, error: null, loading: false };
|
||||
|
||||
function renderDbStats() {
|
||||
const el = $('#dbStats');
|
||||
const s = db.stats;
|
||||
if (!s) {
|
||||
el.innerHTML = `<div class="db-msg${db.error ? ' bad' : ''}">${esc(db.error || t('dbLoading'))}</div>`;
|
||||
return;
|
||||
}
|
||||
const row = (k, v, sub) => `<dt>${esc(k)}</dt><dd><b>${esc(v)}</b>${sub ? ` <span class="muted">${esc(sub)}</span>` : ''}</dd>`;
|
||||
const day = (x) => fmtFull(x).replace(/[.,]\d{3}(?=\D*$)/, '');
|
||||
let html = row(t('dbRows'), fmtNum(s.rows));
|
||||
html += row(t('dbDisk'), fmtBytes(s.diskBytes), s.indexBytes ? t('dbIndex', fmtBytes(s.indexBytes)) : '');
|
||||
if (s.rawBytes) {
|
||||
const ratio = s.diskBytes ? (s.rawBytes / s.diskBytes).toLocaleString(t('locale'), { maximumFractionDigits: 1 }) : '';
|
||||
html += row(t('dbRaw'), fmtBytes(s.rawBytes), ratio ? t('dbRatio', ratio) : '');
|
||||
}
|
||||
if (s.oldest && s.newest) {
|
||||
const sub = [s.days ? t('dbDays', s.days) : '', s.retention ? t('dbRetention', s.retention) : ''].filter(Boolean).join(' · ');
|
||||
html += row(t('dbPeriod'), t('dbFromTo', { from: day(s.oldest), to: day(s.newest) }), sub);
|
||||
} else if (s.retention) {
|
||||
html += row(t('dbPeriod'), '—', t('dbRetention', s.retention));
|
||||
}
|
||||
if (s.rows) {
|
||||
html += row(t('dbRecent'), fmtNum(s.last24h), t('dbLastHour', fmtNum(s.last1h)));
|
||||
html += row(t('dbSources'), t('dbHostsApps', { h: fmtNum(s.hosts), hn: s.hosts, a: fmtNum(s.apps), an: s.apps }));
|
||||
}
|
||||
if (s.freeBytes) html += row(t('dbFree'), fmtBytes(s.freeBytes));
|
||||
if (!s.rows) html += `<div class="db-msg">${esc(t('dbEmpty'))}</div>`;
|
||||
if (s.queryError) html += `<div class="db-msg bad">${esc(t('dbQueryErr') + s.queryError)}</div>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadDbStats(refresh = false) {
|
||||
if (db.loading) return;
|
||||
db.loading = true;
|
||||
$('#dbRefresh').disabled = true;
|
||||
try {
|
||||
db.stats = await api('/api/dbstats' + (refresh ? '?refresh=1' : ''));
|
||||
db.error = null;
|
||||
} catch (e) {
|
||||
db.stats = null;
|
||||
db.error = e.message;
|
||||
} finally {
|
||||
db.loading = false;
|
||||
$('#dbRefresh').disabled = false;
|
||||
}
|
||||
renderDbStats();
|
||||
}
|
||||
$('#dbRefresh').addEventListener('click', () => loadDbStats(true));
|
||||
|
||||
/* ================= Purge ================= */
|
||||
|
||||
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
|
||||
@@ -2039,6 +2123,7 @@ async function loadPurgeStatus() {
|
||||
refresh();
|
||||
loadFacets();
|
||||
loadStats();
|
||||
loadDbStats(true);
|
||||
}
|
||||
purge.running = s.running || 0;
|
||||
} catch { /* shown on the next attempt */ }
|
||||
@@ -2237,6 +2322,8 @@ $('#settingsBtn').addEventListener('click', () => {
|
||||
loadSyslog();
|
||||
loadHost();
|
||||
loadDocker();
|
||||
renderDbStats();
|
||||
loadDbStats();
|
||||
showSettingsTab(store.get('settingsTab', 'locale'));
|
||||
$('#settingsDlg').showModal();
|
||||
});
|
||||
|
||||
@@ -339,6 +339,11 @@
|
||||
<div class="set-panel wide" role="tabpanel" data-panel="data" hidden>
|
||||
<section class="set-section danger">
|
||||
<h3 data-i18n="dangerZone">Danger zone</h3>
|
||||
<div class="db-head">
|
||||
<span class="lbl" data-i18n="dbTitle">Database</span>
|
||||
<button id="dbRefresh" class="link-btn" type="button" data-i18n="dbRefresh">Refresh</button>
|
||||
</div>
|
||||
<dl id="dbStats" class="db-stats"></dl>
|
||||
<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>
|
||||
|
||||
@@ -559,6 +559,17 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
|
||||
background: var(--panel-2); font-family: var(--mono); font-size: 12.5px;
|
||||
}
|
||||
.set-section.danger h3 { color: var(--sev-err); }
|
||||
.db-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin: 4px 0 6px; }
|
||||
.db-stats {
|
||||
display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 14px;
|
||||
margin: 0 0 14px; padding: 10px 12px; border-radius: 8px; background: var(--panel-2); font-size: 12.5px;
|
||||
}
|
||||
.db-stats dt { color: var(--muted); }
|
||||
.db-stats dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.db-stats dd b { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.db-msg { grid-column: 1 / -1; color: var(--muted); }
|
||||
.db-msg.bad { color: var(--sev-err); }
|
||||
.link-btn:disabled { opacity: .5; cursor: default; text-decoration: none; }
|
||||
.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; }
|
||||
|
||||
Reference in new issue
Block a user