feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s

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)
This commit is contained in:
2026-05-16 02:24:31 +03:00
parent 30133bc1eb
commit 2aff22f565
21 changed files with 7445 additions and 460 deletions
+2 -1
View File
@@ -44,7 +44,8 @@
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Triggers' },
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' },
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
];
File diff suppressed because it is too large Load Diff
+305 -117
View File
@@ -2,19 +2,43 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { HookKinds, PluginWorkloadInput } from '$lib/types';
import type { RedeployTrigger } from '$lib/api';
import * as api from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
buildTriggerInput
} from '$lib/components/TriggerKindForm.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// `triggers` is no longer hardcoded into the workload row — the
// kind list is kept on `kinds` only for reference but the wizard
// now composes a standalone Trigger record (or picks one) and
// binds it after the workload is created.
let kinds = $state<HookKinds>({ sources: [], triggers: [] });
let name = $state('');
let sourceKind = $state('image');
let triggerKind = $state('manual');
let sourceConfig = $state('{}');
let triggerConfig = $state('{}');
let publicSubdomain = $state('');
let publicDomain = $state('');
let publicPort = $state(0);
// Trigger UX modes — three branches:
// inline → create a new trigger inline; bind after workload create.
// pick → select an existing trigger; bind after workload create.
// skip → create the workload without any binding.
type TriggerMode = 'inline' | 'pick' | 'skip';
let triggerMode = $state<TriggerMode>('inline');
let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
// Existing-trigger picker. `existingTriggers` is loaded lazily on
// mount; if the request fails the operator can still create one
// inline or skip altogether — we don't block the wizard.
let existingTriggers = $state<RedeployTrigger[]>([]);
let pickedTriggerId = $state('');
// Kind-aware compose editor — the raw-JSON textarea forces users to
// hand-escape YAML inside a JSON string, which is unusable. When the
// source plugin is "compose" we surface a dedicated YAML textarea and
@@ -79,7 +103,7 @@
schemaCache.set(kind, text);
return text;
} catch {
return sourceConfigSample(kind) || triggerConfigSample(kind) || '{}';
return sourceConfigSample(kind) || '{}';
}
}
@@ -274,11 +298,7 @@
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
sourceKind = kinds.sources[0];
}
if (kinds.triggers.length > 0 && !kinds.triggers.includes(triggerKind)) {
triggerKind = kinds.triggers[0];
}
sourceConfig = await fetchSampleJSON(sourceKind);
triggerConfig = await fetchSampleJSON(triggerKind);
if (sourceKind === 'compose') {
seedComposeFromJSON(sourceConfig);
}
@@ -296,6 +316,14 @@
} catch {
registries = [];
}
// Best-effort fetch of existing triggers — feeds the
// "Pick existing" mode. Failure leaves the picker empty
// and the operator can still create one inline.
try {
existingTriggers = await api.listTriggers();
} catch {
existingTriggers = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
} finally {
@@ -350,26 +378,6 @@
}
}
function triggerConfigSample(kind: string): string {
switch (kind) {
case 'registry':
return JSON.stringify(
{ image: 'registry.example.com/owner/app', tag_pattern: 'v*' },
null,
2
);
case 'git':
return JSON.stringify(
{ repo: 'owner/repo', mode: 'push', branch: 'main', tag_pattern: '' },
null,
2
);
case 'manual':
default:
return '{}';
}
}
async function onSourceChange() {
sourceConfig = await fetchSampleJSON(sourceKind);
// Switching INTO compose / image seeds the form fields from
@@ -389,9 +397,6 @@
advancedJson = false;
}
}
async function onTriggerChange() {
triggerConfig = await fetchSampleJSON(triggerKind);
}
// Toggle between the kind-aware form and the raw JSON editor.
// Direction matters: going to Advanced JSON commits the form fields
@@ -423,12 +428,17 @@
}
}
const sourceValid = $derived(jsonOk(sourceConfig));
const triggerValid = $derived(jsonOk(triggerConfig));
const sourceLines = $derived(sourceConfig.split('\n').length);
const sourceBytes = $derived(new Blob([sourceConfig]).size);
const triggerLines = $derived(triggerConfig.split('\n').length);
const triggerBytes = $derived(new Blob([triggerConfig]).size);
// Trigger-step validity. Inline mode requires a complete kind+name+config;
// pick mode requires a chosen trigger; skip mode is always valid.
const triggerStepValid = $derived.by(() => {
if (triggerMode === 'skip') return true;
if (triggerMode === 'pick') return !!pickedTriggerId;
return isTriggerFormValid(triggerForm);
});
async function submit(e: Event) {
e.preventDefault();
@@ -436,7 +446,6 @@
submitting = true;
try {
let parsedSource: unknown;
let parsedTrigger: unknown;
if (useComposeForm) {
// Form fields are typed primitives — no parse step
// needed. compose_yaml passes through verbatim; the
@@ -459,18 +468,17 @@
throw new Error('source_config is not valid JSON');
}
}
try {
parsedTrigger = JSON.parse(triggerConfig);
} catch {
throw new Error('trigger_config is not valid JSON');
}
// Triggers no longer ride on the workload row; the backend
// still accepts the legacy fields but the new code path
// passes a manual placeholder + empty config and binds a
// real Trigger record after creation.
const body: PluginWorkloadInput = {
name: name.trim(),
source_kind: sourceKind,
source_config: parsedSource,
trigger_kind: triggerKind,
trigger_config: parsedTrigger
trigger_kind: '',
trigger_config: {}
};
if (publicSubdomain || publicDomain || publicPort > 0) {
body.public_faces = [
@@ -486,6 +494,35 @@
}
const created = await api.createPluginWorkload(body);
// Bind a trigger to the freshly-created workload. Keep going
// to the detail page even on bind failure — the operator can
// retry from the workload's Triggers panel without losing
// their work.
if (triggerMode === 'inline' || triggerMode === 'pick') {
try {
if (triggerMode === 'inline') {
const inline = buildTriggerInput(triggerForm);
await api.bindTriggerToWorkload(created.id, { inline });
} else if (pickedTriggerId) {
await api.bindTriggerToWorkload(created.id, {
trigger_id: pickedTriggerId
});
}
} catch (be) {
const msg = be instanceof Error ? be.message : 'unknown';
// Surface the bind failure to the user, then still
// route to the detail page where they can retry.
try {
sessionStorage.setItem(
`tinyforge.bindError.${created.id}`,
$t('apps.new.triggers.bindError', { error: msg })
);
} catch {
// session storage may be disabled — ignore.
}
}
}
goto(`/apps/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
@@ -502,8 +539,7 @@
<div class="forge">
{#snippet newLede()}
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
<em>Trigger</em> = when it redeploys (registry push, git push, manual). Both axes are
independently extensible.
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
{/snippet}
<ForgeHero
@@ -553,39 +589,25 @@
<div class="field">
<div class="field-label">
<span class="num">02</span>
<span class="lbl">Plugins</span>
<span class="opt">SOURCE × TRIGGER</span>
</div>
<div class="row">
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<select
id="app-source"
class="input"
bind:value={sourceKind}
onchange={onSourceChange}
>
{#each kinds.sources as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<label class="sub" for="app-trigger">
<span class="sub-label">Trigger</span>
<select
id="app-trigger"
class="input"
bind:value={triggerKind}
onchange={onTriggerChange}
>
{#each kinds.triggers as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<span class="lbl">Source plugin</span>
<span class="opt">REQUIRED</span>
</div>
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<select
id="app-source"
class="input"
bind:value={sourceKind}
onchange={onSourceChange}
>
{#each kinds.sources as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<p class="hint">
Both pickers are populated from the running daemon — only plugins compiled in show up.
Populated from the running daemon — only plugins compiled in show up. Triggers
(registry / git / manual) are configured below as standalone records.
</p>
</div>
@@ -921,10 +943,10 @@
</span>
</label>
</fieldset>
<label class="checkbox-row">
<input
type="checkbox"
<label class="toggle-row">
<ToggleSwitch
bind:checked={staticRenderMarkdown}
label="Render markdown"
/>
<span>
<strong>Render markdown</strong> — auto-render <code>.md</code>
@@ -982,45 +1004,108 @@
{/if}
</div>
<div class="field">
<div class="field-label">
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num">04</span>
<span class="lbl">Trigger config</span>
<span class="req">JSON</span>
<span class="lbl">{$t('apps.new.triggers.section')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
<!-- Mode selector — three short cards. The "active" card
reveals its sub-form below. Skipping is explicit so
users can ship the workload now and wire triggers
later from the detail page. -->
<div
class="trig-mode-row"
role="radiogroup"
aria-label={$t('apps.new.triggers.section')}
>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'inline'}
class="trig-mode-card"
class:active={triggerMode === 'inline'}
onclick={() => (triggerMode = 'inline')}
>
<span class="trig-mode-tag mono">NEW</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'pick'}
class="trig-mode-card"
class:active={triggerMode === 'pick'}
onclick={() => (triggerMode = 'pick')}
>
<span class="trig-mode-tag mono">PICK</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'skip'}
class="trig-mode-card"
class:active={triggerMode === 'skip'}
onclick={() => (triggerMode = 'skip')}
>
<span class="trig-mode-tag mono">SKIP</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
</button>
</div>
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">trigger_config.json · {triggerKind}</span>
<span class="spacer"></span>
<button
type="button"
class="editor-chip"
onclick={() => (triggerConfig = triggerConfigSample(triggerKind))}
>
Reset sample
</button>
{#if triggerMode === 'inline'}
<div class="trig-sub">
<TriggerKindForm
bind:state={triggerForm}
idPrefix="app-trig"
showName={true}
showWebhook={true}
showKindPicker={true}
/>
</div>
<textarea
id="app-trigger-config"
bind:value={triggerConfig}
rows="7"
spellcheck="false"
class="code-area"
aria-label="Trigger plugin configuration (JSON)"
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!triggerValid}>
<span class="foot-dot" aria-hidden="true"></span>
{triggerValid ? 'JSON OK' : 'JSON INVALID'}
</span>
<span class="sep">·</span>
<span>{triggerLines} lines</span>
<span class="sep">·</span>
<span>{triggerBytes} B</span>
{:else if triggerMode === 'pick'}
<div class="trig-sub">
{#if existingTriggers.length === 0}
<div class="note muted-note">
<span class="note-tag"></span>
<p>{$t('apps.new.triggers.pickEmpty')}</p>
</div>
{:else}
<label class="sub" for="app-trig-pick">
<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
<select
id="app-trig-pick"
class="input"
bind:value={pickedTriggerId}
>
<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
{#each existingTriggers as tr (tr.id)}
<option value={tr.id}>
{tr.name} · {tr.kind}{tr.webhook_enabled
? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
: ''}
</option>
{/each}
</select>
<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
</label>
{/if}
</div>
</div>
</div>
{:else}
<div class="trig-sub">
<div class="note muted-note">
<span class="note-tag">SKIP</span>
<p>{$t('apps.new.triggers.skippedNote')}</p>
</div>
</div>
{/if}
</fieldset>
<fieldset class="field group">
<legend class="field-label as-legend">
@@ -1074,7 +1159,7 @@
<button
class="btn-primary"
type="submit"
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerValid}
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
>
<span>{submitting ? 'Forging…' : 'Forge app'}</span>
<span class="arrow" aria-hidden="true"></span>
@@ -1553,16 +1638,15 @@
color: var(--text-secondary);
cursor: pointer;
}
.radio input,
.checkbox-row input {
.radio input {
margin-top: 0.18rem;
accent-color: var(--color-brand-500);
}
.radio strong,
.checkbox-row strong {
.toggle-row strong {
color: var(--text-primary);
}
.checkbox-row {
.toggle-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
@@ -1571,4 +1655,108 @@
color: var(--text-secondary);
cursor: pointer;
}
.toggle-row :global(.toggle-switch) {
margin-top: 0.1rem;
}
/* ── Trigger mode picker ──────────────────────────
Three short cards (NEW / PICK / SKIP). The active
card lights up its tag in brand colour and reveals
the matching sub-form below in a soft inset panel. */
.trig-mode-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 720px) {
.trig-mode-row {
grid-template-columns: 1fr;
}
}
.trig-mode-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.85rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease,
transform 150ms ease;
}
.trig-mode-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.trig-mode-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.trig-mode-tag {
display: inline-flex;
align-self: flex-start;
padding: 0.18rem 0.5rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.trig-mode-card.active .trig-mode-tag {
background: var(--forge-accent);
}
.trig-mode-name {
font-weight: 600;
font-size: 0.92rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.trig-mode-hint {
font-size: 0.7rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.trig-sub {
margin-top: 0.2rem;
padding: 0.95rem 1rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
}
.note {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.65rem 0.85rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.muted-note {
background: transparent;
}
.note-tag {
padding: 0.16rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
}
</style>
+649
View File
@@ -0,0 +1,649 @@
<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>
File diff suppressed because it is too large Load Diff
+767
View File
@@ -0,0 +1,767 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { TriggerInput } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// Three kinds have hand-rolled forms today; anything else falls
// back to the JSON editor. KNOWN_KINDS gates the structured form
// switch — see formNote() for the manual/unknown explainer text.
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
let kind = $state<KnownKind | string>('registry');
let name = $state('');
let webhookEnabled = $state(false);
let webhookRequireSig = $state(true);
let useAdvancedJson = $state(false);
let submitting = $state(false);
let error = $state('');
// Per-kind structured fields. They mirror the Go config shapes
// documented in the parent task description — see TriggerInput
// in $lib/api. Keeping them as separate $state slots lets the
// kind switch persist values across kind flips (operator typo
// recovery) without juggling a discriminated union.
let regImage = $state('');
let regTagPattern = $state('*');
let gitRepo = $state('');
let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main');
let gitTagPattern = $state('v*');
// Advanced JSON editor — primed with the sample shape for the
// current kind on first toggle so the operator has something to
// edit. We only auto-prime when the field is blank to avoid
// nuking deliberate edits on re-toggle.
let jsonText = $state('');
let jsonLoading = $state(false);
const jsonValid = $derived.by(() => {
if (!useAdvancedJson) return true;
if (!jsonText.trim()) return true; // blank treated as empty object server-side
try {
const parsed = JSON.parse(jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function buildConfig(): unknown {
if (useAdvancedJson) {
if (!jsonText.trim()) return {};
return JSON.parse(jsonText);
}
switch (kind) {
case 'registry':
return {
image: regImage.trim(),
tag_pattern: regTagPattern.trim() || '*'
};
case 'git':
return gitMode === 'push'
? { repo: gitRepo.trim(), mode: 'push', branch: gitBranch.trim() || 'main' }
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual':
return {};
default:
// Unknown kind reached the structured path — fall back
// to an empty object; advanced JSON would normally be
// on by this point.
return {};
}
}
function canSubmit(): boolean {
if (submitting) return false;
if (!name.trim()) return false;
if (useAdvancedJson) return jsonValid;
switch (kind) {
case 'registry':
return !!regImage.trim();
case 'git':
return !!gitRepo.trim();
case 'manual':
return true;
default:
return false; // unknown kinds force advanced JSON
}
}
async function loadSampleIntoJson(): Promise<void> {
jsonLoading = true;
try {
const schema = await api.getHookKindSchema(kind);
jsonText = JSON.stringify(schema.sample ?? {}, null, 2);
} catch {
// Best-effort prime — operator can paste their own.
jsonText = '{\n \n}';
} finally {
jsonLoading = false;
}
}
function toggleAdvanced(): void {
useAdvancedJson = !useAdvancedJson;
if (useAdvancedJson && !jsonText.trim()) {
// Seed with current structured values (or schema sample
// as fallback) so the operator can refine instead of
// retyping.
try {
jsonText = JSON.stringify(buildConfig(), null, 2);
} catch {
void loadSampleIntoJson();
}
}
}
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit()) return;
error = '';
submitting = true;
try {
const body: TriggerInput = {
kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
};
const created = await api.createTrigger(body);
goto(`/triggers/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
</script>
<svelte:head>
<title>{$t('redeployTriggers.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('redeployTriggers.ledeNew')}
{/snippet}
<ForgeHero
backHref="/triggers"
backLabel={$t('redeployTriggers.toolbar.backToList')}
eyebrowSuffix={$t('redeployTriggers.toolbar.newButton').toUpperCase()}
title={$t('redeployTriggers.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}
<!-- Step 01 · Kind picker. Renders as a grid of square cards
so the kind is the first visual commitment of the wizard. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('redeployTriggers.form.kindLabel')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</legend>
<p class="hint">{$t('redeployTriggers.form.kindHint')}</p>
<div class="kind-grid" role="radiogroup" aria-label={$t('redeployTriggers.form.kindLabel')}>
{#each ALL_PICKABLE as k}
<button
type="button"
role="radio"
aria-checked={kind === k}
class="kind-card"
class:active={kind === k}
onclick={() => (kind = k)}
>
<span class="kind-card-tag mono">{$t(`redeployTriggers.kindShort.${k}`)}</span>
<span class="kind-card-name">{$t(`redeployTriggers.kind.${k}`)}</span>
<span class="kind-card-hint">{kindHint(k)}</span>
</button>
{/each}
</div>
</fieldset>
<!-- Step 02 · Name. -->
<div class="field">
<label for="trig-name" class="field-label">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('redeployTriggers.form.name')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="trig-name"
type="text"
bind:value={name}
class="input"
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
<!-- Step 03 · Config — kind-aware switch. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if useAdvancedJson}
<p class="hint">{$t('redeployTriggers.form.advancedHint')}</p>
<label class="sub" for="trig-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="trig-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={jsonText}
rows="8"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
aria-describedby={!jsonValid ? 'trig-json-err' : 'trig-json-hint'}
></textarea>
<span id="trig-json-hint" class="hint">
{$t('redeployTriggers.form.configJsonHint')}
{#if jsonLoading} <em>· loading sample…</em>{/if}
</span>
{#if !jsonValid}
<span id="trig-json-err" class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{/if}
</label>
{:else if kind === 'registry'}
<label class="sub" for="trig-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="trig-image"
type="text"
class="input mono"
bind:value={regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="trig-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-tag"
type="text"
class="input mono"
bind:value={regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if kind === 'git'}
<label class="sub" for="trig-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="trig-repo"
type="text"
class="input mono"
bind:value={gitRepo}
placeholder={$t('redeployTriggers.form.repoPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
</label>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.mode')}</span>
<div class="mode-row" role="radiogroup" aria-label={$t('redeployTriggers.form.mode')}>
<button
type="button"
role="radio"
aria-checked={gitMode === 'push'}
class="mode-chip"
class:active={gitMode === 'push'}
onclick={() => (gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={gitMode === 'tag'}
class="mode-chip"
class:active={gitMode === 'tag'}
onclick={() => (gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if gitMode === 'push'}
<label class="sub" for="trig-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="trig-branch"
type="text"
class="input mono"
bind:value={gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="trig-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-gtag"
type="text"
class="input mono"
bind:value={gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if kind === 'manual'}
<div class="note">
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</div>
{:else}
<div class="note">
<span class="note-tag">?</span>
<p>{$t('redeployTriggers.form.unknownNote')}</p>
</div>
{/if}
</fieldset>
<!-- Step 04 · Webhook ingress. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">04</span>
<span class="lbl">{$t('redeployTriggers.detail.webhook')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookEnabled')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookEnabledHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if webhookEnabled}
<div class="row-toggle indent">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookRequireSig')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookRequireSigHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
<div class="actions">
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={!canSubmit()}
aria-busy={submitting}
>
{submitting
? $t('redeployTriggers.form.submitting')
: $t('redeployTriggers.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); }
/* Advanced JSON pill-toggle lives in the same legend row as
the section number. Visually it's a quiet outlined button
that fills in when active. */
.adv-toggle {
margin-left: auto;
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
}
.adv-toggle:hover {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.adv-toggle.on {
background: var(--forge-accent);
border-color: var(--forge-accent);
color: var(--surface-card);
}
/* ── 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.code {
resize: vertical;
min-height: 140px;
line-height: 1.5;
}
.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);
}
.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); }
.hint em {
font-style: italic;
color: var(--forge-accent);
}
/* ── Note banner (manual/unknown) ─────────────────── */
.note {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.note-tag {
padding: 0.18rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── Kind picker grid ─────────────────────────────
Each card has a monospace tag and a soft name. The
active card lights up the tag in brand colour and
adds a subtle inner glow so the choice is obvious. */
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
@media (max-width: 600px) {
.kind-grid { grid-template-columns: 1fr; }
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 0.9rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease,
box-shadow 150ms ease;
position: relative;
overflow: hidden;
}
.kind-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.kind-card-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Mode chips (git push vs tag) ─────────────── */
.mode-row {
display: inline-flex;
gap: 0;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
padding: 2px;
width: fit-content;
}
.mode-chip {
padding: 0.32rem 0.85rem;
background: transparent;
border: 0;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.mode-chip:hover { color: var(--text-primary); }
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
/* ── 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);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.1rem;
padding-left: 1rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.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>