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

Operator-facing surfaces for the two backend features:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:18:29 +03:00
parent 7a9ff7ad54
commit 4707db1c3b
11 changed files with 4455 additions and 1 deletions
+272
View File
@@ -1078,6 +1078,141 @@ export function setWorkloadAppID(id: string, appID: string): Promise<Workload> {
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) ───────────────────────────────────────
export interface ListContainersFilter {
@@ -1121,4 +1256,141 @@ export function deleteApp(id: string): Promise<void> {
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 };