feat(observability): event-triggers + log-scan-rules UI + i18n

Operator-facing surfaces for the two backend features:

- /event-triggers — list (filter summary, status pill),
  /event-triggers/new (form with regex validation), and
  /event-triggers/[id] (edit + Send-test + delete) with
  CONFIGURED secret badge + clear-to-rotate flow, ConfirmDialog
  for delete, aria-live regions on async result slots.
- /log-scan-rules — list with scope filter chips and stats panel
  (active tails, RATE-LIMITED, COOLED DOWN, COMPILE ERRORS),
  /log-scan-rules/new (with EntityPicker for workload scope and
  inline RegexTestBox), /log-scan-rules/[id] (edit + server-side
  /test + delete + live RegexTestBox panel).
- web/src/lib/components/RegexTestBox.svelte — reusable
  client-side regex test with sample input + captures display.
- web/src/lib/api.ts — typed wrappers for EventTrigger and
  LogScanRule CRUD + /test + getLogScanStats +
  getEffectiveLogScanRules.
- web/src/routes/+layout.svelte — nav entries for both surfaces.
- web/src/lib/i18n/{en,ru}.json — ~90 keys under observability.*,
  triggers.*, logscan.* namespaces; Russian translations cover
  every key.

Design + a11y polish per a frontend-design review pass: all
boolean inputs use ToggleSwitch, all destructive actions use
ConfirmDialog with confirmVariant="danger" / onconfirm /
oncancel, hand-rolled .btn-primary replaced with global
forge-btn classes, hex colors replaced with var(--*) tokens,
role="alert" on error banners, aria-invalid + aria-describedby
on invalid-regex inputs, aria-busy on async forms, mobile
breakpoints (hide-md columns, .row.three collapsing 3→2→1,
.table-wrap overflow-x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:18:29 +03:00
parent 7a9ff7ad54
commit 4707db1c3b
11 changed files with 4455 additions and 1 deletions
+8 -1
View File
@@ -32,8 +32,11 @@
countKey?: NavCountKey;
/** When true the badge uses a danger style (red). */
alert?: boolean;
/** Static label override when the i18n catalogue does not yet carry the key. */
labelOverride?: string;
}> = [
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/apps', labelKey: 'nav.apps', icon: 'box' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
@@ -41,6 +44,8 @@
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Triggers' },
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
];
@@ -278,6 +283,8 @@
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'projects'}
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'box'}
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'globe'}
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'stacks'}
@@ -293,7 +300,7 @@
{:else if item.icon === 'settings'}
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{/if}
<span class="nav-label">{$t(item.labelKey)}</span>
<span class="nav-label">{item.labelOverride ?? $t(item.labelKey)}</span>
{#if item.countKey}
{@const count = $navCounts[item.countKey]}
+428
View File
@@ -0,0 +1,428 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { EventTrigger } from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let triggers = $state<EventTrigger[]>([]);
let loading = $state(true);
let error = $state('');
const enabledCount = $derived(triggers.filter((t) => t.enabled).length);
const disabledCount = $derived(triggers.length - enabledCount);
async function load(): Promise<void> {
loading = true;
error = '';
try {
triggers = await api.listEventTriggers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load event triggers';
} finally {
loading = false;
}
}
// Render a short, comma-separated filter summary so the list table can
// fit each trigger in a single row without a sub-table. The
// "any event" fallback is i18n'd; the field=value join keeps the
// (untranslated) field names so the format stays grep-friendly
// across locales.
function filterSummary(trig: EventTrigger): string {
const parts: string[] = [];
if (trig.filter_severity) parts.push(`severity=${trig.filter_severity}`);
if (trig.filter_source) parts.push(`source=${trig.filter_source}`);
if (trig.filter_message_regex) parts.push(`message~/${trig.filter_message_regex}/`);
return parts.length === 0 ? $t('observability.anyEvent') : parts.join(' · ');
}
onMount(load);
</script>
<svelte:head>
<title>{$t('triggers.title')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
{#snippet toolbar()}
<button
class="forge-btn-icon"
onclick={load}
aria-label={$t('observability.refresh')}
disabled={loading}
>
<IconRefresh size={16} />
</button>
<a href="/event-triggers/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('triggers.toolbar.newButton')}</span>
</a>
{/snippet}
{#snippet stats()}
<div>
<dt>{$t('triggers.stat.total')}</dt>
<dd>{loading ? '—' : String(triggers.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('triggers.stat.enabled')}</dt>
<dd>{loading ? '—' : String(enabledCount).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('triggers.stat.disabled')}</dt>
<dd class="accent">{loading ? '—' : String(disabledCount).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet lede()}
{$t('triggers.lede')}
{/snippet}
<ForgeHero
eyebrowSuffix={$t('observability.section').toUpperCase()}
title={$t('triggers.title')}
size="lg"
toolbar={toolbar}
lede_html={lede}
stats={stats}
/>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading}
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
{#each Array(3) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else if triggers.length === 0}
<div class="empty">
<div class="empty-mark" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<h2>{$t('triggers.empty.heading')}</h2>
<p>{$t('triggers.empty.body')}</p>
<a href="/event-triggers/new" class="forge-btn">
<IconPlus size={14} /><span>{$t('triggers.empty.cta')}</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('triggers.list.name')}</th>
<th class="hide-md">{$t('triggers.list.filters')}</th>
<th>{$t('triggers.list.action')}</th>
<th>{$t('triggers.list.status')}</th>
<th class="t-right">{$t('triggers.list.open')}</th>
</tr>
</thead>
<tbody>
{#each triggers as trig, i (trig.id)}
<tr>
<td>
<a class="row-link" href={`/event-triggers/${trig.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{trig.name}</span>
</a>
</td>
<td class="muted mono small hide-md filters-cell">
{filterSummary(trig)}
</td>
<td>
<div class="action-cell">
<span class="badge action">{trig.action_type}</span>
<span class="action-target muted mono small">{trig.action_target}</span>
</div>
</td>
<td>
<span class="status" class:on={trig.enabled} class:off={!trig.enabled}>
<span class="status-dot" aria-hidden="true"></span>
{trig.enabled ? $t('triggers.status.enabled') : $t('triggers.status.disabled')}
</span>
</td>
<td class="actions-cell">
<a class="row-action" href={`/event-triggers/${trig.id}`}>
{$t('observability.open')} <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1100px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 52px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex;
gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--forge-accent);
animation: ember 2.4s ease-in-out infinite;
}
@keyframes ember {
0%,
100% {
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
}
.empty h2 {
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.empty p {
color: var(--text-secondary);
margin: 0 auto 1.5rem;
font-size: 0.95rem;
max-width: 52ch;
line-height: 1.5;
}
/* ── Table ─────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: var(--surface-card);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-wrap :global(.forge-table) {
min-width: 640px;
}
.t-right {
text-align: right;
}
.actions-cell {
text-align: right;
}
.filters-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 820px) {
.hide-md {
display: none;
}
}
/* ── Row link / action ─────────────────────────── */
.row-link {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
color: var(--text-primary);
text-decoration: none;
transition: color 120ms ease;
}
.row-link:hover {
color: var(--forge-accent);
}
.row-link:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-ref {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.row-name {
font-weight: 600;
}
.row-action {
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--forge-accent);
text-decoration: none;
}
.row-action:hover {
color: var(--color-brand-500);
}
.row-action:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
/* ── Action badge + target ─────────────────────── */
.action-cell {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.badge.action {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
flex: 0 0 auto;
}
.action-target {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.small {
font-size: 0.72rem;
}
/* ── Status ────────────────────────────────────── */
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status.on {
color: var(--color-success-dark);
}
.status.on .status-dot {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
}
.status.off {
color: var(--text-tertiary);
}
.status.off .status-dot {
background: var(--text-tertiary);
opacity: 0.5;
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
}
</style>
@@ -0,0 +1,667 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as api from '$lib/api';
import type { EventTrigger, EventTriggerInput, NotificationTestResult } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { t } from '$lib/i18n';
// Parse + validate the URL id once. SvelteKit gives the raw string;
// a non-numeric path segment lands here as NaN, which would otherwise
// reach the API as the literal "NaN". Treat invalid ids as a hard
// failure so the rest of the page doesn't fire bogus requests.
const id = $derived.by(() => {
const n = Number($page.params.id);
return Number.isFinite(n) && n > 0 ? n : null;
});
let trigger = $state<EventTrigger | null>(null);
let loading = $state(true);
let saving = $state(false);
let testing = $state(false);
let confirmDelete = $state(false);
let deleting = $state(false);
let error = $state('');
let testResult = $state<NotificationTestResult | null>(null);
// Form fields — initialized from the loaded trigger.
let name = $state('');
let filterSeverity = $state('');
let filterSource = $state('');
let filterMessageRegex = $state('');
let actionTarget = $state('');
// Secret state: the backend returns a placeholder ("********") when
// a secret is configured so we never expose the real value on read.
// The PATCH path treats an unchanged placeholder as "no change."
// secretConfigured tracks whether a real secret is stored so the
// UI can show a "Configured" badge without revealing the value.
let actionSecret = $state('');
let secretConfigured = $state(false);
let enabled = $state(true);
const regexValid = $derived.by(() => {
if (!filterMessageRegex) return true;
try {
new RegExp(filterMessageRegex);
return true;
} catch {
return false;
}
});
async function load(): Promise<void> {
if (id === null) {
error = 'Invalid trigger id';
loading = false;
return;
}
loading = true;
error = '';
try {
const tr = await api.getEventTrigger(id);
trigger = tr;
name = tr.name;
filterSeverity = tr.filter_severity;
filterSource = tr.filter_source;
filterMessageRegex = tr.filter_message_regex;
actionTarget = tr.action_target;
// Server returns either "" (no secret) or a placeholder
// when one is configured. Keep the value in state so an
// unchanged echo round-trips as "no change."
actionSecret = tr.action_secret;
secretConfigured = tr.action_secret !== '';
enabled = tr.enabled;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load trigger';
} finally {
loading = false;
}
}
async function save(e?: Event): Promise<void> {
e?.preventDefault();
if (!trigger || id === null || saving) return;
saving = true;
error = '';
try {
const body: EventTriggerInput = {
name: name.trim(),
filter_severity: filterSeverity.trim(),
filter_source: filterSource.trim(),
filter_message_regex: filterMessageRegex,
action_type: 'webhook',
action_target: actionTarget.trim(),
action_secret: actionSecret,
enabled
};
trigger = await api.updateEventTrigger(id, body);
actionSecret = trigger.action_secret;
secretConfigured = trigger.action_secret !== '';
} catch (e) {
error = e instanceof Error ? e.message : 'Save failed';
} finally {
saving = false;
}
}
async function sendTest(): Promise<void> {
if (id === null) return;
testing = true;
testResult = null;
error = '';
try {
testResult = await api.testEventTrigger(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Test failed';
} finally {
testing = false;
}
}
async function doDelete(): Promise<void> {
if (id === null) return;
deleting = true;
error = '';
try {
await api.deleteEventTrigger(id);
goto('/event-triggers');
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
deleting = false;
confirmDelete = false;
}
}
// Reset the secret to a blank field — operator can then type a new
// one. The PATCH path treats blank as "clear stored secret."
function clearSecret(): void {
actionSecret = '';
}
const testOk = $derived(
testResult !== null && testResult.status_code >= 200 && testResult.status_code < 300
);
onMount(load);
</script>
<svelte:head>
<title>{trigger?.name ?? $t('triggers.titleSingular')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
<ForgeHero
backHref="/event-triggers"
backLabel={$t('triggers.toolbar.backToList')}
eyebrowSuffix={$t('triggers.titleSingular').toUpperCase()}
title={trigger?.name ?? $t('observability.loading')}
size="lg"
/>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading || !trigger}
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
{#each Array(3) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else}
<form class="panel" onsubmit={save} aria-busy={saving}>
<header class="panel-head">
<h2 class="panel-title">{$t('triggers.detail.config')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{$t('triggers.detail.configSub', { id: String(trigger.id), updatedAt: trigger.updated_at })}
</span>
</header>
<div class="field">
<label for="t-name" class="sub-label">{$t('triggers.form.name')}</label>
<input id="t-name" type="text" class="input" bind:value={name} required />
</div>
<div class="row two">
<div class="sub">
<label for="t-sev" class="sub-label">{$t('triggers.form.severityCsv')}</label>
<input
id="t-sev"
type="text"
class="input"
bind:value={filterSeverity}
placeholder={$t('triggers.form.severityPlaceholder')}
/>
</div>
<div class="sub">
<label for="t-src" class="sub-label">{$t('triggers.form.sourceCsv')}</label>
<input
id="t-src"
type="text"
class="input"
bind:value={filterSource}
placeholder={$t('triggers.form.sourcePlaceholder')}
/>
</div>
</div>
<div class="field">
<label for="t-msg" class="sub-label">{$t('triggers.form.messageRegex')}</label>
<input
id="t-msg"
type="text"
class="input mono"
class:bad={!regexValid}
bind:value={filterMessageRegex}
placeholder={$t('triggers.form.messageRegexPlaceholder')}
spellcheck="false"
aria-invalid={!regexValid}
aria-describedby={!regexValid ? 't-msg-err' : undefined}
/>
{#if !regexValid}
<span id="t-msg-err" class="hint danger" role="alert">
{$t('triggers.form.invalidRegex')}
</span>
{/if}
</div>
<div class="field">
<label for="t-target" class="sub-label">{$t('triggers.form.webhookUrl')}</label>
<input id="t-target" type="url" class="input" bind:value={actionTarget} />
</div>
<div class="field">
<div class="secret-head">
<label for="t-secret" class="sub-label">{$t('triggers.form.secretLabel')}</label>
{#if secretConfigured}
<span class="secret-badge" title={$t('triggers.form.secretRotateHint')}>
{$t('observability.configured')}
</span>
<button
type="button"
class="forge-btn-ghost xs"
onclick={clearSecret}
title={$t('triggers.form.secretRotateHint')}
>
{$t('observability.clear')}
</button>
{/if}
</div>
<input
id="t-secret"
type="password"
class="input"
bind:value={actionSecret}
autocomplete="new-password"
placeholder={$t('triggers.form.secretPlaceholder')}
aria-describedby="t-secret-hint"
/>
<span id="t-secret-hint" class="hint">
{$t('triggers.form.secretRotateHint')}
</span>
</div>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl" aria-hidden="true">{$t('triggers.form.enabled')}</span>
<p class="hint">{$t('triggers.form.enabledHint')}</p>
</div>
<ToggleSwitch bind:checked={enabled} label={$t('triggers.form.enabled')} />
</div>
<div class="actions">
<button
type="button"
class="forge-btn-ghost"
onclick={sendTest}
disabled={testing}
aria-busy={testing}
>
{testing ? $t('triggers.detail.sending') : $t('triggers.detail.sendTest')}
</button>
<button
type="submit"
class="forge-btn"
disabled={saving || !name.trim() || !actionTarget.trim() || !regexValid}
aria-busy={saving}
>
{saving ? $t('observability.saving') : $t('observability.save')}
</button>
</div>
<div class="result-slot" aria-live="polite" aria-atomic="true">
{#if testResult}
<div class="test-result" class:ok={testOk} class:fail={!testOk}>
<div class="tr-head">
<span class="tr-tag">{testOk ? $t('triggers.detail.testOk') : $t('triggers.detail.testFail')}</span>
<span class="tr-status">{$t('triggers.detail.testHttp', { code: String(testResult.status_code) })}</span>
<span class="muted">{testResult.latency_ms}ms</span>
{#if testResult.signature_sent}
<span class="muted">· {$t('triggers.detail.testSigned')}</span>
{/if}
</div>
{#if testResult.error}
<pre class="tr-body danger">{testResult.error}</pre>
{/if}
{#if testResult.response_snippet}
<pre class="tr-body">{testResult.response_snippet}</pre>
{/if}
</div>
{/if}
</div>
</form>
<section class="panel danger-panel" aria-labelledby="danger-heading">
<header class="panel-head">
<h2 class="panel-title" id="danger-heading">
{$t('triggers.detail.dangerZone')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('triggers.detail.dangerZoneSub')}</span>
</header>
<div class="danger-actions">
<button
type="button"
class="forge-btn-ghost forge-btn-danger"
onclick={() => (confirmDelete = true)}
>
{$t('triggers.detail.deleteButton')}
</button>
</div>
</section>
<ConfirmDialog
open={confirmDelete}
title={$t('triggers.detail.deleteTitle')}
message={$t('triggers.detail.deleteMessage', { name: name.trim() || trigger.name })}
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
confirmVariant="danger"
onconfirm={doDelete}
oncancel={() => (confirmDelete = false)}
/>
{/if}
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 760px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 64px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Panel ─────────────────────────────────────── */
.panel {
display: flex;
flex-direction: column;
gap: 0.9rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.5rem;
}
@media (max-width: 600px) {
.panel {
padding: 1.1rem;
gap: 1rem;
}
}
.panel.danger-panel {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--border-primary));
}
.panel-head {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.2rem;
}
.panel-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.title-accent {
color: var(--forge-accent);
}
.panel-sub {
font-family: var(--forge-mono);
font-size: 0.7rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Fields ────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono {
font-family: var(--forge-mono);
font-size: 0.85rem;
}
.input.bad {
border-color: var(--color-danger);
}
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.row.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 600px) {
.row.two {
grid-template-columns: 1fr;
}
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger {
color: var(--color-danger);
}
/* ── Secret affordance ─────────────────────────── */
.secret-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
.secret-badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.4rem;
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success-dark);
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.14em;
}
:global([data-theme='dark']) .secret-badge {
color: color-mix(in srgb, var(--color-success) 50%, var(--text-primary));
}
:global(.forge-btn-ghost.xs) {
padding: 0.2rem 0.55rem;
font-size: 0.6rem;
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--border-primary);
margin-top: 0.2rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.lbl {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
}
/* ── Actions ────────────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding-top: 0.4rem;
flex-wrap: wrap;
}
.danger-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
.danger-actions :global(.forge-btn-ghost) {
width: 100%;
justify-content: center;
}
}
/* ── Test result ────────────────────────────────── */
.result-slot {
min-height: 0;
}
.test-result {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
}
.test-result.ok {
border-color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 8%, var(--surface-card));
}
.test-result.fail {
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 8%, var(--surface-card));
}
.tr-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
font-family: var(--forge-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
}
.tr-tag {
padding: 0.1rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-weight: 700;
letter-spacing: 0.16em;
}
.test-result.ok .tr-tag {
background: var(--color-success);
color: var(--surface-card);
}
.test-result.fail .tr-tag {
background: var(--color-danger);
color: var(--surface-card);
}
.tr-status {
font-weight: 600;
color: var(--text-primary);
}
.tr-body {
margin: 0.5rem 0 0;
font-family: var(--forge-mono);
font-size: 0.78rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow: auto;
}
.tr-body.danger {
color: var(--color-danger);
}
.muted {
color: var(--text-tertiary);
}
</style>
@@ -0,0 +1,417 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { EventTriggerInput } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
let name = $state('');
let filterSeverity = $state('');
let filterSource = $state('');
let filterMessageRegex = $state('');
let actionTarget = $state('');
let actionSecret = $state('');
let enabled = $state(true);
let submitting = $state(false);
let error = $state('');
// Client-side regex sanity check. Doesn't replicate Go's regex flavour
// (e.g. ECMAScript vs RE2), but catches the most common typos before
// the operator hits Submit. Server is authoritative.
const regexValid = $derived.by(() => {
if (!filterMessageRegex) return true;
try {
new RegExp(filterMessageRegex);
return true;
} catch {
return false;
}
});
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (submitting) return;
error = '';
submitting = true;
try {
const body: EventTriggerInput = {
name: name.trim(),
filter_severity: filterSeverity.trim(),
filter_source: filterSource.trim(),
filter_message_regex: filterMessageRegex,
action_type: 'webhook',
action_target: actionTarget.trim(),
action_secret: actionSecret,
enabled
};
const created = await api.createEventTrigger(body);
goto(`/event-triggers/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$t('triggers.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('triggers.ledeNew')}
{/snippet}
<ForgeHero
backHref="/event-triggers"
backLabel={$t('triggers.toolbar.backToList')}
eyebrowSuffix={$t('triggers.toolbar.newButton').toUpperCase()}
title={$t('triggers.titleNew')}
size="lg"
lede_html={lede}
/>
<form onsubmit={submit} class="form" novalidate aria-busy={submitting}>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
<div class="field">
<label for="trig-name" class="field-label">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('triggers.form.name')}</span>
<span class="req">{$t('triggers.form.required')}</span>
</label>
<input
id="trig-name"
type="text"
bind:value={name}
class="input"
placeholder={$t('triggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('triggers.form.filtersLabel')}</span>
<span class="opt">{$t('triggers.form.andComposed')}</span>
</legend>
<div class="row two">
<label class="sub" for="trig-sev">
<span class="sub-label">{$t('triggers.form.severityCsv')}</span>
<input
id="trig-sev"
type="text"
class="input"
bind:value={filterSeverity}
placeholder={$t('triggers.form.severityPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="trig-src">
<span class="sub-label">{$t('triggers.form.sourceCsv')}</span>
<input
id="trig-src"
type="text"
class="input"
bind:value={filterSource}
placeholder={$t('triggers.form.sourcePlaceholder')}
autocomplete="off"
/>
</label>
</div>
<label class="sub" for="trig-msg">
<span class="sub-label">{$t('triggers.form.messageRegex')}</span>
<input
id="trig-msg"
type="text"
class="input mono"
class:bad={!regexValid}
bind:value={filterMessageRegex}
placeholder={$t('triggers.form.messageRegexPlaceholder')}
autocomplete="off"
spellcheck="false"
aria-invalid={!regexValid}
aria-describedby={!regexValid ? 'trig-msg-err' : undefined}
/>
{#if !regexValid}
<span id="trig-msg-err" class="hint danger" role="alert">
{$t('triggers.form.invalidRegex')}
</span>
{/if}
</label>
</fieldset>
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('triggers.form.actionLabel')}</span>
<span class="req">{$t('triggers.form.actionWebhookBadge')}</span>
</legend>
<label class="sub" for="trig-target">
<span class="sub-label">{$t('triggers.form.urlLabel')}</span>
<input
id="trig-target"
type="url"
class="input"
bind:value={actionTarget}
placeholder={$t('triggers.form.urlPlaceholder')}
autocomplete="off"
required
/>
</label>
<label class="sub" for="trig-secret">
<span class="sub-label">{$t('triggers.form.secretLabel')}</span>
<input
id="trig-secret"
type="password"
class="input"
bind:value={actionSecret}
placeholder={$t('triggers.form.secretPlaceholder')}
autocomplete="new-password"
/>
<span class="hint">{$t('triggers.form.secretHint')}</span>
</label>
</fieldset>
<div class="field row-toggle">
<div class="toggle-copy">
<span class="lbl small" aria-hidden="true">{$t('triggers.form.enabled')}</span>
<p class="hint">{$t('triggers.form.enabledHint')}</p>
</div>
<ToggleSwitch bind:checked={enabled} label={$t('triggers.form.enabled')} />
</div>
<div class="actions">
<a href="/event-triggers" class="forge-btn-ghost">{$t('observability.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={submitting || !name.trim() || !actionTarget.trim() || !regexValid}
aria-busy={submitting}
>
{submitting ? $t('triggers.form.submitting') : $t('triggers.form.submit')}
</button>
</div>
</form>
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 760px;
margin: 0 auto;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
@media (max-width: 600px) {
.form {
padding: 1.1rem;
gap: 1.25rem;
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Field structure ────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group {
gap: 0.75rem;
}
.field-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0;
}
.field-label.as-legend {
float: none;
width: 100%;
}
.num {
display: inline-flex;
width: 26px;
height: 26px;
justify-content: center;
align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 700;
flex: 0 0 auto;
}
.lbl {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--text-primary);
}
.lbl.small {
font-size: 0.95rem;
}
.req,
.opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req {
color: var(--color-danger);
}
.opt {
color: var(--text-tertiary);
}
/* ── Inputs ─────────────────────────────────────── */
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.6rem 0.8rem;
font-size: 0.92rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono {
font-family: var(--forge-mono);
font-size: 0.85rem;
}
.input.bad {
border-color: var(--color-danger);
}
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.row.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 600px) {
.row.two {
grid-template-columns: 1fr;
}
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger {
color: var(--color-danger);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border-primary);
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Actions ────────────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
}
</style>
+715
View File
@@ -0,0 +1,715 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { LogScanRule, LogScanStats } from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let rules = $state<LogScanRule[]>([]);
let loading = $state(true);
let error = $state('');
let filter = $state<'all' | 'global' | 'workload' | 'override'>('all');
// Scanner stats are loaded alongside the rule list so the
// operator sees drop counters + compile errors next to the rules
// causing them. Failure to load is non-fatal — the rules table
// is the primary content. Named `scanStats` to avoid colliding
// with the `{#snippet stats()}` slot below that feeds the hero.
let scanStats = $state<LogScanStats | null>(null);
const globals = $derived(rules.filter((r) => r.workload_id === '' && r.overrides_id === 0));
const workloadOnly = $derived(
rules.filter((r) => r.workload_id !== '' && r.overrides_id === 0)
);
const overrides = $derived(rules.filter((r) => r.overrides_id !== 0));
const enabledCount = $derived(rules.filter((r) => r.enabled).length);
const filtered = $derived.by(() => {
switch (filter) {
case 'global':
return globals;
case 'workload':
return workloadOnly;
case 'override':
return overrides;
default:
return rules;
}
});
async function load(): Promise<void> {
loading = true;
error = '';
try {
const [rs, st] = await Promise.all([
api.listLogScanRules(),
api.getLogScanStats().catch(() => null)
]);
rules = rs;
scanStats = st;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load log scan rules';
} finally {
loading = false;
}
}
function scopeLabel(r: LogScanRule): string {
if (r.overrides_id !== 0) {
return $t('logscan.scope.overrideShort', { id: String(r.overrides_id) });
}
if (r.workload_id !== '') {
return $t('logscan.scope.workload', { id: r.workload_id.slice(0, 8) });
}
return $t('logscan.scope.global');
}
function scopeClass(r: LogScanRule): string {
if (r.overrides_id !== 0) return 'scope-override';
if (r.workload_id !== '') return 'scope-workload';
return 'scope-global';
}
onMount(load);
</script>
<svelte:head>
<title>{$t('logscan.title')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
{#snippet toolbar()}
<button
class="forge-btn-icon"
onclick={load}
aria-label={$t('observability.refresh')}
disabled={loading}
>
<IconRefresh size={16} />
</button>
<a href="/log-scan-rules/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('logscan.toolbar.newButton')}</span>
</a>
{/snippet}
{#snippet stats()}
<div>
<dt>{$t('logscan.stat.total')}</dt>
<dd>{loading ? '—' : String(rules.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('logscan.stat.global')}</dt>
<dd>{loading ? '—' : String(globals.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('logscan.stat.workload')}</dt>
<dd>{loading ? '—' : String(workloadOnly.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('logscan.stat.overrides')}</dt>
<dd class="accent">{loading ? '—' : String(overrides.length).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet lede()}
{$t('logscan.lede', { enabled: String(enabledCount), total: String(rules.length) })}
{/snippet}
<ForgeHero
eyebrowSuffix={$t('observability.section').toUpperCase()}
title={$t('logscan.title')}
size="lg"
toolbar={toolbar}
lede_html={lede}
stats={stats}
/>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if !loading && rules.length > 0}
<div class="filter-row" role="group" aria-label={$t('logscan.list.scope')}>
{#each [['all', $t('logscan.filter.all'), rules.length], ['global', $t('logscan.filter.global'), globals.length], ['workload', $t('logscan.filter.workload'), workloadOnly.length], ['override', $t('logscan.filter.overrides'), overrides.length]] as [key, label, count]}
<button
type="button"
class="chip"
class:active={filter === key}
aria-pressed={filter === key}
onclick={() => (filter = key as typeof filter)}
>
<span class="chip-label">{label}</span>
<span class="chip-count">{String(count).padStart(2, '0')}</span>
</button>
{/each}
</div>
{/if}
{#if scanStats}
<section class="stats-panel" aria-labelledby="stats-heading">
<header class="stats-head">
<h2 class="stats-title" id="stats-heading">
{$t('logscan.stats.heading')}<span class="title-accent">.</span>
</h2>
<span class="stats-sub">{$t('logscan.stats.headingSub')}</span>
</header>
<dl class="stats-grid">
<div class="stat-cell">
<dt>{$t('logscan.stat.activeTails')}</dt>
<dd>{String(scanStats.active_tails).padStart(2, '0')}</dd>
</div>
<div
class="stat-cell"
class:warn={scanStats.engine.dropped_by_bucket > 0}
>
<dt>{$t('logscan.stat.droppedBucket')}</dt>
<dd>{scanStats.engine.dropped_by_bucket.toLocaleString()}</dd>
</div>
<div class="stat-cell">
<dt>{$t('logscan.stat.droppedCooldown')}</dt>
<dd>{scanStats.engine.dropped_by_cooldown.toLocaleString()}</dd>
</div>
<div
class="stat-cell"
class:bad={scanStats.last_compile_errors.length > 0}
>
<dt>{$t('logscan.stat.compileErrors')}</dt>
<dd>{String(scanStats.last_compile_errors.length).padStart(2, '0')}</dd>
</div>
</dl>
{#if scanStats.last_compile_errors.length > 0}
<div class="compile-errors" role="alert">
<span class="ce-heading">{$t('logscan.stats.compileErrorsHeading')}</span>
<ul>
{#each scanStats.last_compile_errors as msg}
<li class="mono">{msg}</li>
{/each}
</ul>
</div>
{:else}
<p class="hint stats-foot">{$t('logscan.stats.noCompileErrors')}</p>
{/if}
</section>
{/if}
{#if loading}
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
{#each Array(4) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else if rules.length === 0}
<div class="empty">
<div class="empty-mark" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<h2>{$t('logscan.empty.heading')}</h2>
<p>{$t('logscan.empty.body')}</p>
<a href="/log-scan-rules/new" class="forge-btn">
<IconPlus size={14} /><span>{$t('logscan.empty.cta')}</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('logscan.list.name')}</th>
<th>{$t('logscan.list.pattern')}</th>
<th>{$t('logscan.list.scope')}</th>
<th>{$t('logscan.list.severity')}</th>
<th class="hide-md">{$t('logscan.list.streams')}</th>
<th>{$t('logscan.list.status')}</th>
<th class="t-right">{$t('logscan.list.open')}</th>
</tr>
</thead>
<tbody>
{#each filtered as r, i (r.id)}
<tr>
<td>
<a class="row-link" href={`/log-scan-rules/${r.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{r.name}</span>
</a>
</td>
<td class="muted mono small pattern">/{r.pattern}/</td>
<td>
<span class="badge {scopeClass(r)}">{scopeLabel(r)}</span>
</td>
<td>
<span class="severity sev-{r.severity}">{r.severity}</span>
</td>
<td class="mono small muted hide-md">{r.streams}</td>
<td>
<span class="status" class:on={r.enabled} class:off={!r.enabled}>
<span class="status-dot" aria-hidden="true"></span>
{r.enabled ? $t('logscan.status.enabled') : $t('logscan.status.disabled')}
</span>
</td>
<td class="actions-cell">
<a class="row-action" href={`/log-scan-rules/${r.id}`}>
{$t('observability.open')} <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1200px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Filter chips ──────────────────────────────── */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
cursor: pointer;
color: var(--text-secondary);
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
transform 150ms ease;
}
.chip:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.chip.active {
background: var(--text-primary);
color: var(--surface-card);
border-color: var(--text-primary);
}
.chip-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
}
.chip-count {
font-family: var(--forge-mono);
font-size: 0.6rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 52px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex;
gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--forge-accent);
animation: ember 2.4s ease-in-out infinite;
}
@keyframes ember {
0%,
100% {
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
}
.empty h2 {
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.empty p {
color: var(--text-secondary);
margin: 0 auto 1.5rem;
font-size: 0.95rem;
max-width: 52ch;
line-height: 1.5;
}
/* ── Table ─────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: var(--surface-card);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-wrap :global(.forge-table) {
min-width: 720px;
}
.t-right {
text-align: right;
}
.pattern {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.pattern {
max-width: 200px;
}
.hide-md {
display: none;
}
}
/* ── Row link / action ────────────────────────── */
.row-link {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
color: var(--text-primary);
text-decoration: none;
transition: color 120ms ease;
}
.row-link:hover {
color: var(--forge-accent);
}
.row-link:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-ref {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.row-name {
font-weight: 600;
}
.row-action {
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--forge-accent);
text-decoration: none;
}
.row-action:hover {
color: var(--color-brand-500);
}
.row-action:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
.actions-cell {
text-align: right;
}
/* ── Badges ────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 1px solid var(--border-primary);
white-space: nowrap;
}
.badge.scope-global {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.badge.scope-workload {
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
border-color: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
color: var(--color-brand-600);
}
.badge.scope-override {
background: color-mix(in srgb, var(--forge-accent) 12%, transparent);
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
color: var(--forge-accent);
}
/* ── Severity ──────────────────────────────────── */
.severity {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
border: 1px solid var(--border-primary);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
white-space: nowrap;
}
.sev-info {
background: color-mix(in srgb, var(--color-info, var(--text-tertiary)) 10%, transparent);
border-color: color-mix(in srgb, var(--color-info, var(--text-tertiary)) 30%, transparent);
color: var(--text-secondary);
}
.sev-warn {
background: color-mix(in srgb, var(--color-warning, #f59e0b) 16%, transparent);
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
color: var(--color-warning-dark, #b45309);
}
.sev-error {
background: var(--color-danger-light);
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
color: var(--color-danger-dark);
}
:global([data-theme='dark']) .sev-error {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
.small {
font-size: 0.72rem;
}
/* ── Status ────────────────────────────────────── */
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status.on {
color: var(--color-success-dark);
}
.status.on .status-dot {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
}
.status.off {
color: var(--text-tertiary);
}
.status.off .status-dot {
background: var(--text-tertiary);
opacity: 0.5;
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
}
/* ── Stats panel ────────────────────────────────────────
Drops a small four-cell counter row + compile-error list
between the hero/filter chips and the rules table.
Cells highlight when their counter is non-zero so the
operator notices a noisy regex without having to read
the number itself. */
.stats-panel {
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 1rem 1.1rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
}
.stats-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.stats-title {
margin: 0;
font-size: 0.95rem;
}
.title-accent {
color: var(--forge-accent);
}
.stats-sub {
font-family: var(--forge-mono);
font-size: 0.7rem;
color: var(--text-tertiary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
margin: 0;
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.stat-cell {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.55rem 0.7rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
}
.stat-cell.warn {
background: color-mix(in srgb, var(--color-warning, #f59e0b) 12%, transparent);
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 40%, var(--border-primary));
}
.stat-cell.bad {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
border-color: color-mix(in srgb, var(--color-danger) 40%, var(--border-primary));
}
.stat-cell dt {
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--text-secondary);
margin: 0;
}
.stat-cell dd {
font-family: var(--forge-mono);
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.compile-errors {
padding: 0.55rem 0.75rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-radius: var(--radius-md);
}
.compile-errors .ce-heading {
display: block;
font-family: var(--forge-mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 0.4rem;
}
.compile-errors ul {
margin: 0;
padding-left: 1.1rem;
font-size: 0.8rem;
line-height: 1.5;
}
.stats-foot {
margin: 0;
font-size: 0.78rem;
color: var(--text-tertiary);
}
</style>
@@ -0,0 +1,683 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as api from '$lib/api';
import type { LogScanRule, LogScanRuleInput, LogScanTestResult } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import RegexTestBox from '$lib/components/RegexTestBox.svelte';
import { t } from '$lib/i18n';
// SvelteKit gives a string id; a non-numeric path silently maps
// to NaN. Guard explicitly so the rest of the page doesn't make
// bogus API calls.
const id = $derived.by(() => {
const n = Number($page.params.id);
return Number.isFinite(n) && n > 0 ? n : null;
});
let rule = $state<LogScanRule | null>(null);
let loading = $state(true);
let saving = $state(false);
let testing = $state(false);
let deleting = $state(false);
let confirmDelete = $state(false);
let error = $state('');
let serverTestResult = $state<LogScanTestResult | null>(null);
// Cached workload name for the scope label. We fetch by id
// rather than listing every workload because the rule already
// tells us exactly which one to look up. A failed lookup falls
// back to the truncated id so the page still renders.
let scopedWorkloadName = $state('');
let name = $state('');
let pattern = $state('');
let severity = $state<'info' | 'warn' | 'error'>('warn');
let streams = $state<'all' | 'stdout' | 'stderr'>('all');
let cooldownSeconds = $state(60);
let enabled = $state(true);
let sampleLine = $state('');
const regexValid = $derived.by(() => {
if (!pattern) return true;
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
});
async function load(): Promise<void> {
if (id === null) {
error = 'Invalid rule id';
loading = false;
return;
}
loading = true;
error = '';
try {
const r = await api.getLogScanRule(id);
rule = r;
name = r.name;
pattern = r.pattern;
severity = r.severity;
streams = r.streams;
cooldownSeconds = r.cooldown_seconds;
enabled = r.enabled;
// Best-effort: resolve the workload name for the scope
// label. Failure here doesn't block the rest of the page —
// scopeLabel falls back to the truncated id.
if (r.workload_id) {
try {
const w = await api.getWorkload(r.workload_id);
scopedWorkloadName = w.name;
} catch {
scopedWorkloadName = '';
}
} else {
scopedWorkloadName = '';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load rule';
} finally {
loading = false;
}
}
async function save(e?: Event): Promise<void> {
e?.preventDefault();
if (!rule || id === null || saving) return;
saving = true;
error = '';
try {
const body: LogScanRuleInput = {
name: name.trim(),
pattern,
severity,
streams,
cooldown_seconds: cooldownSeconds,
enabled
};
rule = await api.updateLogScanRule(id, body);
} catch (e) {
error = e instanceof Error ? e.message : 'Save failed';
} finally {
saving = false;
}
}
async function runServerTest(): Promise<void> {
if (id === null) return;
testing = true;
serverTestResult = null;
error = '';
try {
serverTestResult = await api.testLogScanRule(id, sampleLine);
} catch (e) {
error = e instanceof Error ? e.message : 'Test failed';
} finally {
testing = false;
}
}
async function doDelete(): Promise<void> {
if (id === null) return;
deleting = true;
error = '';
try {
await api.deleteLogScanRule(id);
goto('/log-scan-rules');
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
deleting = false;
confirmDelete = false;
}
}
function scopeLabel(r: LogScanRule | null): string {
if (!r) return '';
if (r.overrides_id !== 0) {
return $t('logscan.scope.override', { id: String(r.overrides_id) });
}
if (r.workload_id !== '') {
// Prefer the human-readable name when the workload load
// succeeded; fall back to the truncated id so the label
// still resolves on missing/deleted workloads.
const label = scopedWorkloadName || r.workload_id.slice(0, 8);
return $t('logscan.scope.workload', { id: label });
}
return $t('logscan.scope.global');
}
onMount(load);
</script>
<svelte:head>
<title>{rule?.name ?? $t('logscan.titleSingular')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
{#snippet detailLede()}
{#if rule}
<span class="lede-meta">
{$t('logscan.list.scope')} <code>{scopeLabel(rule)}</code> ·
{$t('logscan.list.severity')} <code>{rule.severity}</code> ·
{$t('logscan.list.streams')} <code>{rule.streams}</code>
</span>
{/if}
{/snippet}
<ForgeHero
backHref="/log-scan-rules"
backLabel={$t('logscan.toolbar.backToList')}
eyebrowSuffix={$t('logscan.titleSingular').toUpperCase()}
title={rule?.name ?? $t('observability.loading')}
size="lg"
lede_html={rule ? detailLede : undefined}
/>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading || !rule}
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
{#each Array(3) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else}
<form class="panel" onsubmit={save} aria-busy={saving}>
<header class="panel-head">
<h2 class="panel-title">{$t('logscan.detail.config')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{$t('logscan.detail.configSub', { id: String(rule.id), scope: scopeLabel(rule) })}
</span>
</header>
<div class="field">
<label for="r-name" class="sub-label">{$t('logscan.form.name')}</label>
<input id="r-name" type="text" class="input" bind:value={name} required />
</div>
<div class="field">
<label for="r-pattern" class="sub-label">{$t('logscan.form.pattern')}</label>
<input
id="r-pattern"
type="text"
class="input mono"
class:bad={!regexValid}
bind:value={pattern}
spellcheck="false"
aria-invalid={!regexValid}
aria-describedby={!regexValid ? 'r-pattern-err' : undefined}
required
/>
{#if !regexValid}
<span id="r-pattern-err" class="hint danger" role="alert">
{$t('logscan.form.invalidRegex')}
</span>
{/if}
</div>
<div class="row three">
<label class="sub" for="r-severity">
<span class="sub-label">{$t('logscan.form.severity')}</span>
<select id="r-severity" class="input" bind:value={severity}>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="sub" for="r-streams">
<span class="sub-label">{$t('logscan.form.streams')}</span>
<select id="r-streams" class="input" bind:value={streams}>
<option value="all">all</option>
<option value="stdout">stdout</option>
<option value="stderr">stderr</option>
</select>
</label>
<label class="sub" for="r-cooldown">
<span class="sub-label">{$t('logscan.form.cooldown')}</span>
<input
id="r-cooldown"
type="number"
min="0"
class="input"
bind:value={cooldownSeconds}
/>
</label>
</div>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl" aria-hidden="true">{$t('logscan.form.enabled')}</span>
<p class="hint">{$t('logscan.form.enabledHint')}</p>
</div>
<ToggleSwitch bind:checked={enabled} label={$t('logscan.form.enabled')} />
</div>
<div class="actions">
<button
type="submit"
class="forge-btn"
disabled={saving || !name.trim() || !pattern || !regexValid}
aria-busy={saving}
>
{saving ? $t('observability.saving') : $t('observability.save')}
</button>
</div>
</form>
<section class="panel" aria-labelledby="test-heading">
<header class="panel-head">
<h2 class="panel-title" id="test-heading">{$t('logscan.detail.regexTest')}<span class="title-accent">.</span></h2>
<span class="panel-sub">{$t('logscan.detail.regexTestSub')}</span>
</header>
<RegexTestBox {pattern} bind:sample={sampleLine} />
<div class="server-actions">
<button
type="button"
class="forge-btn-ghost"
onclick={runServerTest}
disabled={testing || !sampleLine}
aria-busy={testing}
title={!sampleLine
? $t('logscan.detail.serverTestHint')
: $t('logscan.detail.serverTestSendHint')}
>
{testing ? $t('logscan.detail.testing') : $t('logscan.detail.runServerTest')}
</button>
</div>
<div class="server-slot" aria-live="polite" aria-atomic="true">
{#if serverTestResult}
<div
class="server-test"
class:ok={serverTestResult.matched && !serverTestResult.error}
class:fail={!!serverTestResult.error}
>
{#if serverTestResult.error}
<span class="tag fail">{$t('logscan.detail.serverError')}</span>
<pre class="server-pre muted">{serverTestResult.error}</pre>
{:else if serverTestResult.matched}
<span class="tag ok">{$t('logscan.detail.serverMatch')}</span>
{#if serverTestResult.captures}
<dl class="captures">
{#each Object.entries(serverTestResult.captures) as [k, v]}
<div class="cap">
<dt>{k}</dt>
<dd class="mono">{v || '∅'}</dd>
</div>
{/each}
</dl>
{/if}
{:else}
<span class="tag off">{$t('logscan.detail.serverNoMatch')}</span>
<span class="muted">{$t('logscan.detail.serverNoMatchHint')}</span>
{/if}
</div>
{/if}
</div>
</section>
<section class="panel danger-panel" aria-labelledby="danger-heading">
<header class="panel-head">
<h2 class="panel-title" id="danger-heading">{$t('logscan.detail.dangerZone')}<span class="title-accent">.</span></h2>
<span class="panel-sub">{$t('logscan.detail.dangerZoneSub')}</span>
</header>
<div class="danger-actions">
<button
type="button"
class="forge-btn-ghost forge-btn-danger"
onclick={() => (confirmDelete = true)}
>
{$t('logscan.detail.deleteButton')}
</button>
</div>
</section>
<ConfirmDialog
open={confirmDelete}
title={$t('logscan.detail.deleteTitle')}
message={$t('logscan.detail.deleteMessage', { name: name.trim() || rule.name })}
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
confirmVariant="danger"
onconfirm={doDelete}
oncancel={() => (confirmDelete = false)}
/>
{/if}
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 820px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 64px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Panel ─────────────────────────────────────── */
.panel {
display: flex;
flex-direction: column;
gap: 0.9rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.5rem;
}
@media (max-width: 600px) {
.panel {
padding: 1.1rem;
gap: 1rem;
}
}
.panel.danger-panel {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--border-primary));
}
.panel-head {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.2rem;
}
.panel-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.title-accent {
color: var(--forge-accent);
}
.panel-sub {
font-family: var(--forge-mono);
font-size: 0.7rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Fields ────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono {
font-family: var(--forge-mono);
font-size: 0.85rem;
}
.input.bad {
border-color: var(--color-danger);
}
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.row.three {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 720px) {
.row.three {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.row.three {
grid-template-columns: 1fr;
}
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger {
color: var(--color-danger);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--border-primary);
margin-top: 0.2rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.lbl {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
}
/* ── Action footers ─────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding-top: 0.4rem;
flex-wrap: wrap;
}
.danger-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
.danger-actions :global(.forge-btn-ghost) {
width: 100%;
justify-content: center;
}
}
.server-actions {
display: flex;
justify-content: flex-end;
}
.server-slot {
min-height: 0;
}
.server-test {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-card-hover);
}
.server-test.ok {
border-color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 8%, var(--surface-card));
}
.server-test.fail {
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 8%, var(--surface-card));
}
/* ── Tags ───────────────────────────────────────── */
.tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.6rem;
letter-spacing: 0.16em;
padding: 0.1rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
.tag.ok {
background: var(--color-success);
color: var(--surface-card);
}
.tag.fail {
background: var(--color-danger);
color: var(--surface-card);
}
.tag.off {
background: var(--text-tertiary);
color: var(--surface-card);
}
/* ── Captures ───────────────────────────────────── */
.captures {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0;
flex: 1 1 100%;
}
.cap {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
}
.cap dt {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--text-secondary);
margin: 0;
}
.cap dd {
margin: 0;
font-size: 0.78rem;
color: var(--text-primary);
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
}
.server-pre {
margin: 0;
font-family: var(--forge-mono);
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
max-width: 100%;
flex: 1 1 100%;
}
</style>
@@ -0,0 +1,589 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { LogScanRuleInput } from '$lib/api';
import type { EntityPickerItem, Workload } from '$lib/types';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import RegexTestBox from '$lib/components/RegexTestBox.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import { IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
let name = $state('');
let pattern = $state('');
let severity = $state<'info' | 'warn' | 'error'>('warn');
let streams = $state<'all' | 'stdout' | 'stderr'>('all');
let cooldownSeconds = $state(60);
let workloadID = $state(''); // empty = global
let enabled = $state(true);
let sampleLine = $state('');
let submitting = $state(false);
let error = $state('');
// Workload picker state. Loaded once on mount so the modal is
// instant when the user opens it. Failure to load is non-fatal —
// the operator can still type-paste an id via the (legacy)
// advanced toggle if we ever surface one; for now we just
// surface the load error in the page-level alert.
let workloads = $state<Workload[]>([]);
let pickerOpen = $state(false);
// Map each workload to a picker item. Plugin-native rows
// surface their source plugin; legacy rows show their kind
// (project / stack / site). The group label lets the picker's
// grouped layout cluster related entries together.
const pickerItems = $derived<EntityPickerItem[]>(
workloads.map((w) => ({
value: w.id,
label: w.name,
description: w.source_kind || w.kind,
group: (w.source_kind || w.kind || 'other').toUpperCase()
}))
);
const selectedWorkload = $derived(workloads.find((w) => w.id === workloadID));
const regexValid = $derived.by(() => {
if (!pattern) return true;
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
});
onMount(async () => {
try {
workloads = await api.listWorkloads();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load workloads';
}
});
function pickWorkload(value: string): void {
workloadID = value;
pickerOpen = false;
}
function clearWorkload(): void {
workloadID = '';
}
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (submitting) return;
error = '';
submitting = true;
try {
const body: LogScanRuleInput = {
name: name.trim(),
pattern,
severity,
streams,
cooldown_seconds: cooldownSeconds,
workload_id: workloadID.trim(),
enabled
};
const created = await api.createLogScanRule(body);
goto(`/log-scan-rules/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$t('logscan.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('logscan.ledeNew')}
{/snippet}
<ForgeHero
backHref="/log-scan-rules"
backLabel={$t('logscan.toolbar.backToList')}
eyebrowSuffix={$t('logscan.toolbar.newButton').toUpperCase()}
title={$t('logscan.titleNew')}
size="lg"
lede_html={lede}
/>
<form onsubmit={submit} class="form" aria-busy={submitting}>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
<div class="field">
<label for="r-name" class="field-label">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('logscan.form.name')}</span>
<span class="req">{$t('logscan.form.required')}</span>
</label>
<input
id="r-name"
type="text"
class="input"
bind:value={name}
required
placeholder={$t('logscan.form.namePlaceholder')}
/>
</div>
<div class="field">
<label for="r-pattern" class="field-label">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('logscan.form.pattern')}</span>
<span class="req">{$t('logscan.form.regex')}</span>
</label>
<input
id="r-pattern"
type="text"
class="input mono"
class:bad={!regexValid}
bind:value={pattern}
placeholder={$t('logscan.form.patternPlaceholder')}
spellcheck="false"
aria-invalid={!regexValid}
aria-describedby={!regexValid ? 'r-pattern-err' : undefined}
required
/>
{#if !regexValid}
<span id="r-pattern-err" class="hint danger" role="alert">
{$t('logscan.form.invalidRegex')}
</span>
{/if}
<RegexTestBox {pattern} bind:sample={sampleLine} />
</div>
<div class="field group">
<div class="field-label">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('logscan.form.matchShape')}</span>
<span class="opt">{$t('logscan.form.matchShapeOpts')}</span>
</div>
<div class="row three">
<label class="sub" for="r-severity">
<span class="sub-label">{$t('logscan.form.severity')}</span>
<select id="r-severity" class="input" bind:value={severity}>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="sub" for="r-streams">
<span class="sub-label">{$t('logscan.form.streams')}</span>
<select id="r-streams" class="input" bind:value={streams}>
<option value="all">all</option>
<option value="stdout">stdout</option>
<option value="stderr">stderr</option>
</select>
</label>
<label class="sub" for="r-cooldown">
<span class="sub-label">{$t('logscan.form.cooldown')}</span>
<input
id="r-cooldown"
type="number"
min="0"
class="input"
bind:value={cooldownSeconds}
/>
</label>
</div>
<p class="hint">{$t('logscan.form.cooldownHint')}</p>
</div>
<div class="field">
<div class="field-label">
<span class="num" aria-hidden="true">04</span>
<span class="lbl">{$t('logscan.form.scope')}</span>
<span class="opt">{$t('logscan.form.optional')}</span>
</div>
<div class="scope-picker">
{#if workloadID === ''}
<div class="scope-state global">
<span class="scope-icon" aria-hidden="true"></span>
<span class="scope-text">{$t('logscan.form.scopeGlobal')}</span>
<button
type="button"
class="forge-btn-ghost xs"
onclick={() => (pickerOpen = true)}
>
{$t('logscan.form.scopePick')}
</button>
</div>
{:else}
<div class="scope-state workload">
<span class="scope-tag">{$t('logscan.form.scopeSelected')}</span>
{#if selectedWorkload}
<span class="scope-text">{selectedWorkload.name}</span>
<code class="scope-meta">
{selectedWorkload.source_kind || selectedWorkload.kind}
</code>
{:else}
<span class="scope-text muted">{$t('logscan.form.scopeUnknown')}</span>
<code class="scope-meta">{workloadID}</code>
{/if}
<button
type="button"
class="forge-btn-ghost xs"
onclick={() => (pickerOpen = true)}
>
{$t('observability.edit')}
</button>
<button
type="button"
class="scope-clear"
onclick={clearWorkload}
aria-label={$t('logscan.form.scopeClear')}
title={$t('logscan.form.scopeClear')}
>
<IconX size={14} />
</button>
</div>
{/if}
</div>
<p class="hint">{$t('logscan.form.scopeHint')}</p>
</div>
<div class="field row-toggle">
<div class="toggle-copy">
<span class="lbl small" aria-hidden="true">{$t('logscan.form.enabled')}</span>
<p class="hint">{$t('logscan.form.enabledHint')}</p>
</div>
<ToggleSwitch bind:checked={enabled} label={$t('logscan.form.enabled')} />
</div>
<div class="actions">
<a href="/log-scan-rules" class="forge-btn-ghost">{$t('observability.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={submitting || !name.trim() || !pattern || !regexValid}
aria-busy={submitting}
>
{submitting ? $t('logscan.form.submitting') : $t('logscan.form.submit')}
</button>
</div>
</form>
<EntityPicker
bind:open={pickerOpen}
items={pickerItems}
current={workloadID}
title={$t('logscan.form.scopePickTitle')}
onselect={pickWorkload}
onclose={() => (pickerOpen = false)}
/>
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 820px;
margin: 0 auto;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
@media (max-width: 600px) {
.form {
padding: 1.1rem;
gap: 1.25rem;
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Field structure ────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group {
gap: 0.75rem;
}
.field-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
}
.num {
display: inline-flex;
width: 26px;
height: 26px;
justify-content: center;
align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 700;
flex: 0 0 auto;
}
.lbl {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.lbl.small {
font-size: 0.95rem;
}
.req,
.opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req {
color: var(--color-danger);
}
.opt {
color: var(--text-tertiary);
}
/* ── Inputs ─────────────────────────────────────── */
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono {
font-family: var(--forge-mono);
font-size: 0.85rem;
}
.input.bad {
border-color: var(--color-danger);
}
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.row.three {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 720px) {
.row.three {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.row.three {
grid-template-columns: 1fr;
}
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger {
color: var(--color-danger);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border-primary);
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Action footer ──────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
}
/* ── Scope picker chip ────────────────────────────────────
Replaces the free-text workload-id input with a single
state-rendering row: either "Global · [Pick workload…]"
or "Workload · <name> <kind> [Edit] [×]". Visual style
mirrors the apps detail page's chain/status chips. */
.scope-picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.scope-state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.75rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
}
.scope-state.global {
border-style: dashed;
}
.scope-state.workload {
background: color-mix(in srgb, var(--color-brand-500) 8%, var(--surface-card-hover));
border-color: color-mix(in srgb, var(--color-brand-500) 35%, var(--border-primary));
}
.scope-icon {
font-size: 0.7rem;
color: var(--text-tertiary);
}
.scope-text {
font-weight: 600;
font-size: 0.92rem;
color: var(--text-primary);
}
.scope-text.muted {
color: var(--text-tertiary);
font-style: italic;
}
.scope-tag {
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.12rem 0.4rem;
background: var(--color-brand-500);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
.scope-meta {
font-family: var(--forge-mono);
font-size: 0.72rem;
color: var(--text-secondary);
padding: 0.1rem 0.35rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
}
.scope-state .forge-btn-ghost {
margin-left: auto;
}
.scope-state.workload .forge-btn-ghost {
margin-left: auto;
margin-right: 0;
}
.scope-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-tertiary);
cursor: pointer;
transition: color 120ms ease, border-color 120ms ease;
}
.scope-clear:hover {
color: var(--color-danger);
border-color: var(--color-danger);
}
:global(.forge-btn-ghost.xs) {
padding: 0.2rem 0.55rem;
font-size: 0.62rem;
}
</style>