Files
tiny-forge/web/src/routes/triggers/+page.svelte
T
alexei.dolgolyov 2aff22f565
Build / build (push) Successful in 10m39s
feat(triggers): first-class triggers + bindings with fan-out webhook
Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).

Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
  backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged

Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
  apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated

Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
  the static-source inline port + hard legacy cutover (Priority 1)
2026-05-16 02:24:31 +03:00

650 lines
17 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { RedeployTrigger } from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
// Known kinds drive the kind-aware form switch in /new and the
// filter chips here. Future kinds are tolerated: an unknown kind
// renders with a generic label + grey badge instead of dropping
// the row.
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
let triggers = $state<RedeployTrigger[]>([]);
let loading = $state(true);
let error = $state('');
let kindFilter = $state<'all' | KnownKind | string>('all');
const filtered = $derived(
kindFilter === 'all' ? triggers : triggers.filter((t) => t.kind === kindFilter)
);
const withWebhook = $derived(triggers.filter((t) => t.webhook_enabled).length);
const totalBindings = $derived(
triggers.reduce((sum, t) => sum + (t.binding_count ?? 0), 0)
);
// Group triggers by kind for the hero stat rail. Caps to first
// three kinds + a roll-up so the rail stays single-line on
// narrow screens; the chip filter row exposes the full breakdown.
const byKind = $derived.by(() => {
const acc: Record<string, number> = {};
for (const t of triggers) acc[t.kind] = (acc[t.kind] ?? 0) + 1;
return acc;
});
const presentKinds = $derived(Object.keys(byKind));
function kindLabel(k: string): string {
const key = `redeployTriggers.kind.${k}`;
const label = $t(key);
return label === key ? k : label;
}
function kindShort(k: string): string {
const key = `redeployTriggers.kindShort.${k}`;
const label = $t(key);
return label === key ? k.slice(0, 3).toUpperCase() : label;
}
function kindClass(k: string): string {
// CSS-only kind colour. Falls through to the neutral
// `kind-other` style for unknown kinds so the row still
// renders cleanly.
return KNOWN_KINDS.includes(k as KnownKind) ? `kind-${k}` : 'kind-other';
}
function fmtCreated(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit'
});
} catch {
return iso;
}
}
async function load(): Promise<void> {
loading = true;
error = '';
try {
triggers = await api.listTriggers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load triggers';
} finally {
loading = false;
}
}
onMount(load);
</script>
<svelte:head>
<title>{$t('redeployTriggers.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="/triggers/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('redeployTriggers.toolbar.newButton')}</span>
</a>
{/snippet}
{#snippet stats()}
<div>
<dt>{$t('redeployTriggers.stat.total')}</dt>
<dd>{loading ? '—' : String(triggers.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('redeployTriggers.stat.withWebhook')}</dt>
<dd class="accent">{loading ? '—' : String(withWebhook).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('redeployTriggers.stat.boundWorkloads')}</dt>
<dd>{loading ? '—' : String(totalBindings).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet lede()}
{$t('redeployTriggers.lede')}
{/snippet}
<ForgeHero
eyebrowSuffix={$t('redeployTriggers.section').toUpperCase()}
title={$t('redeployTriggers.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 && triggers.length > 0}
<!-- Kind filter chips. ALL is always present; per-kind chips are
rendered only for kinds present in the result set so the
row stays scannable when the operator only uses two kinds. -->
<div class="filter-row" role="group" aria-label={$t('redeployTriggers.filter.ariaLabel')}>
<button
type="button"
class="chip"
class:active={kindFilter === 'all'}
aria-pressed={kindFilter === 'all'}
onclick={() => (kindFilter = 'all')}
>
<span class="chip-label">{$t('redeployTriggers.filter.all')}</span>
<span class="chip-count">{String(triggers.length).padStart(2, '0')}</span>
</button>
{#each presentKinds as k}
<button
type="button"
class="chip"
class:active={kindFilter === k}
aria-pressed={kindFilter === k}
onclick={() => (kindFilter = k)}
>
<span class="chip-label">{kindLabel(k)}</span>
<span class="chip-count">{String(byKind[k]).padStart(2, '0')}</span>
</button>
{/each}
</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('redeployTriggers.empty.heading')}</h2>
<p>{$t('redeployTriggers.empty.body')}</p>
<a href="/triggers/new" class="forge-btn">
<IconPlus size={14} /><span>{$t('redeployTriggers.empty.cta')}</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('redeployTriggers.list.name')}</th>
<th>{$t('redeployTriggers.list.kind')}</th>
<th>{$t('redeployTriggers.list.bindings')}</th>
<th>{$t('redeployTriggers.list.webhook')}</th>
<th class="hide-md">{$t('redeployTriggers.list.created')}</th>
<th class="t-right">{$t('redeployTriggers.list.open')}</th>
</tr>
</thead>
<tbody>
{#each filtered as trig, i (trig.id)}
<tr>
<td>
<a class="row-link" href={`/triggers/${trig.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{trig.name}</span>
</a>
</td>
<td>
<span class="badge kind {kindClass(trig.kind)}">
<span class="kind-tag">{kindShort(trig.kind)}</span>
<span class="kind-name">{kindLabel(trig.kind)}</span>
</span>
</td>
<td>
{#if trig.binding_count > 0}
<span class="bindings-pill" title={String(trig.binding_count)}>
<span class="bp-num">{trig.binding_count}</span>
<span class="bp-bar" aria-hidden="true">
{#each Array(Math.min(trig.binding_count, 6)) as _}
<span></span>
{/each}
</span>
</span>
{:else}
<span class="muted mono small">{$t('redeployTriggers.list.noBindings')}</span>
{/if}
</td>
<td>
<span class="status" class:on={trig.webhook_enabled} class:off={!trig.webhook_enabled}>
<span class="status-dot" aria-hidden="true"></span>
{trig.webhook_enabled
? $t('redeployTriggers.list.webhookOn')
: $t('redeployTriggers.list.webhookOff')}
</span>
</td>
<td class="muted mono small hide-md">{fmtCreated(trig.created_at)}</td>
<td class="actions-cell">
<a class="row-action" href={`/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));
}
/* ── 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;
text-transform: uppercase;
}
.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;
}
.actions-cell {
text-align: right;
}
@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);
}
.arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
/* ── Kind badge ─────────────────────────────────
Two-segment pill: a tight monospace tag (REG, GIT…)
followed by the human-readable kind name. The tag
carries the colour so the eye can pick out the kind
even when the operator filters all rows down to two
kinds and the names line up.
*/
.badge.kind {
display: inline-flex;
align-items: stretch;
overflow: hidden;
border-radius: var(--radius-full);
border: 1px solid var(--border-primary);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.kind-tag {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.5rem;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--surface-card);
background: var(--text-primary);
}
.kind-name {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem 0.18rem 0.5rem;
}
/* Per-kind colour. The pattern matches the rest of the app:
coloured tag + soft-tinted body that reads in both themes. */
.badge.kind.kind-registry .kind-tag {
background: var(--color-brand-600);
}
.badge.kind.kind-registry {
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.kind.kind-git .kind-tag {
background: #6b46c1;
}
.badge.kind.kind-git {
background: color-mix(in srgb, #8b5cf6 12%, transparent);
border-color: color-mix(in srgb, #8b5cf6 32%, transparent);
color: #6b46c1;
}
:global([data-theme='dark']) .badge.kind.kind-git {
color: #c4b5fd;
}
.badge.kind.kind-manual .kind-tag {
background: var(--text-secondary);
}
.badge.kind.kind-schedule .kind-tag {
background: var(--color-warning, #f59e0b);
}
.badge.kind.kind-schedule {
background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent);
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
color: var(--color-warning-dark, #b45309);
}
.badge.kind.kind-webhook .kind-tag {
background: var(--forge-accent);
}
.badge.kind.kind-webhook {
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
color: var(--forge-accent);
}
.badge.kind.kind-logscan .kind-tag {
background: #0891b2;
}
.badge.kind.kind-logscan {
background: color-mix(in srgb, #06b6d4 12%, transparent);
border-color: color-mix(in srgb, #06b6d4 32%, transparent);
color: #0e7490;
}
:global([data-theme='dark']) .badge.kind.kind-logscan {
color: #67e8f9;
}
/* ── Bindings pill ──────────────────────────────
Number + miniature segmented bar that visually
communicates fan-out without taking a whole column.
*/
.bindings-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.18rem 0.55rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
}
.bp-num {
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.bp-bar {
display: inline-flex;
gap: 2px;
}
.bp-bar span {
width: 4px;
height: 10px;
background: var(--forge-accent);
border-radius: 1px;
opacity: 0.85;
}
.bp-bar span:nth-child(2) { opacity: 0.7; }
.bp-bar span:nth-child(3) { opacity: 0.6; }
.bp-bar span:nth-child(4) { opacity: 0.5; }
.bp-bar span:nth-child(5) { opacity: 0.4; }
.bp-bar span:nth-child(6) { opacity: 0.3; }
.small {
font-size: 0.72rem;
}
/* ── Status (webhook on/off) ──────────────────── */
.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>