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:
@@ -1078,6 +1078,141 @@ export function setWorkloadAppID(id: string, appID: string): Promise<Workload> {
|
|||||||
return patch<Workload>(`/api/workloads/${id}/app`, { app_id: appID });
|
return patch<Workload>(`/api/workloads/${id}/app`, { app_id: appID });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPluginWorkload(body: import('./types').PluginWorkloadInput): Promise<Workload> {
|
||||||
|
return post<Workload>('/api/workloads', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePluginWorkload(id: string, body: import('./types').PluginWorkloadInput): Promise<Workload> {
|
||||||
|
return put<Workload>(`/api/workloads/${id}/plugin`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deployPluginWorkload(
|
||||||
|
id: string,
|
||||||
|
body?: { reference?: string; note?: string }
|
||||||
|
): Promise<{ workload_id: string; reference: string; triggered_by: string }> {
|
||||||
|
return post(`/api/workloads/${id}/deploy`, body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listHookKinds(signal?: AbortSignal): Promise<import('./types').HookKinds> {
|
||||||
|
return get<import('./types').HookKinds>('/api/hooks/kinds', signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePluginWorkload(id: string): Promise<{ deleted: string }> {
|
||||||
|
return del<{ deleted: string }>(`/api/workloads/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkloadEnv {
|
||||||
|
id: string;
|
||||||
|
workload_id: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
encrypted: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkloadEnv(id: string, signal?: AbortSignal): Promise<WorkloadEnv[]> {
|
||||||
|
return get<WorkloadEnv[]>(`/api/workloads/${id}/env`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkloadEnv(
|
||||||
|
id: string,
|
||||||
|
body: { key: string; value: string; encrypted: boolean }
|
||||||
|
): Promise<WorkloadEnv> {
|
||||||
|
return put<WorkloadEnv>(`/api/workloads/${id}/env`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted: string }> {
|
||||||
|
return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkloadWebhook {
|
||||||
|
webhook_url: string;
|
||||||
|
webhook_secret: string;
|
||||||
|
has_signing_secret: boolean;
|
||||||
|
webhook_require_signature: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkloadWebhook(id: string, signal?: AbortSignal): Promise<WorkloadWebhook> {
|
||||||
|
return get<WorkloadWebhook>(`/api/workloads/${id}/webhook`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateWorkloadWebhook(id: string): Promise<WorkloadWebhook> {
|
||||||
|
return post<WorkloadWebhook>(`/api/workloads/${id}/webhook/regenerate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWorkloadContainerLogs(
|
||||||
|
workloadId: string,
|
||||||
|
containerRowId: string,
|
||||||
|
tail: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
return get<string[]>(
|
||||||
|
`/api/workloads/${workloadId}/containers/${containerRowId}/logs?tail=${tail}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkloadVolume {
|
||||||
|
id: string;
|
||||||
|
workload_id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
scope: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkloadVolumes(id: string, signal?: AbortSignal): Promise<WorkloadVolume[]> {
|
||||||
|
return get<WorkloadVolume[]>(`/api/workloads/${id}/volumes`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkloadVolume(
|
||||||
|
id: string,
|
||||||
|
body: { source: string; target: string; scope: string; name?: string }
|
||||||
|
): Promise<WorkloadVolume> {
|
||||||
|
return put<WorkloadVolume>(`/api/workloads/${id}/volumes`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWorkloadVolume(id: string, volID: string): Promise<{ deleted: string }> {
|
||||||
|
return del<{ deleted: string }>(`/api/workloads/${id}/volumes/${volID}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookKindSchema {
|
||||||
|
kind: string;
|
||||||
|
sample: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise<HookKindSchema> {
|
||||||
|
return get<HookKindSchema>(`/api/hooks/kinds/${kind}/schema`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkloadChainNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source_kind: string;
|
||||||
|
trigger_kind: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkloadChain {
|
||||||
|
parent: WorkloadChainNode | null;
|
||||||
|
self: WorkloadChainNode;
|
||||||
|
children: WorkloadChainNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkloadChain(id: string, signal?: AbortSignal): Promise<WorkloadChain> {
|
||||||
|
return get<WorkloadChain>(`/api/workloads/${id}/chain`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteFromWorkload(
|
||||||
|
targetID: string,
|
||||||
|
sourceID: string,
|
||||||
|
body?: { image_tag?: string; deploy?: boolean }
|
||||||
|
): Promise<{ workload_id: string; source_id: string; promoted_tag: string; deploy_queued: boolean }> {
|
||||||
|
return post(`/api/workloads/${targetID}/promote-from/${sourceID}`, body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Containers (global index) ───────────────────────────────────────
|
// ── Containers (global index) ───────────────────────────────────────
|
||||||
|
|
||||||
export interface ListContainersFilter {
|
export interface ListContainersFilter {
|
||||||
@@ -1121,4 +1256,141 @@ export function deleteApp(id: string): Promise<void> {
|
|||||||
return del<void>(`/api/apps/${id}`);
|
return del<void>(`/api/apps/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event Triggers ──────────────────────────────────────────────────
|
||||||
|
// Backend: internal/api/event_triggers.go. AND-composed filter shape;
|
||||||
|
// empty filter fields mean "match any value." The dispatcher fans
|
||||||
|
// matching event_log entries out to action_target via signed webhook.
|
||||||
|
|
||||||
|
export interface EventTrigger {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
filter_severity: string; // CSV; "" = any
|
||||||
|
filter_source: string; // CSV; "" = any
|
||||||
|
filter_message_regex: string; // "" = any
|
||||||
|
action_type: string; // 'webhook' today
|
||||||
|
action_target: string; // URL
|
||||||
|
action_secret: string; // optional HMAC secret
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventTriggerInput {
|
||||||
|
name: string;
|
||||||
|
filter_severity?: string;
|
||||||
|
filter_source?: string;
|
||||||
|
filter_message_regex?: string;
|
||||||
|
action_type?: string;
|
||||||
|
action_target: string;
|
||||||
|
action_secret?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEventTriggers(signal?: AbortSignal): Promise<EventTrigger[]> {
|
||||||
|
return get<EventTrigger[]>('/api/event-triggers', signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventTrigger(id: number, signal?: AbortSignal): Promise<EventTrigger> {
|
||||||
|
return get<EventTrigger>(`/api/event-triggers/${id}`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventTrigger(data: EventTriggerInput): Promise<EventTrigger> {
|
||||||
|
return post<EventTrigger>('/api/event-triggers', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEventTrigger(id: number, data: EventTriggerInput): Promise<EventTrigger> {
|
||||||
|
return patch<EventTrigger>(`/api/event-triggers/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEventTrigger(id: number): Promise<void> {
|
||||||
|
return del<void>(`/api/event-triggers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testEventTrigger(id: number): Promise<NotificationTestResult> {
|
||||||
|
return post<NotificationTestResult>(`/api/event-triggers/${id}/test`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log scan rules ──────────────────────────────────────────────────
|
||||||
|
// Backend: internal/api/log_scan_rules.go. Rules are regex patterns
|
||||||
|
// the scanner manager evaluates against container log lines. Scope
|
||||||
|
// model: workload_id="" + overrides_id=0 → global; workload_id set →
|
||||||
|
// workload-only (or per-workload override of a global via
|
||||||
|
// overrides_id).
|
||||||
|
|
||||||
|
export interface LogScanRule {
|
||||||
|
id: number;
|
||||||
|
workload_id: string; // "" = global
|
||||||
|
overrides_id: number; // 0 = not an override
|
||||||
|
name: string;
|
||||||
|
pattern: string;
|
||||||
|
severity: 'info' | 'warn' | 'error';
|
||||||
|
streams: 'all' | 'stdout' | 'stderr';
|
||||||
|
cooldown_seconds: number;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogScanRuleInput {
|
||||||
|
workload_id?: string;
|
||||||
|
overrides_id?: number;
|
||||||
|
name: string;
|
||||||
|
pattern: string;
|
||||||
|
severity?: 'info' | 'warn' | 'error';
|
||||||
|
streams?: 'all' | 'stdout' | 'stderr';
|
||||||
|
cooldown_seconds?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogScanTestResult {
|
||||||
|
matched: boolean;
|
||||||
|
captures?: Record<string, string>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLogScanRules(opts?: {
|
||||||
|
workloadID?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<LogScanRule[]> {
|
||||||
|
const params = opts?.workloadID ? `?workload_id=${encodeURIComponent(opts.workloadID)}` : '';
|
||||||
|
return get<LogScanRule[]>(`/api/log-scan-rules${params}`, opts?.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogScanRule(id: number, signal?: AbortSignal): Promise<LogScanRule> {
|
||||||
|
return get<LogScanRule>(`/api/log-scan-rules/${id}`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogScanRule(data: LogScanRuleInput): Promise<LogScanRule> {
|
||||||
|
return post<LogScanRule>('/api/log-scan-rules', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLogScanRule(id: number, data: LogScanRuleInput): Promise<LogScanRule> {
|
||||||
|
return patch<LogScanRule>(`/api/log-scan-rules/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLogScanRule(id: number): Promise<void> {
|
||||||
|
return del<void>(`/api/log-scan-rules/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testLogScanRule(id: number, sampleLine: string): Promise<LogScanTestResult> {
|
||||||
|
return post<LogScanTestResult>(`/api/log-scan-rules/${id}/test`, { sample_line: sampleLine });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveLogScanRules(workloadID: string, signal?: AbortSignal): Promise<LogScanRule[]> {
|
||||||
|
return get<LogScanRule[]>(`/api/workloads/${workloadID}/effective-rules`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogScanStats {
|
||||||
|
engine: {
|
||||||
|
dropped_by_bucket: number;
|
||||||
|
dropped_by_cooldown: number;
|
||||||
|
};
|
||||||
|
active_tails: number;
|
||||||
|
last_compile_errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogScanStats(signal?: AbortSignal): Promise<LogScanStats> {
|
||||||
|
return get<LogScanStats>('/api/log-scan-rules/stats', signal);
|
||||||
|
}
|
||||||
|
|
||||||
export { ApiError };
|
export { ApiError };
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<!--
|
||||||
|
RegexTestBox — input a sample log line, instantly see whether the
|
||||||
|
current pattern matches and what subgroups it captures. Used on
|
||||||
|
the log-scan-rules new and edit pages so operators can iterate on
|
||||||
|
a regex without saving + waiting for a real container line.
|
||||||
|
|
||||||
|
Pure client-side evaluation. JavaScript regex flavour differs from
|
||||||
|
Go's RE2 in obscure cases — close enough for an iteration aid; the
|
||||||
|
server-side /test endpoint is authoritative.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pattern: string;
|
||||||
|
sample?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pattern, sample = $bindable('') }: Props = $props();
|
||||||
|
|
||||||
|
type Result =
|
||||||
|
| { state: 'empty' }
|
||||||
|
| { state: 'invalid-pattern'; error: string }
|
||||||
|
| { state: 'no-match' }
|
||||||
|
| { state: 'match'; full: string; captures: Array<{ name: string; value: string }> };
|
||||||
|
|
||||||
|
const result = $derived.by((): Result => {
|
||||||
|
if (!pattern || !sample) return { state: 'empty' };
|
||||||
|
let re: RegExp;
|
||||||
|
try {
|
||||||
|
re = new RegExp(pattern);
|
||||||
|
} catch (e) {
|
||||||
|
return { state: 'invalid-pattern', error: e instanceof Error ? e.message : 'bad regex' };
|
||||||
|
}
|
||||||
|
const m = re.exec(sample);
|
||||||
|
if (!m) return { state: 'no-match' };
|
||||||
|
const captures = m.slice(1).map((value, i) => ({ name: `$${i + 1}`, value: value ?? '' }));
|
||||||
|
return { state: 'match', full: m[0], captures };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rtb">
|
||||||
|
<label for="rtb-sample" class="sub-label">{$t('observability.regex.sampleLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
id="rtb-sample"
|
||||||
|
class="rtb-input"
|
||||||
|
bind:value={sample}
|
||||||
|
rows="3"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={$t('observability.regex.placeholder')}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="rtb-feedback">
|
||||||
|
{#if result.state === 'empty'}
|
||||||
|
<div class="status muted">{$t('observability.regex.promptType')}</div>
|
||||||
|
{:else if result.state === 'invalid-pattern'}
|
||||||
|
<div class="status fail">
|
||||||
|
<span class="tag">{$t('observability.regex.invalid')}</span>
|
||||||
|
<span class="msg">{result.error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if result.state === 'no-match'}
|
||||||
|
<div class="status off">
|
||||||
|
<span class="tag">{$t('observability.regex.noMatch')}</span>
|
||||||
|
<span class="msg muted">{$t('observability.regex.noMatchHint')}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="status ok">
|
||||||
|
<span class="tag">{$t('observability.regex.match')}</span>
|
||||||
|
<span class="full">{result.full}</span>
|
||||||
|
</div>
|
||||||
|
{#if result.captures.length > 0}
|
||||||
|
<div class="captures">
|
||||||
|
<span class="sub-label">{$t('observability.regex.captures')}</span>
|
||||||
|
<dl>
|
||||||
|
{#each result.captures as c}
|
||||||
|
<div class="cap">
|
||||||
|
<dt>{c.name}</dt>
|
||||||
|
<dd class="mono">{c.value || '∅'}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rtb {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.rtb-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-input);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.rtb-input:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--forge-accent-soft);
|
||||||
|
}
|
||||||
|
.rtb-feedback {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background: var(--surface-card-hover);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.status.ok {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
background: color-mix(in srgb, var(--color-success) 8%, var(--surface-card));
|
||||||
|
}
|
||||||
|
.status.fail {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 8%, var(--surface-card));
|
||||||
|
}
|
||||||
|
.status.off,
|
||||||
|
.status.muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
padding: 0.12rem 0.4rem;
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.status.ok .tag {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: var(--surface-card);
|
||||||
|
}
|
||||||
|
.status.fail .tag {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--surface-card);
|
||||||
|
}
|
||||||
|
.full {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.captures {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.captures dl {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.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.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.cap dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
"apps": "Apps",
|
||||||
|
"eventTriggers": "Triggers",
|
||||||
|
"logScanRules": "Log Rules",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"deploy": "Deploy",
|
"deploy": "Deploy",
|
||||||
"proxies": "Proxies",
|
"proxies": "Proxies",
|
||||||
@@ -1301,5 +1304,228 @@
|
|||||||
"thresholds": "Thresholds",
|
"thresholds": "Thresholds",
|
||||||
"thresholdsDesc": "Tune when Tinyforge flags stale containers and warns about unused image disk usage.",
|
"thresholdsDesc": "Tune when Tinyforge flags stale containers and warns about unused image disk usage.",
|
||||||
"dangerZone": "Danger zone"
|
"dangerZone": "Danger zone"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"section": "Observability",
|
||||||
|
"manage": "manage",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"anyEvent": "any event",
|
||||||
|
"noUrlSet": "No URL configured",
|
||||||
|
"configured": "CONFIGURED",
|
||||||
|
"clear": "Clear",
|
||||||
|
"advanced": "Advanced",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save changes",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"open": "Open",
|
||||||
|
"edit": "Edit",
|
||||||
|
"back": "Back",
|
||||||
|
"regex": {
|
||||||
|
"sampleLabel": "Sample line",
|
||||||
|
"placeholder": "paste a representative log line here",
|
||||||
|
"promptType": "type a sample to test the pattern",
|
||||||
|
"noMatch": "NO MATCH",
|
||||||
|
"noMatchHint": "pattern did not match this line",
|
||||||
|
"match": "MATCH",
|
||||||
|
"invalid": "REGEX",
|
||||||
|
"captures": "Captures"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"title": "Event triggers",
|
||||||
|
"titleNew": "Forge a new trigger",
|
||||||
|
"titleSingular": "Trigger",
|
||||||
|
"lede": "Filter event-log entries (deploy events, log scanner output, future sources) and dispatch a webhook when they match. Filters AND together; empty filters mean \"match anything.\"",
|
||||||
|
"ledeNew": "Create a filter+action rule. The dispatcher AND-composes all filter fields. Leave a field empty to skip that dimension.",
|
||||||
|
"stat": {
|
||||||
|
"total": "TOTAL",
|
||||||
|
"enabled": "ENABLED",
|
||||||
|
"disabled": "DISABLED"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newButton": "New trigger",
|
||||||
|
"backToList": "Back to triggers"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"heading": "No triggers yet",
|
||||||
|
"body": "Configure a trigger to forward event-log entries to Slack, a notification bridge, or any HTTP receiver. Tinyforge signs requests with X-Hub-Signature-256 when a secret is set.",
|
||||||
|
"cta": "Create the first trigger"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"name": "Name",
|
||||||
|
"filters": "Filters",
|
||||||
|
"action": "Action",
|
||||||
|
"status": "Status",
|
||||||
|
"open": "Open"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"config": "Configuration",
|
||||||
|
"configSub": "id #{id} · updated {updatedAt}",
|
||||||
|
"dangerZone": "Danger zone",
|
||||||
|
"dangerZoneSub": "Trigger deletion is immediate. No soft-delete.",
|
||||||
|
"sendTest": "Send test",
|
||||||
|
"sending": "Sending…",
|
||||||
|
"testHttp": "HTTP {code}",
|
||||||
|
"testSigned": "signed",
|
||||||
|
"testOk": "OK",
|
||||||
|
"testFail": "FAIL",
|
||||||
|
"deleteButton": "Delete trigger",
|
||||||
|
"deleteTitle": "Delete trigger?",
|
||||||
|
"deleteMessage": "Trigger \"{name}\" will be removed immediately. This cannot be undone."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. Slack #alerts on deploy failure",
|
||||||
|
"required": "REQUIRED",
|
||||||
|
"andComposed": "AND-COMPOSED",
|
||||||
|
"filtersLabel": "Filters",
|
||||||
|
"actionLabel": "Action",
|
||||||
|
"actionWebhookBadge": "WEBHOOK",
|
||||||
|
"severityCsv": "Severity (CSV)",
|
||||||
|
"severityPlaceholder": "warn,error",
|
||||||
|
"sourceCsv": "Source (CSV)",
|
||||||
|
"sourcePlaceholder": "deploy,logscan",
|
||||||
|
"messageRegex": "Message regex (optional)",
|
||||||
|
"messageRegexPlaceholder": "(?i)\\bpanic\\b",
|
||||||
|
"invalidRegex": "Invalid regex — server will reject.",
|
||||||
|
"urlLabel": "URL",
|
||||||
|
"urlPlaceholder": "https://hooks.slack.com/services/...",
|
||||||
|
"secretLabel": "HMAC secret (optional)",
|
||||||
|
"secretPlaceholder": "leave blank for unsigned delivery",
|
||||||
|
"secretHint": "Receivers verify X-Hub-Signature-256 against the raw body.",
|
||||||
|
"secretRotateHint": "Stored encrypted at rest. The value is never returned by the API after creation — leave the placeholder untouched to preserve the existing secret, type a new value to rotate, or clear and save to remove signing.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"enabledHint": "Disabled triggers stay in the table but never dispatch.",
|
||||||
|
"submit": "Forge trigger",
|
||||||
|
"submitting": "Forging…",
|
||||||
|
"webhookUrl": "Webhook URL"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enabled": "enabled",
|
||||||
|
"disabled": "disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logscan": {
|
||||||
|
"title": "Log scan rules",
|
||||||
|
"titleNew": "Forge a new rule",
|
||||||
|
"titleSingular": "Rule",
|
||||||
|
"lede": "Regex patterns the scanner runs against every running container's log stream. Matched lines land in event_log with the rule's severity, where event triggers pick them up and fan out to operator-configured webhooks. {enabled} of {total} enabled.",
|
||||||
|
"ledeNew": "Tail container logs against a regex. Leave the workload field empty to create a global rule. To override an existing global for one workload, use the per-workload override action on the workload detail page.",
|
||||||
|
"stat": {
|
||||||
|
"total": "TOTAL",
|
||||||
|
"global": "GLOBAL",
|
||||||
|
"workload": "WORKLOAD",
|
||||||
|
"overrides": "OVERRIDES",
|
||||||
|
"activeTails": "ACTIVE TAILS",
|
||||||
|
"droppedBucket": "RATE-LIMITED",
|
||||||
|
"droppedCooldown": "COOLED DOWN",
|
||||||
|
"compileErrors": "COMPILE ERRORS"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"heading": "Scanner stats",
|
||||||
|
"headingSub": "Engine drop counters and last-snapshot compile errors. Counters reset on server restart.",
|
||||||
|
"noCompileErrors": "All rules compile cleanly.",
|
||||||
|
"compileErrorsHeading": "Compile errors (rule dropped from snapshot)",
|
||||||
|
"tailsExplain": "Per-container tail goroutines currently driven by the scanner manager."
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newButton": "New rule",
|
||||||
|
"backToList": "Back to rules"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "ALL",
|
||||||
|
"global": "GLOBAL",
|
||||||
|
"workload": "WORKLOAD",
|
||||||
|
"overrides": "OVERRIDES"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"heading": "No rules yet",
|
||||||
|
"body": "Start with a global rule like (?i)\\bpanic\\b at severity error, then narrow per-workload via overrides on the workload detail page.",
|
||||||
|
"cta": "Create the first rule"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"name": "Name",
|
||||||
|
"pattern": "Pattern",
|
||||||
|
"scope": "Scope",
|
||||||
|
"severity": "Severity",
|
||||||
|
"streams": "Streams",
|
||||||
|
"status": "Status",
|
||||||
|
"open": "Open"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"config": "Configuration",
|
||||||
|
"configSub": "id #{id} · scope {scope}",
|
||||||
|
"regexTest": "Regex test",
|
||||||
|
"regexTestSub": "Live preview uses the browser's JavaScript regex engine. Click \"Run server test\" to evaluate against Go's RE2 — authoritative and the only reliable signal for RE2-only constructs.",
|
||||||
|
"runServerTest": "Run server test",
|
||||||
|
"testing": "Testing…",
|
||||||
|
"serverTestHint": "Enter a sample line above first",
|
||||||
|
"serverTestSendHint": "Send sample to backend /test endpoint",
|
||||||
|
"serverMatch": "SERVER MATCH",
|
||||||
|
"serverNoMatch": "NO MATCH",
|
||||||
|
"serverNoMatchHint": "server regex did not match the sample",
|
||||||
|
"serverError": "ERROR",
|
||||||
|
"dangerZone": "Danger zone",
|
||||||
|
"dangerZoneSub": "Deleting a global rule cascade-removes its per-workload overrides.",
|
||||||
|
"deleteButton": "Delete rule",
|
||||||
|
"deleteTitle": "Delete rule?",
|
||||||
|
"deleteMessage": "Rule \"{name}\" will be removed immediately. Per-workload overrides referencing it will be cascade-deleted."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. Panic in worker",
|
||||||
|
"pattern": "Pattern",
|
||||||
|
"regex": "REGEX",
|
||||||
|
"patternPlaceholder": "(?i)\\bpanic\\b",
|
||||||
|
"invalidRegex": "Invalid regex — server will reject.",
|
||||||
|
"matchShape": "Match shape",
|
||||||
|
"matchShapeOpts": "SEVERITY · STREAMS · COOLDOWN",
|
||||||
|
"severity": "Severity",
|
||||||
|
"streams": "Streams",
|
||||||
|
"cooldown": "Cooldown (s)",
|
||||||
|
"cooldownHint": "Cooldown is per-rule per-container — the same rule firing on two containers stays independent. Token bucket caps per-container emissions at 10 events / 60s to prevent flooding event_log.",
|
||||||
|
"scope": "Scope",
|
||||||
|
"scopePlaceholder": "empty for global rule, or paste a workload id",
|
||||||
|
"scopeHint": "Workload-scoped rules apply only to that workload's containers. Per-workload overrides are easier to create from the workload detail page.",
|
||||||
|
"scopeGlobal": "Global (applies to every workload)",
|
||||||
|
"scopePick": "Pick workload…",
|
||||||
|
"scopePickTitle": "Pick a workload",
|
||||||
|
"scopeClear": "Make global",
|
||||||
|
"scopeSelected": "Workload",
|
||||||
|
"scopeUnknown": "Unknown workload",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"enabledHint": "Disabled rules stay in the table but never fire.",
|
||||||
|
"required": "REQUIRED",
|
||||||
|
"optional": "OPTIONAL",
|
||||||
|
"submit": "Forge rule",
|
||||||
|
"submitting": "Forging…"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "global",
|
||||||
|
"workload": "workload {id}",
|
||||||
|
"override": "override of #{id}",
|
||||||
|
"overrideShort": "override #{id}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enabled": "enabled",
|
||||||
|
"disabled": "disabled",
|
||||||
|
"on": "on",
|
||||||
|
"off": "off"
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"heading": "Log rules",
|
||||||
|
"subEmpty": "No effective rules for this workload",
|
||||||
|
"subCount": "{count} effective rules",
|
||||||
|
"subCountOne": "1 effective rule",
|
||||||
|
"emptyHint": "This workload has no log scan rules applied. Create one via New rule — globals apply automatically; this workload can also have its own narrower rules or overrides.",
|
||||||
|
"newRule": "New rule",
|
||||||
|
"footerHint": "Global rules apply to every workload. Workload rules apply only here. Override rows substitute for a global on this workload — edit them to disable or change severity per-workload without touching the global.",
|
||||||
|
"override": "Override",
|
||||||
|
"overriding": "Overriding…",
|
||||||
|
"overrideTitle": "Create a per-workload override of this global rule"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Панель",
|
"dashboard": "Панель",
|
||||||
|
"apps": "Приложения",
|
||||||
|
"eventTriggers": "Триггеры",
|
||||||
|
"logScanRules": "Лог-правила",
|
||||||
"projects": "Проекты",
|
"projects": "Проекты",
|
||||||
"deploy": "Деплой",
|
"deploy": "Деплой",
|
||||||
"proxies": "Прокси",
|
"proxies": "Прокси",
|
||||||
@@ -1301,5 +1304,228 @@
|
|||||||
"thresholds": "Пороги",
|
"thresholds": "Пороги",
|
||||||
"thresholdsDesc": "Настройте, когда Tinyforge помечает контейнеры как устаревшие и предупреждает о неиспользуемых образах.",
|
"thresholdsDesc": "Настройте, когда Tinyforge помечает контейнеры как устаревшие и предупреждает о неиспользуемых образах.",
|
||||||
"dangerZone": "Опасная зона"
|
"dangerZone": "Опасная зона"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"section": "Наблюдаемость",
|
||||||
|
"manage": "управление",
|
||||||
|
"loading": "Загрузка…",
|
||||||
|
"anyEvent": "любое событие",
|
||||||
|
"noUrlSet": "URL не настроен",
|
||||||
|
"configured": "НАСТРОЕН",
|
||||||
|
"clear": "Очистить",
|
||||||
|
"advanced": "Расширенно",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saving": "Сохранение…",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"deleting": "Удаление…",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"open": "Открыть",
|
||||||
|
"edit": "Изменить",
|
||||||
|
"back": "Назад",
|
||||||
|
"regex": {
|
||||||
|
"sampleLabel": "Пример строки",
|
||||||
|
"placeholder": "вставьте сюда характерную строку лога",
|
||||||
|
"promptType": "введите образец для проверки шаблона",
|
||||||
|
"noMatch": "НЕТ СОВПАДЕНИЯ",
|
||||||
|
"noMatchHint": "шаблон не совпал с этой строкой",
|
||||||
|
"match": "СОВПАЛО",
|
||||||
|
"invalid": "REGEX",
|
||||||
|
"captures": "Группы"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"title": "Триггеры событий",
|
||||||
|
"titleNew": "Новый триггер",
|
||||||
|
"titleSingular": "Триггер",
|
||||||
|
"lede": "Фильтруйте записи журнала событий (события деплоев, вывод сканера логов, будущие источники) и отправляйте webhook при совпадении. Фильтры объединяются по И; пустой фильтр означает «совпадает всё».",
|
||||||
|
"ledeNew": "Создайте правило «фильтр + действие». Диспетчер объединяет фильтры по И. Оставьте поле пустым, чтобы пропустить это измерение.",
|
||||||
|
"stat": {
|
||||||
|
"total": "ВСЕГО",
|
||||||
|
"enabled": "ВКЛЮЧЕНО",
|
||||||
|
"disabled": "ВЫКЛЮЧЕНО"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newButton": "Новый триггер",
|
||||||
|
"backToList": "К списку триггеров"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"heading": "Триггеров пока нет",
|
||||||
|
"body": "Настройте триггер, чтобы пересылать записи журнала событий в Slack, мост уведомлений или любой HTTP-приёмник. Tinyforge подписывает запросы заголовком X-Hub-Signature-256, если задан секрет.",
|
||||||
|
"cta": "Создать первый триггер"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"name": "Имя",
|
||||||
|
"filters": "Фильтры",
|
||||||
|
"action": "Действие",
|
||||||
|
"status": "Статус",
|
||||||
|
"open": "Открыть"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"config": "Конфигурация",
|
||||||
|
"configSub": "id #{id} · обновлено {updatedAt}",
|
||||||
|
"dangerZone": "Опасная зона",
|
||||||
|
"dangerZoneSub": "Удаление триггера происходит сразу. Восстановления нет.",
|
||||||
|
"sendTest": "Отправить тест",
|
||||||
|
"sending": "Отправка…",
|
||||||
|
"testHttp": "HTTP {code}",
|
||||||
|
"testSigned": "подписано",
|
||||||
|
"testOk": "OK",
|
||||||
|
"testFail": "ОШИБКА",
|
||||||
|
"deleteButton": "Удалить триггер",
|
||||||
|
"deleteTitle": "Удалить триггер?",
|
||||||
|
"deleteMessage": "Триггер «{name}» будет удалён немедленно. Действие необратимо."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Имя",
|
||||||
|
"namePlaceholder": "например, Slack #alerts при сбое деплоя",
|
||||||
|
"required": "ОБЯЗАТЕЛЬНО",
|
||||||
|
"andComposed": "ОБЪЕДИНЕНИЕ ПО И",
|
||||||
|
"filtersLabel": "Фильтры",
|
||||||
|
"actionLabel": "Действие",
|
||||||
|
"actionWebhookBadge": "WEBHOOK",
|
||||||
|
"severityCsv": "Уровень (CSV)",
|
||||||
|
"severityPlaceholder": "warn,error",
|
||||||
|
"sourceCsv": "Источник (CSV)",
|
||||||
|
"sourcePlaceholder": "deploy,logscan",
|
||||||
|
"messageRegex": "Регулярное выражение сообщения (необязательно)",
|
||||||
|
"messageRegexPlaceholder": "(?i)\\bpanic\\b",
|
||||||
|
"invalidRegex": "Некорректный regex — сервер отклонит.",
|
||||||
|
"urlLabel": "URL",
|
||||||
|
"urlPlaceholder": "https://hooks.slack.com/services/...",
|
||||||
|
"secretLabel": "HMAC-секрет (необязательно)",
|
||||||
|
"secretPlaceholder": "оставьте пустым для неподписанной доставки",
|
||||||
|
"secretHint": "Приёмники проверяют X-Hub-Signature-256 по сырому телу запроса.",
|
||||||
|
"secretRotateHint": "Хранится в зашифрованном виде. После создания API не возвращает значение — оставьте плейсхолдер без изменений, чтобы сохранить существующий секрет, введите новое значение для смены или очистите и сохраните, чтобы отключить подпись.",
|
||||||
|
"enabled": "Включён",
|
||||||
|
"enabledHint": "Выключенные триггеры остаются в таблице, но не срабатывают.",
|
||||||
|
"submit": "Создать триггер",
|
||||||
|
"submitting": "Создание…",
|
||||||
|
"webhookUrl": "URL webhook"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enabled": "включён",
|
||||||
|
"disabled": "выключен"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logscan": {
|
||||||
|
"title": "Правила сканирования логов",
|
||||||
|
"titleNew": "Новое правило",
|
||||||
|
"titleSingular": "Правило",
|
||||||
|
"lede": "Регулярные выражения, которые сканер применяет к потоку логов каждого работающего контейнера. Совпавшие строки попадают в event_log с уровнем правила, откуда триггеры событий передают их на настроенные webhook-приёмники. Включено {enabled} из {total}.",
|
||||||
|
"ledeNew": "Сканируйте логи контейнеров по регулярному выражению. Оставьте поле «нагрузка» пустым, чтобы создать глобальное правило. Чтобы переопределить глобальное для одной нагрузки, используйте действие «Переопределить» на странице нагрузки.",
|
||||||
|
"stat": {
|
||||||
|
"total": "ВСЕГО",
|
||||||
|
"global": "ГЛОБАЛЬНЫЕ",
|
||||||
|
"workload": "НАГРУЗКА",
|
||||||
|
"overrides": "ПЕРЕОПРЕДЕЛЕНИЯ",
|
||||||
|
"activeTails": "АКТИВНЫХ TAIL",
|
||||||
|
"droppedBucket": "ЛИМИТ",
|
||||||
|
"droppedCooldown": "COOLDOWN",
|
||||||
|
"compileErrors": "ОШИБКИ КОМПИЛЯЦИИ"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"heading": "Статистика сканера",
|
||||||
|
"headingSub": "Счётчики отбрасываний движка и ошибки компиляции из последнего снимка. Счётчики сбрасываются при перезапуске сервера.",
|
||||||
|
"noCompileErrors": "Все правила компилируются без ошибок.",
|
||||||
|
"compileErrorsHeading": "Ошибки компиляции (правило отброшено из снимка)",
|
||||||
|
"tailsExplain": "Сейчас открыто goroutine-tail'ов по контейнерам у менеджера сканера."
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newButton": "Новое правило",
|
||||||
|
"backToList": "К списку правил"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "ВСЕ",
|
||||||
|
"global": "ГЛОБАЛЬНЫЕ",
|
||||||
|
"workload": "НАГРУЗКА",
|
||||||
|
"overrides": "ПЕРЕОПРЕДЕЛЕНИЯ"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"heading": "Правил пока нет",
|
||||||
|
"body": "Начните с глобального правила вроде (?i)\\bpanic\\b с уровнем error, затем сужайте по нагрузкам через переопределения на странице нагрузки.",
|
||||||
|
"cta": "Создать первое правило"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"name": "Имя",
|
||||||
|
"pattern": "Шаблон",
|
||||||
|
"scope": "Область",
|
||||||
|
"severity": "Уровень",
|
||||||
|
"streams": "Потоки",
|
||||||
|
"status": "Статус",
|
||||||
|
"open": "Открыть"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"config": "Конфигурация",
|
||||||
|
"configSub": "id #{id} · область {scope}",
|
||||||
|
"regexTest": "Проверка regex",
|
||||||
|
"regexTestSub": "Предпросмотр использует JavaScript-движок regex в браузере. Нажмите «Проверить на сервере», чтобы получить авторитетную проверку Go RE2 — это единственный надёжный сигнал для конструкций, специфичных для RE2.",
|
||||||
|
"runServerTest": "Проверить на сервере",
|
||||||
|
"testing": "Проверка…",
|
||||||
|
"serverTestHint": "Сначала введите пример строки выше",
|
||||||
|
"serverTestSendHint": "Отправить пример на backend /test",
|
||||||
|
"serverMatch": "СОВПАЛО (СЕРВЕР)",
|
||||||
|
"serverNoMatch": "НЕТ СОВПАДЕНИЯ",
|
||||||
|
"serverNoMatchHint": "серверный regex не совпал с примером",
|
||||||
|
"serverError": "ОШИБКА",
|
||||||
|
"dangerZone": "Опасная зона",
|
||||||
|
"dangerZoneSub": "Удаление глобального правила каскадно удаляет его переопределения для нагрузок.",
|
||||||
|
"deleteButton": "Удалить правило",
|
||||||
|
"deleteTitle": "Удалить правило?",
|
||||||
|
"deleteMessage": "Правило «{name}» будет удалено немедленно. Переопределения по нагрузкам, ссылающиеся на него, также удалятся."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Имя",
|
||||||
|
"namePlaceholder": "например, Panic в воркере",
|
||||||
|
"pattern": "Шаблон",
|
||||||
|
"regex": "REGEX",
|
||||||
|
"patternPlaceholder": "(?i)\\bpanic\\b",
|
||||||
|
"invalidRegex": "Некорректный regex — сервер отклонит.",
|
||||||
|
"matchShape": "Параметры совпадения",
|
||||||
|
"matchShapeOpts": "УРОВЕНЬ · ПОТОКИ · COOLDOWN",
|
||||||
|
"severity": "Уровень",
|
||||||
|
"streams": "Потоки",
|
||||||
|
"cooldown": "Cooldown (с)",
|
||||||
|
"cooldownHint": "Cooldown — на правило × на контейнер: одно правило, срабатывающее в двух контейнерах, считается независимо. Token bucket ограничивает выдачу на контейнер до 10 событий / 60с, чтобы не переполнить event_log.",
|
||||||
|
"scope": "Область",
|
||||||
|
"scopePlaceholder": "пусто для глобального правила или вставьте id нагрузки",
|
||||||
|
"scopeHint": "Правила области нагрузки применяются только к её контейнерам. Переопределения для отдельных нагрузок проще создавать со страницы нагрузки.",
|
||||||
|
"scopeGlobal": "Глобально (применяется ко всем нагрузкам)",
|
||||||
|
"scopePick": "Выбрать нагрузку…",
|
||||||
|
"scopePickTitle": "Выберите нагрузку",
|
||||||
|
"scopeClear": "Сделать глобальным",
|
||||||
|
"scopeSelected": "Нагрузка",
|
||||||
|
"scopeUnknown": "Неизвестная нагрузка",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"enabledHint": "Выключенные правила остаются в таблице, но не срабатывают.",
|
||||||
|
"required": "ОБЯЗАТЕЛЬНО",
|
||||||
|
"optional": "НЕОБЯЗАТЕЛЬНО",
|
||||||
|
"submit": "Создать правило",
|
||||||
|
"submitting": "Создание…"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "глобальное",
|
||||||
|
"workload": "нагрузка {id}",
|
||||||
|
"override": "переопределение #{id}",
|
||||||
|
"overrideShort": "переопр. #{id}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enabled": "включено",
|
||||||
|
"disabled": "выключено",
|
||||||
|
"on": "вкл",
|
||||||
|
"off": "выкл"
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"heading": "Лог-правила",
|
||||||
|
"subEmpty": "Для этой нагрузки правил нет",
|
||||||
|
"subCount": "Действует правил: {count}",
|
||||||
|
"subCountOne": "Действует 1 правило",
|
||||||
|
"emptyHint": "Для этой нагрузки нет правил сканирования логов. Создайте через «Новое правило» — глобальные правила применяются автоматически; для этой нагрузки также можно завести свои или переопределения.",
|
||||||
|
"newRule": "Новое правило",
|
||||||
|
"footerHint": "Глобальные правила применяются ко всем нагрузкам. Правила нагрузки — только здесь. Переопределения замещают глобальное для этой нагрузки — изменяйте уровень или отключайте их, не трогая исходное глобальное.",
|
||||||
|
"override": "Переопределить",
|
||||||
|
"overriding": "Переопределение…",
|
||||||
|
"overrideTitle": "Создать переопределение глобального правила для этой нагрузки"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,11 @@
|
|||||||
countKey?: NavCountKey;
|
countKey?: NavCountKey;
|
||||||
/** When true the badge uses a danger style (red). */
|
/** When true the badge uses a danger style (red). */
|
||||||
alert?: boolean;
|
alert?: boolean;
|
||||||
|
/** Static label override when the i18n catalogue does not yet carry the key. */
|
||||||
|
labelOverride?: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||||
|
{ href: '/apps', labelKey: 'nav.apps', icon: 'box' },
|
||||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
||||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
||||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
||||||
@@ -41,6 +44,8 @@
|
|||||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
{ 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' }
|
{ 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" />
|
<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'}
|
{: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" />
|
<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'}
|
{: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" />
|
<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'}
|
{:else if item.icon === 'stacks'}
|
||||||
@@ -293,7 +300,7 @@
|
|||||||
{:else if item.icon === 'settings'}
|
{: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" />
|
<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}
|
{/if}
|
||||||
<span class="nav-label">{$t(item.labelKey)}</span>
|
<span class="nav-label">{item.labelOverride ?? $t(item.labelKey)}</span>
|
||||||
|
|
||||||
{#if item.countKey}
|
{#if item.countKey}
|
||||||
{@const count = $navCounts[item.countKey]}
|
{@const count = $navCounts[item.countKey]}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user