Author SHA1 Message Date
cedricandClaude Opus 5.5 c664f1eaaf Two-digit code per color tag, shown as badges on matching log lines
Each tag gets a stable code (01-99) assigned by the server and stored in
tags.json; existing tags get one when the file is loaded. A fixed-width
Filters column shows the codes of the active tags found in each message
(room for 3, then 2 + "+N", all listed in the tooltip).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:35:20 +02:00
10 changed files with 197 additions and 199 deletions

No files matched your search

+15 -13
View File
@@ -78,8 +78,8 @@ par exemple `error AND host:web-01`, `app:~"ssh|nginx"` ou `* | stats by (host)
Le direct est désactivé dans ce mode. Le direct est désactivé dans ce mode.
Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur), Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur),
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application,
et le message. Un clic sur un hôte ou une application filtre dessus. les codes des tags trouvés et le message. Un clic sur un hôte ou une application filtre dessus.
Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
@@ -235,20 +235,22 @@ couleur, est mémorisé par navigateur.
ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression
régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance : régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance :
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour. les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
un aussi). La colonne *Filtres* de la liste affiche, en badges gris, les codes des tags actifs
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
l'infobulle les liste tous.
- **Interface** - **Interface**
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le - *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
bouton soleil/lune de l'en-tête bascule entre clair et sombre. bouton soleil/lune de l'en-tête bascule entre clair et sombre.
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité - *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande) et police :
(normale, ou compacte pour afficher environ 50 % de lignes en plus à l'écran) et police : la police monospace du système, ou l'une des 12 polices libres conçues pour le texte dense
la police monospace du système, [Iosevka](https://github.com/be5invis/Iosevka) (étroite, (JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
donc plus de texte par ligne ; servie par LogStream lui-même, fonctionne hors ligne), ou Ubuntu Mono, Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Elles sont
l'une des 12 polices libres conçues pour le texte dense (JetBrains Mono, Fira Code, Source chargées par le navigateur depuis [Bunny Fonts](https://fonts.bunny.net), un service
Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat Mono, européen de polices respectueux de la vie privée ; sans accès à internet, la police du
Noto Sans Mono, Victor Mono, DM Mono). Ces 12 polices sont chargées par le navigateur depuis système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
[Bunny Fonts](https://fonts.bunny.net), un service européen de polices respectueux de la vie quels.
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 + Iosevka.
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut - **Données** : « 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 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`) ; mettez être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez
+12 -10
View File
@@ -72,7 +72,7 @@ message, host and app. Words are combined with AND.
The live view is disabled in this mode. The live view is disabled in this mode.
Each row shows, from left to right: the **reception time** (server clock), the timestamp Each row shows, from left to right: the **reception time** (server clock), the timestamp
found in the message itself (`msg_time`), severity, host, app and message. Click a host or an found in the message itself (`msg_time`), severity, host, app, codes of the tags found and message. Click a host or an
app to filter on it. app to filter on it.
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
@@ -214,18 +214,20 @@ remembered per browser.
added tags can be edited like any other. In a regular expression, a group named `hl` added tags can be edited like any other. In a regular expression, a group named `hl`
(`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code (`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code
or the method, not the text around it. or the method, not the text around it.
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
until the tag is deleted (codes are also given to tags created by earlier versions). The
*Filters* column of the log list shows, as grey badges, the codes of the active tags found in
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
all.
- **Interface** - **Interface**
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon - *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
button in the header switches between light and dark. button in the header switches between light and dark.
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to - *Log display*: font size (tiny, small, medium, large) and font: the system monospace
fit about 50% more lines on screen) and font: the system monospace font, font, or one of 12 free fonts made for dense text (JetBrains Mono, Fira Code, Source
[Iosevka](https://github.com/be5invis/Iosevka) (narrow, so more text fits on each line; Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat
served by LogStream itself, works offline), or one of 12 free fonts made for dense text Mono, Noto Sans Mono, Victor Mono, DM Mono). They are loaded by the browser from
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, [Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
Ubuntu Mono, Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). These 12 are internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as typed.
loaded by the browser from [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 + Iosevka.
- **Data**: "Delete all logs" permanently erases every stored log (you must type - **Data**: "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable` `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. (already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature.
+45 -4
View File
@@ -16,6 +16,7 @@ import (
// Tag highlights a keyword in displayed messages. // Tag highlights a keyword in displayed messages.
type Tag struct { type Tag struct {
ID string `json:"id"` ID string `json:"id"`
Code string `json:"code"` // two digits, shown on matching log lines
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
Label string `json:"label,omitempty"` // shown instead of the pattern (presets) Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
Color string `json:"color"` Color string `json:"color"`
@@ -27,12 +28,47 @@ type Tag struct {
func defaultTags() []Tag { func defaultTags() []Tag {
return []Tag{ return []Tag{
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true}, {ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true}, {ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true}, {ID: "ok", Code: "03", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
} }
} }
var codeRe = regexp.MustCompile(`^[0-9]{2}$`)
// freeCode returns the lowest code from 01 to 99 not used by tags, or "" when
// all are taken. A code stays with its tag until the tag is deleted.
func freeCode(tags []Tag) string {
used := map[string]bool{}
for _, t := range tags {
used[t.Code] = true
}
for n := 1; n <= 99; n++ {
if c := fmt.Sprintf("%02d", n); !used[c] {
return c
}
}
return ""
}
// assignCodes gives a code to the tags that have none (files written before
// codes existed) or share one with an earlier tag.
func assignCodes(tags []Tag) bool {
changed := false
seen := map[string]bool{}
for i := range tags {
if codeRe.MatchString(tags[i].Code) && !seen[tags[i].Code] {
seen[tags[i].Code] = true
continue
}
tags[i].Code = ""
tags[i].Code = freeCode(tags)
seen[tags[i].Code] = true
changed = true
}
return changed
}
// Colors of the default tags in earlier versions: still unchanged, they are // Colors of the default tags in earlier versions: still unchanged, they are
// switched to the new pastel defaults when the file is loaded. // switched to the new pastel defaults when the file is loaded.
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"} var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
@@ -68,6 +104,7 @@ func (e *codedError) Error() string {
var ( var (
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"} errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"}
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
) )
@@ -108,7 +145,7 @@ func LoadTagStore(path string) (*TagStore, error) {
if err := json.Unmarshal(b, &s.tags); err != nil { if err := json.Unmarshal(b, &s.tags); err != nil {
return nil, fmt.Errorf("%s: %w", path, err) return nil, fmt.Errorf("%s: %w", path, err)
} }
if migrateDefaultColors(s.tags) { if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 {
if err := s.save(); err != nil { if err := s.save(); err != nil {
return nil, err return nil, err
} }
@@ -145,6 +182,9 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
t.ID = newID() t.ID = newID()
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if t.Code = freeCode(s.tags); t.Code == "" {
return t, errTooManyTags
}
s.tags = append(s.tags, t) s.tags = append(s.tags, t)
return t, s.save() return t, s.save()
} }
@@ -158,6 +198,7 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
defer s.mu.Unlock() defer s.mu.Unlock()
for i := range s.tags { for i := range s.tags {
if s.tags[i].ID == id { if s.tags[i].ID == id {
t.Code = s.tags[i].Code // assigned by the server, never changed
s.tags[i] = t s.tags[i] = t
return t, s.save() return t, s.save()
} }
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestTagCodes(t *testing.T) {
path := filepath.Join(t.TempDir(), "tags.json")
// File written before codes existed, with a duplicate code.
old := `[{"id":"a","pattern":"x","color":"#000000"},{"id":"b","code":"07","pattern":"y","color":"#000000"},{"id":"c","code":"07","pattern":"z","color":"#000000"}]`
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
got := []string{}
for _, tg := range s.List() {
got = append(got, tg.Code)
}
if want := []string{"01", "07", "02"}; !slices.Equal(got, want) {
t.Fatalf("migrated codes = %v, want %v", got, want)
}
n, err := s.Create(Tag{Pattern: "w", Color: "#000000"})
if err != nil || n.Code != "03" {
t.Fatalf("Create code = %q, %v; want 03", n.Code, err)
}
// The client cannot change a code.
u, err := s.Update("b", Tag{Code: "42", Pattern: "y2", Color: "#000000"})
if err != nil || u.Code != "07" {
t.Fatalf("Update code = %q, %v; want 07", u.Code, err)
}
// A deleted tag frees its code; the others keep theirs.
if err := s.Delete("a"); err != nil {
t.Fatal(err)
}
if n, _ := s.Create(Tag{Pattern: "v", Color: "#000000"}); n.Code != "01" {
t.Fatalf("code after delete = %q, want 01", n.Code)
}
// Codes are saved in the file.
s2, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
if got := s2.List()[0].Code; got != "07" {
t.Fatalf("reloaded code = %q, want 07", got)
}
}
+46 -37
View File
@@ -59,6 +59,7 @@ const I18N = {
err_invalid_color: 'Invalid color (expected #rrggbb)', err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression', err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found', err_tag_not_found: 'Tag not found',
err_too_many_tags: 'Too many tags (99 at most)',
err_live_logsql: 'Live view is not available in LogsQL mode', err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time', dateTime: 'Date & time',
tzLabel: 'Time zone', tzLabel: 'Time zone',
@@ -119,9 +120,8 @@ const I18N = {
themeHelp: 'System follows the light/dark preference of your computer or phone.', themeHelp: 'System follows the light/dark preference of your computer or phone.',
logDisplay: 'Log display', fontSize: 'Font size', logDisplay: 'Log display', fontSize: 'Font size',
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large', sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact', fontLabel: 'Font', fontSystem: 'System monospace (no download)',
fontLabel: 'Font', fontSystem: 'System monospace (no download)', fontBuiltin: 'built in, narrow', fontHelp: 'Free fonts (SIL Open Font License) loaded by your browser from Bunny Fonts, a privacy-friendly European font service. Without internet access, the system font is used.',
fontHelp: 'Free fonts (SIL Open Font License). Iosevka is served by LogStream itself and works offline; it is 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)'], 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', dangerZone: 'Danger zone',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.', purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
@@ -149,7 +149,9 @@ const I18N = {
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.', hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd', hUnzoom: '× Reset zoom', unitDay: 'd',
toTop: 'Back to top', toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message', colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
codesTitle: 'Codes of the color tags found in the message',
tagCodeTitle: 'Code shown on the log lines this tag matches',
colGrip: 'Drag to resize, double-click for the automatic width', colGrip: 'Drag to resize, double-click for the automatic width',
resetCols: 'Reset column widths', colsReset: 'Column widths reset', resetCols: 'Reset column widths', colsReset: 'Column widths reset',
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).', colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
@@ -210,6 +212,7 @@ const I18N = {
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)', err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide', err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable', err_tag_not_found: 'Tag introuvable',
err_too_many_tags: 'Trop de tags (99 au maximum)',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL', err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure', dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire', tzLabel: 'Fuseau horaire',
@@ -270,9 +273,8 @@ const I18N = {
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.', themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
logDisplay: 'Affichage des logs', fontSize: 'Taille du texte', logDisplay: 'Affichage des logs', fontSize: 'Taille du texte',
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande', sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte', fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', fontBuiltin: 'intégrée, étroite', fontHelp: 'Polices libres (licence SIL Open Font) 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.',
fontHelp: 'Polices libres (licence SIL Open Font). Iosevka est servie par LogStream lui-même et fonctionne hors ligne ; elle est étroite, 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)'], 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', 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.', 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.',
@@ -300,7 +302,9 @@ const I18N = {
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.', hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j', hUnzoom: '× Annuler le zoom', unitDay: 'j',
toTop: 'Revenir en haut', toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message', colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
codesTitle: 'Codes des tags de couleur trouvés dans le message',
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique', colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées', resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).', colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
@@ -533,11 +537,9 @@ $('#themeSwitch').addEventListener('click', (ev) => {
/* ================= Log font and size ================= */ /* ================= Log font and size ================= */
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net), except the // Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net).
// built-in ones (web/fonts, declared in style.css), which also work offline.
const LOG_FONTS = [ const LOG_FONTS = [
{ id: 'system' }, { id: 'system' },
{ id: 'iosevka', family: 'Iosevka', builtin: true },
{ id: 'jetbrains-mono', family: 'JetBrains Mono' }, { id: 'jetbrains-mono', family: 'JetBrains Mono' },
{ id: 'fira-code', family: 'Fira Code' }, { id: 'fira-code', family: 'Fira Code' },
{ id: 'source-code-pro', family: 'Source Code Pro' }, { id: 'source-code-pro', family: 'Source Code Pro' },
@@ -552,8 +554,7 @@ const LOG_FONTS = [
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' }, { id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
]; ];
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' }; const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
const LOG_DENSITIES = ['normal', 'compact']; const ui = { font: 'system', size: 'medium' };
const ui = { font: 'system', size: 'medium', density: 'normal' };
function applyLogFont(id) { function applyLogFont(id) {
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0]; const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
@@ -563,10 +564,6 @@ function applyLogFont(id) {
root.removeProperty('--log-font'); root.removeProperty('--log-font');
return; return;
} }
if (f.builtin) {
root.setProperty('--log-font', `'${f.family}', var(--mono)`);
return;
}
const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`; const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`;
let link = document.getElementById('logFontCss'); let link = document.getElementById('logFontCss');
if (!link) { if (!link) {
@@ -582,22 +579,13 @@ function applyLogSize(id) {
document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]); document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]);
} }
function applyLogDensity(id) {
ui.density = LOG_DENSITIES.includes(id) ? id : 'normal';
document.documentElement.dataset.density = ui.density;
}
function renderInterface() { function renderInterface() {
renderThemeSwitch(); renderThemeSwitch();
for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) { for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) {
b.setAttribute('aria-checked', String(b.dataset.size === ui.size)); b.setAttribute('aria-checked', String(b.dataset.size === ui.size));
} }
for (const b of document.querySelectorAll('#densitySwitch [data-density]')) {
b.setAttribute('aria-checked', String(b.dataset.density === ui.density));
}
const sel = $('#fontSelect'); const sel = $('#fontSelect');
const fontName = (f) => (!f.family ? t('fontSystem') : f.builtin ? `${f.family} (${t('fontBuiltin')})` : f.family); sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).join('');
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(fontName(f))}</option>`).join('');
sel.value = ui.font; sel.value = ui.font;
renderFontPreview(); renderFontPreview();
renderHistoSettings(); renderHistoSettings();
@@ -621,13 +609,6 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
store.set('logSize', ui.size); store.set('logSize', ui.size);
renderInterface(); renderInterface();
}); });
$('#densitySwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-density]');
if (!b) return;
applyLogDensity(b.dataset.density);
store.set('logDensity', ui.density);
renderInterface();
});
$('#fontSelect').addEventListener('change', (ev) => { $('#fontSelect').addEventListener('change', (ev) => {
applyLogFont(ev.target.value); applyLogFont(ev.target.value);
store.set('logFont', ui.font); store.set('logFont', ui.font);
@@ -721,7 +702,8 @@ function compileMatchers() {
// A regex may name a group "hl" to color only that part of the match. // A regex may name a group "hl" to color only that part of the match.
const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern); const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern);
if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>'); if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>');
out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` }); out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">`,
code: tag.code, name: tag.label || tag.pattern });
} catch (e) { } catch (e) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message); console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
} }
@@ -763,6 +745,30 @@ function highlight(text) {
return out + esc(text.slice(pos)); return out + esc(text.slice(pos));
} }
// Badges with the codes of the tags found in a message, in list order. The
// column has room for 3: beyond that, 2 badges and "+N" (all in the tooltip).
function codesHTML(text) {
text = String(text ?? '');
const found = [];
for (const m of state.matchers) {
if (!m.code || !text) continue;
m.re.lastIndex = 0;
let x;
while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; }
const g = x.indices?.groups?.hl;
if (!x.indices?.groups || g) { found.push(m); break; }
}
}
if (!found.length) return '';
const shown = found.length > 3 ? found.slice(0, 2) : found;
const title = found.map((m) => `${m.code} ${m.name}`).join('\n');
return `<span title="${esc(title)}">`
+ shown.map((m) => `<b>${esc(m.code)}</b>`).join('')
+ (found.length > 3 ? `<b class="more">+${found.length - 2}</b>` : '')
+ '</span>';
}
/* ================= List rendering ================= */ /* ================= List rendering ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' }; const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
@@ -788,6 +794,7 @@ function rowHTML(r, isNew) {
+ (r.app + (r.app
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>` ? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
: '<span class="app"></span>') : '<span class="app"></span>')
+ `<span class="codes">${codesHTML(r._msg)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>` + `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>'; + '</article>';
} }
@@ -854,7 +861,9 @@ function renderList() {
function rehighlight() { function rehighlight() {
for (const row of list.children) { for (const row of list.children) {
const r = recOf.get(row); const r = recOf.get(row);
if (r) row.querySelector('.msg').innerHTML = highlight(r._msg); if (!r) continue;
row.querySelector('.msg').innerHTML = highlight(r._msg);
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
} }
} }
@@ -2051,6 +2060,7 @@ function tagRowHTML(tag) {
const opt = (field, label, title) => const opt = (field, label, title) =>
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`; `<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}"> return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
<span class="tag-code" title="${esc(t('tagCodeTitle'))}">${esc(tag.code || '··')}</span>
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}"> <input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}"> <input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span> <span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span>
@@ -2180,7 +2190,6 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
} }
applyLogFont(store.get('logFont', 'system')); applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium')); applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal'));
applyLang(); applyLang();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
-110
View File
@@ -1,110 +0,0 @@
Copyright (c) 2015-2023, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+1 -5
View File
@@ -107,6 +107,7 @@
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Filters</span></span>
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span> <span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
</div> </div>
<main id="list" class="list"></main> <main id="list" class="list"></main>
@@ -281,11 +282,6 @@
<button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button> <button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button>
<button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button> <button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button>
</div> </div>
<span class="lbl" data-i18n="density">Density</span>
<div id="densitySwitch" class="seg" role="radiogroup" data-i18n-aria="density">
<button type="button" role="radio" data-density="normal" data-i18n="densityNormal">Normal</button>
<button type="button" role="radio" data-density="compact" data-i18n="densityCompact">Compact</button>
</div>
<label for="fontSelect" data-i18n="fontLabel">Font</label> <label for="fontSelect" data-i18n="fontLabel">Font</label>
<select id="fontSelect" class="field"></select> <select id="fontSelect" class="field"></select>
</div> </div>
+24 -20
View File
@@ -1,8 +1,3 @@
/* Iosevka (SIL OFL, web/fonts/OFL-Iosevka.txt): a narrow monospace font served by LogStream
itself, so it also works offline. Latin subset only; other scripts fall back to --mono. */
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
/* ---------- Theme: everything goes through these variables ---------- */ /* ---------- Theme: everything goes through these variables ---------- */
:root { :root {
--bg: #f6f7f9; --bg: #f6f7f9;
@@ -42,7 +37,8 @@
--tag-warning: #fdba74; --tag-warning: #fdba74;
--tag-warning-text: #111827; --tag-warning-text: #111827;
--log-size: 12.5px; /* Settings > Interface > Font size */ --log-size: 12.5px;
--codes-w: 4.9rem; /* tag codes column: room for 3 badges */ /* Settings > Interface > Font size */
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */ /* --log-font is set by Settings > Interface > Font (defaults to --mono) */
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -301,13 +297,13 @@ body.busy .progress::after {
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.list:empty { display: none; } .list:empty { display: none; }
/* Columns: received, message time, severity, host, app, message. Widths come from /* Columns: received, message time, severity, host, app, tag codes, message. Widths come from
--col-* (set on #table when a column has been resized, see app.js), shared by --col-* (set on #table when a column has been resized, see app.js), shared by
the header and every row through subgrid so that the columns line up. */ the header and every row through subgrid so that the columns line up. */
.table { .table {
display: grid; display: grid;
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem) grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) minmax(0, 1fr); var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
column-gap: 12px; column-gap: 12px;
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */ overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
@@ -336,7 +332,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row { .row {
display: grid; display: grid;
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr; grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
gap: 0 12px; align-items: baseline; gap: 0 12px; align-items: baseline;
padding: 5px 14px 5px 11px; padding: 5px 14px 5px 11px;
border-left: 3px solid transparent; border-left: 3px solid transparent;
@@ -346,9 +342,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
cursor: pointer; cursor: pointer;
} }
.row:last-child { border-bottom: 0; } .row:last-child { border-bottom: 0; }
/* Settings > Interface > Density: compact fits about 50% more lines on screen */
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.25; }
:root[data-density="compact"] .row .sev { padding-top: 0; padding-bottom: 0; line-height: 1.3; }
.row:hover { background: var(--row-hover); } .row:hover { background: var(--row-hover); }
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.row.new { animation: flash 1.2s ease-out; } .row.new { animation: flash 1.2s ease-out; }
@@ -380,6 +373,15 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); } .row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); } .row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
.row .host[data-act], .row .app[data-act] { cursor: pointer; } .row .host[data-act], .row .app[data-act] { cursor: pointer; }
/* Codes of the tags found in the message: grey badges, fixed size */
.row .codes { white-space: nowrap; overflow: hidden; }
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
.row .codes b, .tag-code {
min-width: 2.2ch; padding: 1px 4px; border-radius: 4px; text-align: center;
font-family: var(--mono); font-size: 10.5px; font-weight: 700; line-height: 1.35; font-variant-numeric: tabular-nums;
background: #b4bac3; color: #111827;
}
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px #b4bac3; color: var(--muted); }
.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
mark.tag { mark.tag {
@@ -483,6 +485,7 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
.preview-list .row .host { grid-area: host; } .preview-list .row .host { grid-area: host; }
.preview-list .row .app { grid-area: app; display: block; } .preview-list .row .app { grid-area: app; display: block; }
.preview-list .row .msg { grid-area: msg; } .preview-list .row .msg { grid-area: msg; }
.preview-list .row .codes { display: none; }
dialog { dialog {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
@@ -600,11 +603,12 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } .tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.tag-row { .tag-row {
display: grid; align-items: center; gap: 8px 10px; display: grid; align-items: center; gap: 8px 10px;
grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px; grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px;
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel-2); background: var(--panel-2);
} }
.tag-row.off { opacity: .55; } .tag-row.off { opacity: .55; }
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
.tag-row input[type="color"] { .tag-row input[type="color"] {
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px; width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
background: none; cursor: pointer; background: none; cursor: pointer;
@@ -647,8 +651,8 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.table .list { display: block; margin: 0; } .table .list { display: block; margin: 0; }
.list-head { display: none; } .list-head { display: none; }
.row, .table .row { .row, .table .row {
grid-template-columns: auto auto 1fr; grid-template-columns: auto auto 1fr auto;
grid-template-areas: "rcv sev host" "msg msg msg"; grid-template-areas: "rcv sev host codes" "msg msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px; gap: 3px 8px; padding: 8px 12px 8px 10px;
} }
.row time.rcv { grid-area: rcv; } .row time.rcv { grid-area: rcv; }
@@ -656,6 +660,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.row .sev { grid-area: sev; } .row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; } .row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; } .row .app { display: none; }
.row .codes { grid-area: codes; }
.row .msg { grid-area: msg; } .row .msg { grid-area: msg; }
.details { grid-column: 1 / -1; } .details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; } .details dl { grid-template-columns: 1fr; }
@@ -673,15 +678,14 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
} }
.set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } .set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.set-panels { padding: 0 16px 18px; } .set-panels { padding: 0 16px 18px; }
#sizeSwitch, #densitySwitch { display: flex; } #sizeSwitch { display: flex; }
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; } #sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
.field-grid select { margin-bottom: 6px; } .field-grid select { margin-bottom: 6px; }
.status { padding: 6px 16px; } .status { padding: 6px 16px; }
.tag-row { grid-template-columns: 38px 1fr 36px; } .tag-row { grid-template-columns: auto 38px 1fr 36px; }
.tag-row .preview { display: none; } .tag-row .preview { display: none; }
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; } .tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
.tag-row [data-del] { grid-column: 3; grid-row: 1; } .tag-row [data-del] { grid-column: 4; grid-row: 1; }
} }
/* ---------- Login page (AUTH_MODE=local) ---------- */ /* ---------- Login page (AUTH_MODE=local) ---------- */