6229bf9b74
Splits the monolithic settings page into 6 focused glass components matching
the polish of the recently redesigned settings/backup page.
- SettingsHero: PageHeader with 4 live status pills (URL host, timezone +
ticking clock, locale codes, log severity tinted by level)
- IdentityCassette: groups External URL + Timezone + Locales as numbered
rows; URL field gains copy + open chips and a mint border when valid
- TelegramCassette: webhook secret with show/hide toggle and verified
status chip; cache TTL/max as oversized mono numerals with humanized
previews ("720 hrs -> 30d")
- CacheLedger: mirrors BackupLedger -- big total, gradient capacity meter,
tone-edged URL/Asset bucket rows colored by oldest entry age
- LoggingCassette: per-module overrides become tone-edged chips with
severity-colored level borders; raw-text fallback behind toggle; live
ACTIVE preview line
- SaveBar: sticky dirty-aware footer with citrus pulse, italic message,
and Discard/Save (only renders when settings differ from baseline)
No backend changes -- same /settings and /settings/telegram-cache/* endpoints.
449 lines
12 KiB
Svelte
449 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { t } from '$lib/i18n';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import Hint from '$lib/components/Hint.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
|
|
|
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
|
|
|
|
interface Override {
|
|
module: string;
|
|
level: Level;
|
|
}
|
|
|
|
interface Props {
|
|
logLevel: string;
|
|
logFormat: string;
|
|
logLevels: string;
|
|
}
|
|
|
|
let {
|
|
logLevel = $bindable(),
|
|
logFormat = $bindable(),
|
|
logLevels = $bindable(),
|
|
}: Props = $props();
|
|
|
|
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
|
|
const LEVEL_TONE: Record<Level, string> = {
|
|
DEBUG: 'sky',
|
|
INFO: 'mint',
|
|
WARNING: 'citrus',
|
|
ERROR: 'coral',
|
|
};
|
|
|
|
let rawMode = $state(false);
|
|
|
|
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
|
|
function parse(csv: string): Override[] {
|
|
if (!csv) return [];
|
|
const out: Override[] = [];
|
|
const seen = new Set<string>();
|
|
for (const raw of csv.split(',')) {
|
|
const piece = raw.trim();
|
|
if (!piece) continue;
|
|
const eq = piece.indexOf('=');
|
|
if (eq < 0) continue;
|
|
const module = piece.slice(0, eq).trim();
|
|
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
|
|
if (!module || seen.has(module)) continue;
|
|
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
|
|
seen.add(module);
|
|
out.push({ module, level });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serialize(rows: Override[]): string {
|
|
return rows
|
|
.filter(r => r.module.trim().length > 0)
|
|
.map(r => `${r.module.trim()}=${r.level}`)
|
|
.join(',');
|
|
}
|
|
|
|
let rows = $state<Override[]>(parse(logLevels));
|
|
let lastEmitted = $state(logLevels);
|
|
|
|
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
|
|
$effect(() => {
|
|
if (logLevels !== lastEmitted) {
|
|
rows = parse(logLevels);
|
|
lastEmitted = logLevels;
|
|
}
|
|
});
|
|
|
|
function commit(next: Override[]): void {
|
|
rows = next;
|
|
const serialized = serialize(next);
|
|
lastEmitted = serialized;
|
|
logLevels = serialized;
|
|
}
|
|
|
|
function addRow(): void {
|
|
commit([...rows, { module: '', level: 'INFO' }]);
|
|
}
|
|
|
|
function removeRow(i: number): void {
|
|
commit(rows.filter((_, idx) => idx !== i));
|
|
}
|
|
|
|
function updateModule(i: number, value: string): void {
|
|
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
|
|
commit(next);
|
|
}
|
|
|
|
function updateLevel(i: number, level: Level): void {
|
|
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
|
|
commit(next);
|
|
}
|
|
|
|
const previewLine = $derived.by(() => {
|
|
const root = (logLevel || 'INFO').toUpperCase();
|
|
if (rows.length === 0) return `root=${root}`;
|
|
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
|
|
});
|
|
</script>
|
|
|
|
<section class="logging glass">
|
|
<header class="log-head">
|
|
<div class="log-eyebrow">
|
|
<MdiIcon name="mdiTextBoxOutline" size={12} />
|
|
<span>{t('settings.logging')}</span>
|
|
</div>
|
|
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
|
|
</header>
|
|
|
|
<!-- Level + format -->
|
|
<div class="log-row">
|
|
<div class="log-cell">
|
|
<span class="log-label">
|
|
{t('settings.logLevel')}
|
|
<Hint text={t('settings.logLevelHint')} />
|
|
</span>
|
|
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
|
|
</div>
|
|
<div class="log-cell">
|
|
<span class="log-label">
|
|
{t('settings.logFormat')}
|
|
<Hint text={t('settings.logFormatHint')} />
|
|
</span>
|
|
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Per-module overrides -->
|
|
<div class="overrides">
|
|
<div class="overrides-head">
|
|
<span class="log-label">
|
|
{t('settings.logLevels')}
|
|
<Hint text={t('settings.logLevelsHint')} />
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="mode-toggle"
|
|
onclick={() => (rawMode = !rawMode)}
|
|
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
|
|
>
|
|
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
|
|
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if rawMode}
|
|
<input
|
|
bind:value={logLevels}
|
|
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
|
class="raw-input"
|
|
/>
|
|
{:else}
|
|
<div class="chip-stack">
|
|
{#each rows as row, i (i)}
|
|
{@const tone = LEVEL_TONE[row.level]}
|
|
<div class="chip" data-tone={tone}>
|
|
<span class="chip-edge" aria-hidden="true"></span>
|
|
<input
|
|
value={row.module}
|
|
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
|
|
placeholder={t('settings.logModulePlaceholder')}
|
|
class="chip-input"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
<span class="chip-sep" aria-hidden="true">=</span>
|
|
<select
|
|
value={row.level}
|
|
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
|
|
class="chip-level"
|
|
aria-label={t('settings.logLevel')}
|
|
>
|
|
{#each LEVELS as lvl}
|
|
<option value={lvl}>{lvl}</option>
|
|
{/each}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="chip-remove"
|
|
onclick={() => removeRow(i)}
|
|
aria-label={t('settings.removeOverride')}
|
|
title={t('settings.removeOverride')}
|
|
>
|
|
<MdiIcon name="mdiClose" size={13} />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
|
|
<button type="button" class="chip-add" onclick={addRow}>
|
|
<MdiIcon name="mdiPlus" size={13} />
|
|
<span>{t('settings.addOverride')}</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Live preview -->
|
|
<div class="preview" role="status">
|
|
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
|
|
<code class="preview-text">{previewLine}</code>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.logging {
|
|
padding: 1.5rem 1.6rem 1.4rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.15rem;
|
|
}
|
|
.log-head {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.log-eyebrow {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.62rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.18em;
|
|
color: var(--color-muted-foreground);
|
|
margin-bottom: 0.45rem;
|
|
}
|
|
.log-title {
|
|
margin: 0;
|
|
font-family: var(--font-display);
|
|
font-style: italic;
|
|
font-weight: 400;
|
|
font-size: 1.15rem;
|
|
line-height: 1.35;
|
|
letter-spacing: -0.015em;
|
|
color: var(--color-foreground);
|
|
max-width: 38ch;
|
|
}
|
|
|
|
.log-row {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 0.85rem;
|
|
}
|
|
@media (min-width: 720px) {
|
|
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
|
|
}
|
|
.log-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
.log-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: var(--color-foreground);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
/* --- Overrides editor --- */
|
|
.overrides {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
.overrides-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.mode-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
padding: 0.25rem 0.55rem;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-muted-foreground);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.6rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.mode-toggle:hover {
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
border-color: var(--color-rule-strong);
|
|
}
|
|
|
|
.raw-input {
|
|
width: 100%;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
padding: 0.6rem 0.85rem;
|
|
}
|
|
|
|
.chip-stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
.chip {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto auto;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-glass-strong);
|
|
overflow: hidden;
|
|
transition: border-color 0.18s, background 0.18s;
|
|
}
|
|
.chip:hover {
|
|
border-color: var(--color-rule-strong);
|
|
background: var(--color-glass-elev);
|
|
}
|
|
.chip-edge {
|
|
position: absolute;
|
|
left: 0; top: 0; bottom: 0;
|
|
width: 3px;
|
|
opacity: 0.85;
|
|
}
|
|
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
|
|
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
|
|
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
|
|
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
|
|
|
|
.chip-input {
|
|
width: 100%;
|
|
background: transparent;
|
|
border: 0;
|
|
outline: 0;
|
|
padding: 0.35rem 0;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
color: var(--color-foreground);
|
|
min-width: 0;
|
|
}
|
|
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
|
|
|
|
.chip-sep {
|
|
font-family: var(--font-mono);
|
|
color: var(--color-muted-foreground);
|
|
opacity: 0.5;
|
|
padding: 0 0.15rem;
|
|
}
|
|
|
|
.chip-level {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-glass);
|
|
color: var(--color-foreground);
|
|
min-width: 7.2rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
|
|
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
|
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
|
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
|
|
|
|
.chip-remove {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 26px; height: 26px;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
border: 0;
|
|
color: var(--color-muted-foreground);
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.chip-remove:hover {
|
|
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
|
|
color: var(--color-error-fg);
|
|
}
|
|
|
|
.chip-add {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
align-self: flex-start;
|
|
padding: 0.35rem 0.85rem;
|
|
border-radius: 999px;
|
|
border: 1px dashed var(--color-rule-strong);
|
|
background: transparent;
|
|
color: var(--color-muted-foreground);
|
|
font-family: inherit;
|
|
font-size: 0.72rem;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.chip-add:hover {
|
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
|
color: var(--color-primary);
|
|
border-style: solid;
|
|
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
|
|
}
|
|
|
|
/* --- Live preview --- */
|
|
.preview {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
padding: 0.65rem 0.85rem;
|
|
border-radius: 12px;
|
|
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
|
|
border: 1px solid var(--color-border);
|
|
overflow: hidden;
|
|
}
|
|
.preview-eyebrow {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.55rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.18em;
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
.preview-text {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
color: var(--color-foreground);
|
|
word-break: break-all;
|
|
line-height: 1.45;
|
|
}
|
|
</style>
|