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 });
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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": {
|
||||
"dashboard": "Dashboard",
|
||||
"apps": "Apps",
|
||||
"eventTriggers": "Triggers",
|
||||
"logScanRules": "Log Rules",
|
||||
"projects": "Projects",
|
||||
"deploy": "Deploy",
|
||||
"proxies": "Proxies",
|
||||
@@ -1301,5 +1304,228 @@
|
||||
"thresholds": "Thresholds",
|
||||
"thresholdsDesc": "Tune when Tinyforge flags stale containers and warns about unused image disk usage.",
|
||||
"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": {
|
||||
"dashboard": "Панель",
|
||||
"apps": "Приложения",
|
||||
"eventTriggers": "Триггеры",
|
||||
"logScanRules": "Лог-правила",
|
||||
"projects": "Проекты",
|
||||
"deploy": "Деплой",
|
||||
"proxies": "Прокси",
|
||||
@@ -1301,5 +1304,228 @@
|
||||
"thresholds": "Пороги",
|
||||
"thresholdsDesc": "Настройте, когда Tinyforge помечает контейнеры как устаревшие и предупреждает о неиспользуемых образах.",
|
||||
"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": "Создать переопределение глобального правила для этой нагрузки"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user