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
+122
View File
@@ -1186,6 +1186,128 @@ export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise<H
return get<HookKindSchema>(`/api/hooks/kinds/${kind}/schema`, signal);
}
// ── Triggers (first-class redeploy signal sources) ──────────────────
export interface RedeployTrigger {
id: string;
kind: string;
name: string;
config: unknown;
webhook_enabled: boolean;
webhook_require_signature: boolean;
binding_count: number;
created_at: string;
updated_at: string;
}
export interface TriggerWebhook {
url: string;
secret: string;
webhook_require_signature: boolean;
}
export interface TriggerBinding {
id: string;
workload_id: string;
workload_name: string;
trigger_id: string;
binding_config: unknown;
enabled: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface WorkloadTriggerBinding extends TriggerBinding {
trigger_kind: string;
trigger_name: string;
}
export interface TriggerInput {
kind: string;
name: string;
config: unknown;
webhook_enabled: boolean;
webhook_require_signature: boolean;
}
export interface BindingInput {
workload_id: string;
binding_config?: unknown;
enabled?: boolean;
sort_order?: number;
}
export interface WorkloadBindInput {
trigger_id?: string;
binding_config?: unknown;
enabled?: boolean;
sort_order?: number;
inline?: TriggerInput;
}
export function listTriggers(kind?: string, signal?: AbortSignal): Promise<RedeployTrigger[]> {
const path = kind ? `/api/triggers?kind=${encodeURIComponent(kind)}` : '/api/triggers';
return get<RedeployTrigger[]>(path, signal);
}
export function getTrigger(id: string, signal?: AbortSignal): Promise<RedeployTrigger> {
return get<RedeployTrigger>(`/api/triggers/${id}`, signal);
}
export function createTrigger(body: TriggerInput): Promise<RedeployTrigger> {
return post<RedeployTrigger>('/api/triggers', body);
}
export function updateTrigger(id: string, body: TriggerInput): Promise<RedeployTrigger> {
return put<RedeployTrigger>(`/api/triggers/${id}`, body);
}
export function deleteTrigger(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/triggers/${id}`);
}
export function getTriggerWebhook(id: string, signal?: AbortSignal): Promise<TriggerWebhook> {
return get<TriggerWebhook>(`/api/triggers/${id}/webhook`, signal);
}
export function regenerateTriggerWebhook(id: string): Promise<{ secret: string; url: string }> {
return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`);
}
export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise<TriggerBinding[]> {
return get<TriggerBinding[]>(`/api/triggers/${id}/bindings`, signal);
}
export function bindWorkloadToTrigger(triggerId: string, body: BindingInput): Promise<TriggerBinding> {
return post<TriggerBinding>(`/api/triggers/${triggerId}/bindings`, body);
}
export function listBindingsForWorkload(
workloadId: string,
signal?: AbortSignal
): Promise<WorkloadTriggerBinding[]> {
return get<WorkloadTriggerBinding[]>(`/api/workloads/${workloadId}/triggers`, signal);
}
export function bindTriggerToWorkload(
workloadId: string,
body: WorkloadBindInput
): Promise<TriggerBinding> {
return post<TriggerBinding>(`/api/workloads/${workloadId}/triggers`, body);
}
export function updateBinding(
id: string,
body: { binding_config?: unknown; enabled?: boolean; sort_order?: number }
): Promise<TriggerBinding> {
return put<TriggerBinding>(`/api/bindings/${id}`, body);
}
export function deleteBinding(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/bindings/${id}`);
}
export interface WorkloadChainNode {
id: string;
name: string;
@@ -0,0 +1,714 @@
<!--
Shared per-kind trigger configuration form.
Mirrors the structured form used in /triggers/new so that the
workload-side "Add trigger" surfaces (apps/new + apps/[id]) can
reuse the same UX without copy-pasting field markup.
Owns:
- kind (registry | git | manual | <unknown>)
- name (always required)
- webhook_enabled + webhook_require_signature
- the structured per-kind config slots (image/tag, repo/mode/branch/tag)
- an advanced JSON escape hatch
Exposes a `build()` method on the bindable `state` prop so callers can
read out a `TriggerInput` body when they're ready to submit.
-->
<script lang="ts" module>
import type { TriggerInput } from '$lib/api';
export const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
export type KnownTriggerKind = (typeof KNOWN_KINDS)[number];
/**
* State shared between the component and its parent. The parent owns
* one of these and binds it; the component mutates fields in place
* and exposes `build()` to materialize a submission body.
*/
export interface TriggerKindFormState {
kind: KnownTriggerKind | string;
name: string;
webhookEnabled: boolean;
webhookRequireSig: boolean;
useAdvancedJson: boolean;
// registry
regImage: string;
regTagPattern: string;
// git
gitRepo: string;
gitMode: 'push' | 'tag';
gitBranch: string;
gitTagPattern: string;
// JSON fallback
jsonText: string;
}
export function createTriggerKindFormState(
init: Partial<TriggerKindFormState> = {}
): TriggerKindFormState {
return {
kind: init.kind ?? 'registry',
name: init.name ?? '',
webhookEnabled: init.webhookEnabled ?? false,
webhookRequireSig: init.webhookRequireSig ?? true,
useAdvancedJson: init.useAdvancedJson ?? false,
regImage: init.regImage ?? '',
regTagPattern: init.regTagPattern ?? '*',
gitRepo: init.gitRepo ?? '',
gitMode: init.gitMode ?? 'push',
gitBranch: init.gitBranch ?? 'main',
gitTagPattern: init.gitTagPattern ?? 'v*',
jsonText: init.jsonText ?? ''
};
}
function isKnownKind(k: string): k is KnownTriggerKind {
return (KNOWN_KINDS as readonly string[]).includes(k);
}
export function isTriggerFormValid(s: TriggerKindFormState): boolean {
if (!s.name.trim()) return false;
if (s.useAdvancedJson) {
if (!s.jsonText.trim()) return true;
try {
const parsed = JSON.parse(s.jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
}
switch (s.kind) {
case 'registry':
return !!s.regImage.trim();
case 'git':
return !!s.gitRepo.trim();
case 'manual':
return true;
default:
// Unknown kinds without an advanced JSON payload are unsubmittable.
return false;
}
}
export function buildTriggerInput(s: TriggerKindFormState): TriggerInput {
let config: unknown;
if (s.useAdvancedJson) {
config = s.jsonText.trim() ? JSON.parse(s.jsonText) : {};
} else if (s.kind === 'registry') {
config = {
image: s.regImage.trim(),
tag_pattern: s.regTagPattern.trim() || '*'
};
} else if (s.kind === 'git') {
config =
s.gitMode === 'push'
? { repo: s.gitRepo.trim(), mode: 'push', branch: s.gitBranch.trim() || 'main' }
: {
repo: s.gitRepo.trim(),
mode: 'tag',
tag_pattern: s.gitTagPattern.trim() || '*'
};
} else if (s.kind === 'manual') {
config = {};
} else {
config = {};
}
return {
kind: s.kind,
name: s.name.trim(),
config,
webhook_enabled: s.webhookEnabled,
webhook_require_signature: s.webhookRequireSig
};
}
</script>
<script lang="ts">
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
interface Props {
/** Bound state container. */
state: TriggerKindFormState;
/** Optional id prefix to make labels unique when several copies are on-page. */
idPrefix?: string;
/** Render the name field above the per-kind config. Default true. */
showName?: boolean;
/** Render the webhook toggles below the config. Default true. */
showWebhook?: boolean;
/** Allow the operator to pick a kind. Default true (false on edit). */
showKindPicker?: boolean;
}
let {
state = $bindable(),
idPrefix = 'tk',
showName = true,
showWebhook = true,
showKindPicker = true
}: Props = $props();
const jsonValid = $derived.by(() => {
if (!state.useAdvancedJson) return true;
if (!state.jsonText.trim()) return true;
try {
const parsed = JSON.parse(state.jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
function toggleAdvanced(): void {
state.useAdvancedJson = !state.useAdvancedJson;
if (state.useAdvancedJson && !state.jsonText.trim()) {
// Seed JSON from current structured values so the operator can
// refine rather than retype.
try {
const built = buildTriggerInput(state);
state.jsonText = JSON.stringify(built.config ?? {}, null, 2);
} catch {
state.jsonText = '{\n \n}';
}
}
}
</script>
<div class="tk-form">
{#if showKindPicker}
<fieldset class="field group">
<legend class="sub-label">{$t('redeployTriggers.form.kindLabel')}</legend>
<div
class="kind-grid"
role="radiogroup"
aria-label={$t('redeployTriggers.form.kindLabel')}
>
{#each KNOWN_KINDS as k (k)}
<button
type="button"
role="radio"
aria-checked={state.kind === k}
class="kind-card"
class:active={state.kind === k}
onclick={() => (state.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>
{:else}
<div class="kind-static">
<span class="kind-tag mono">{$t(`redeployTriggers.kindShort.${state.kind}`)}</span>
<span>
{$t(`redeployTriggers.kind.${state.kind}`) === `redeployTriggers.kind.${state.kind}`
? state.kind
: $t(`redeployTriggers.kind.${state.kind}`)}
</span>
</div>
{/if}
{#if showName}
<div class="field">
<label for="{idPrefix}-name" class="sub-label">
{$t('redeployTriggers.form.name')}
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="{idPrefix}-name"
type="text"
class="input"
bind:value={state.name}
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
{/if}
<fieldset class="field group">
<legend class="sub-label legend-row">
<span>{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${state.kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={state.useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if state.useAdvancedJson}
<label class="sub" for="{idPrefix}-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="{idPrefix}-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={state.jsonText}
rows="6"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
></textarea>
{#if !jsonValid}
<span class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{:else}
<span class="hint">{$t('redeployTriggers.form.configJsonHint')}</span>
{/if}
</label>
{:else if state.kind === 'registry'}
<label class="sub" for="{idPrefix}-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="{idPrefix}-image"
type="text"
class="input mono"
bind:value={state.regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="{idPrefix}-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="{idPrefix}-tag"
type="text"
class="input mono"
bind:value={state.regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if state.kind === 'git'}
<label class="sub" for="{idPrefix}-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="{idPrefix}-repo"
type="text"
class="input mono"
bind:value={state.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={state.gitMode === 'push'}
class="mode-chip"
class:active={state.gitMode === 'push'}
onclick={() => (state.gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={state.gitMode === 'tag'}
class="mode-chip"
class:active={state.gitMode === 'tag'}
onclick={() => (state.gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if state.gitMode === 'push'}
<label class="sub" for="{idPrefix}-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="{idPrefix}-branch"
type="text"
class="input mono"
bind:value={state.gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="{idPrefix}-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="{idPrefix}-gtag"
type="text"
class="input mono"
bind:value={state.gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if state.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>
{#if showWebhook}
<fieldset class="field group">
<legend class="sub-label legend-row">
<span>{$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={state.webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if state.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={state.webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
{/if}
</div>
<style>
.tk-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group {
gap: 0.6rem;
}
.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);
}
.legend-row {
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.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);
}
.adv-toggle {
margin-left: auto;
padding: 0.2rem 0.55rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.56rem;
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);
}
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono {
font-family: var(--forge-mono);
font-size: 0.85rem;
}
.input.code {
resize: vertical;
min-height: 110px;
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);
}
.hint {
font-size: 0.76rem;
color: var(--text-tertiary);
line-height: 1.45;
margin: 0;
}
.hint.danger {
color: var(--color-danger);
}
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 600px) {
.kind-grid {
grid-template-columns: 1fr;
}
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.8rem;
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;
position: relative;
}
.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-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;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.68rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.kind-static {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 0.95rem;
color: var(--text-primary);
font-weight: 600;
}
.kind-tag {
display: inline-flex;
padding: 0.16rem 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;
}
.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.28rem 0.8rem;
background: transparent;
border: 0;
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: background 150ms ease, color 150ms ease;
}
.mode-chip:hover {
color: var(--text-primary);
}
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
.note {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.7rem 0.85rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.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;
}
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.05rem;
padding-left: 0.95rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.lbl {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
}
.lbl.small {
font-size: 0.9rem;
}
</style>
+231
View File
@@ -17,6 +17,7 @@
"apps": "Apps",
"eventTriggers": "Triggers",
"logScanRules": "Log Rules",
"triggers": "Triggers",
"projects": "Projects",
"deploy": "Deploy",
"proxies": "Proxies",
@@ -1527,5 +1528,235 @@
"overriding": "Overriding…",
"overrideTitle": "Create a per-workload override of this global rule"
}
},
"redeployTriggers": {
"section": "The Forge",
"title": "Redeploy triggers",
"titleNew": "Forge a new trigger",
"titleSingular": "Trigger",
"lede": "Sources of redeploy signals — registry pushes, git events, manual fires, schedules, webhooks, log matches. Each trigger lives once and fans out to every workload bound to it.",
"ledeNew": "Pick a kind, name it, and decide whether external systems may poke it via webhook. Bind it to one or more workloads from the workload page after creation.",
"ledeDetail": "Edit the trigger config, manage its webhook ingress, and review every workload listening to this signal.",
"stat": {
"total": "TOTAL",
"byKind": "{kind}",
"withWebhook": "WEBHOOK ON",
"boundWorkloads": "WORKLOADS"
},
"kind": {
"registry": "Registry",
"git": "Git",
"manual": "Manual",
"schedule": "Schedule",
"webhook": "Webhook",
"logscan": "Log scan",
"unknown": "Unknown"
},
"kindShort": {
"registry": "REG",
"git": "GIT",
"manual": "MAN",
"schedule": "CRN",
"webhook": "HK",
"logscan": "LOG",
"unknown": "?"
},
"kindHint": {
"registry": "Watch a container image; fire when a new tag matching the pattern is pushed.",
"git": "Fire when a configured branch advances or a tag matching the pattern is created.",
"manual": "Fires only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"schedule": "Fires on a fixed cron-style schedule.",
"webhook": "Pure webhook — fires when the ingress URL is hit.",
"logscan": "Fires when an upstream log-scan rule matches a tailed line.",
"unknown": "Unknown trigger kind — fall back to the raw JSON editor."
},
"toolbar": {
"newButton": "New trigger",
"backToList": "Back to triggers"
},
"filter": {
"all": "ALL",
"ariaLabel": "Filter by kind"
},
"empty": {
"heading": "No triggers yet",
"body": "A trigger is the source of a redeploy signal — a registry watcher, git hook, manual button, scheduled fire, or webhook. Create one and bind it to as many workloads as you like.",
"cta": "Forge the first trigger"
},
"list": {
"name": "Name",
"kind": "Kind",
"bindings": "Workloads",
"webhook": "Webhook",
"created": "Created",
"open": "Open",
"webhookOn": "ON",
"webhookOff": "—",
"noBindings": "—",
"bindingsCount": "{count}"
},
"detail": {
"config": "Trigger configuration",
"configSub": "kind {kind} · id {id} · updated {updatedAt}",
"webhook": "Webhook ingress",
"webhookSub": "When enabled, external systems can fire this trigger by posting to the URL below. Each workload bound to it will be redeployed in turn.",
"webhookEnable": "Enable webhook ingress",
"webhookEnableHint": "When off, the trigger fires only via internal sources (its kind config) and the manual deploy button.",
"webhookRequireSig": "Require HMAC signature",
"webhookRequireSigHint": "Reject requests without a valid X-Hub-Signature-256 header. Recommended whenever the URL is reachable from the public internet.",
"webhookUrlLabel": "Ingress URL",
"webhookUrlNote": "Paste this into your CI / registry / GitHub webhook settings. The secret segment is the bearer — treat it like a password.",
"webhookCopy": "Copy",
"webhookCopied": "Copied",
"webhookRotate": "Rotate secret",
"webhookRotating": "Rotating…",
"webhookDisabledNote": "Webhook ingress is disabled. Toggle it on, save, and the URL will appear here.",
"bindings": "Bound workloads",
"bindingsSub": "Every workload listening to this trigger. To bind a new workload, open the workload page and add this trigger from there.",
"bindingsEmpty": "No workloads are bound to this trigger yet. Open a workload and bind this trigger from its Triggers panel.",
"bindingsListItem": {
"openWorkload": "Open workload",
"unbind": "Unbind"
},
"bindingEnabledHint": "Disable to keep the binding but stop this trigger from redeploying that workload.",
"dangerZone": "Danger zone",
"dangerZoneSub": "Trigger deletion is immediate. All bindings to it are cascade-removed.",
"deleteButton": "Delete trigger",
"deleteTitle": "Delete trigger?",
"deleteMessage": "Trigger \"{name}\" will be removed immediately, along with {count} binding(s). This cannot be undone.",
"rotateTitle": "Rotate webhook secret?",
"rotateMessage": "The current ingress URL stops working immediately. Update every external integration with the new URL after rotation.",
"rotateConfirm": "Rotate now",
"unbindTitle": "Unbind workload?",
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
"unbindConfirm": "Unbind"
},
"form": {
"kindLabel": "Kind",
"kindHint": "Pick the source of the redeploy signal. The form below adapts to the kind.",
"name": "Name",
"namePlaceholder": "e.g. ghcr.io/me/api · main",
"required": "REQUIRED",
"configLabel": "Configuration",
"image": "Image reference",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.",
"tagPattern": "Tag pattern",
"tagPatternPlaceholder": "*",
"tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.",
"repo": "Repository",
"repoPlaceholder": "owner/name",
"repoHint": "Provider-agnostic owner/name slug as advertised by the git host.",
"mode": "Mode",
"modePush": "Push to branch",
"modeTag": "Tag created",
"branch": "Branch",
"branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.",
"advancedToggle": "Advanced JSON",
"advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.",
"configJson": "Config JSON",
"configJsonHint": "Must parse as a valid JSON object. The shape is validated server-side per kind.",
"invalidJson": "Invalid JSON — server will reject.",
"webhookEnabled": "Enable webhook ingress now",
"webhookEnabledHint": "Generates a secret URL that external systems can hit to fire the trigger.",
"webhookRequireSig": "Require HMAC signature",
"webhookRequireSigHint": "Reject unsigned requests. The secret is the same one embedded in the URL — sign the body with HMAC-SHA256 and send it as X-Hub-Signature-256.",
"submit": "Forge trigger",
"submitting": "Forging…",
"cancel": "Cancel"
},
"binding": {
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"apps": {
"new": {
"triggers": {
"section": "Trigger",
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
"modeInline": "Add a trigger",
"modeInlineHint": "Creates a brand-new trigger record bound to this app — fits the common 1:1 case.",
"modePick": "Pick existing trigger",
"modePickHint": "Bind an existing trigger so multiple apps share one signal.",
"modeSkip": "Skip — add later",
"modeSkipHint": "The app is created without any trigger binding. Manual deploys still work.",
"switchToPick": "Pick existing trigger →",
"switchToInline": "← Create a new trigger instead",
"switchToSkip": "Skip for now",
"pickPlaceholder": "Select a trigger…",
"pickEmpty": "No triggers exist yet — create one inline above, or visit /triggers.",
"pickLabel": "Existing trigger",
"pickHint": "The same trigger can be bound to many apps. Manage standalone triggers under /triggers.",
"pickWebhookOn": "WEBHOOK ON",
"skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.",
"bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry."
}
},
"detail": {
"manualDeploySub": "Bypasses configured triggers and dispatches through the source plugin directly.",
"chainTriggersZero": "no triggers",
"chainTriggersOne": "1 trigger",
"chainTriggersMany": "{count} triggers",
"bindings": {
"title": "Triggers",
"subEmpty": "No triggers bound. Manual deploys still work — add a trigger to wire up registry / git / webhook redeploys.",
"subCount": "{count} trigger bound",
"subCountMany": "{count} triggers bound",
"addButton": "Add trigger",
"openTrigger": "View trigger",
"unbindAction": "Unbind",
"rowEnabled": "Enabled",
"rowDisabled": "Disabled",
"rowEnableHint": "Disable to keep the binding but stop this trigger from redeploying the app.",
"loading": "Loading triggers…",
"loadError": "Failed to load trigger bindings",
"unbindTitle": "Unbind trigger?",
"unbindMessage": "Trigger \"{name}\" will stop redeploying this app. The trigger record itself is not deleted — it stays in /triggers and remains bound to any other apps.",
"unbindConfirm": "Unbind",
"modal": {
"title": "Add trigger",
"subtitle": "Bind a trigger to this app — create a new one inline, or pick an existing trigger to share.",
"tabInline": "Create new",
"tabPick": "Bind existing",
"submitInline": "Create & bind",
"submitPick": "Bind",
"submitting": "Binding…",
"cancel": "Cancel",
"error": "Bind failed",
"pickPlaceholder": "Select a trigger…",
"pickEmpty": "No triggers exist yet — switch to \"Create new\" to make one.",
"pickLabel": "Existing trigger",
"pickKind": "Filter by kind",
"pickKindAll": "All kinds"
},
"override": {
"toggle": "Override",
"title": "Per-binding overrides",
"subtitle": "Override fields of the trigger's config for this app only. Top-level keys you set here win; everything else inherits from the trigger.",
"badgeOne": "OVERRIDES 1 FIELD",
"badgeMany": "OVERRIDES {count} FIELDS",
"badgeTitle": "This binding overrides one or more fields of the trigger's config.",
"baseLabel": "Trigger config",
"baseLoading": "Loading trigger config…",
"baseHint": "Read-only view of the parent trigger's config. Edit it from the trigger page if it should change for every binding.",
"editLabel": "Override (JSON object)",
"editHint": "Top-level merge: only keys present here override the trigger. Leave the editor as {} to inherit verbatim.",
"previewLabel": "Effective config",
"previewHint": "Preview of what this binding will see when the trigger fires (trigger config with the override merged on top).",
"invalidJson": "Override must be a JSON object.",
"tooLarge": "Override is {size} B — exceeds the {limit} B server limit.",
"errInvalidJson": "Cannot save: override is not a valid JSON object.",
"errTooLarge": "Cannot save: override exceeds the 8 KiB server limit.",
"saveButton": "Save override",
"saving": "Saving…",
"resetButton": "Reset to inherit",
"closeButton": "Close"
}
}
}
}
}
+231
View File
@@ -17,6 +17,7 @@
"apps": "Приложения",
"eventTriggers": "Триггеры",
"logScanRules": "Лог-правила",
"triggers": "Триггеры",
"projects": "Проекты",
"deploy": "Деплой",
"proxies": "Прокси",
@@ -1527,5 +1528,235 @@
"overriding": "Переопределение…",
"overrideTitle": "Создать переопределение глобального правила для этой нагрузки"
}
},
"redeployTriggers": {
"section": "Кузница",
"title": "Триггеры передеплоя",
"titleNew": "Новый триггер",
"titleSingular": "Триггер",
"lede": "Источники сигналов передеплоя — push в registry, события git, ручной запуск, расписания, webhook'и, совпадения в логах. Триггер создаётся один раз и веером раздаёт сигнал всем привязанным к нему нагрузкам.",
"ledeNew": "Выберите вид, дайте имя и решите, могут ли внешние системы дёргать его через webhook. Привязку к нагрузкам делайте со страницы нагрузки после создания.",
"ledeDetail": "Редактируйте конфигурацию триггера, управляйте webhook-приёмом и просматривайте все нагрузки, слушающие этот сигнал.",
"stat": {
"total": "ВСЕГО",
"byKind": "{kind}",
"withWebhook": "С WEBHOOK",
"boundWorkloads": "НАГРУЗОК"
},
"kind": {
"registry": "Registry",
"git": "Git",
"manual": "Ручной",
"schedule": "Расписание",
"webhook": "Webhook",
"logscan": "Лог-скан",
"unknown": "Неизвестный"
},
"kindShort": {
"registry": "REG",
"git": "GIT",
"manual": "MAN",
"schedule": "CRN",
"webhook": "HK",
"logscan": "LOG",
"unknown": "?"
},
"kindHint": {
"registry": "Следит за образом контейнера; срабатывает при push нового тега, подходящего под шаблон.",
"git": "Срабатывает при продвижении указанной ветки или создании тега, подходящего под шаблон.",
"manual": "Срабатывает только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"schedule": "Срабатывает по фиксированному cron-расписанию.",
"webhook": "Чистый webhook — срабатывает при обращении к URL приёма.",
"logscan": "Срабатывает, когда правило сканирования логов совпадает со строкой.",
"unknown": "Неизвестный вид триггера — используйте сырой JSON-редактор."
},
"toolbar": {
"newButton": "Новый триггер",
"backToList": "К списку триггеров"
},
"filter": {
"all": "ВСЕ",
"ariaLabel": "Фильтр по виду"
},
"empty": {
"heading": "Триггеров пока нет",
"body": "Триггер — источник сигнала передеплоя: registry-watcher, git-hook, ручная кнопка, расписание или webhook. Создайте один и привяжите к скольким угодно нагрузкам.",
"cta": "Создать первый триггер"
},
"list": {
"name": "Имя",
"kind": "Вид",
"bindings": "Нагрузки",
"webhook": "Webhook",
"created": "Создан",
"open": "Открыть",
"webhookOn": "ВКЛ",
"webhookOff": "—",
"noBindings": "—",
"bindingsCount": "{count}"
},
"detail": {
"config": "Конфигурация триггера",
"configSub": "вид {kind} · id {id} · обновлено {updatedAt}",
"webhook": "Webhook-приём",
"webhookSub": "Когда включено, внешние системы могут дёргать триггер по URL ниже. Каждая привязанная нагрузка будет передеплоена по очереди.",
"webhookEnable": "Включить webhook-приём",
"webhookEnableHint": "Когда выключено, триггер срабатывает только из внутренних источников (по конфигу его вида) и кнопки ручного деплоя.",
"webhookRequireSig": "Требовать HMAC-подпись",
"webhookRequireSigHint": "Отклонять запросы без корректного X-Hub-Signature-256. Рекомендуется, если URL доступен из публичной сети.",
"webhookUrlLabel": "URL приёма",
"webhookUrlNote": "Вставьте это в настройки CI / registry / webhook GitHub. Сегмент-секрет — это пароль, обращайтесь как с паролем.",
"webhookCopy": "Копировать",
"webhookCopied": "Скопировано",
"webhookRotate": "Сменить секрет",
"webhookRotating": "Смена…",
"webhookDisabledNote": "Webhook-приём выключен. Включите тумблер, сохраните — и URL появится здесь.",
"bindings": "Привязанные нагрузки",
"bindingsSub": "Все нагрузки, слушающие этот триггер. Чтобы привязать новую нагрузку, откройте её страницу и добавьте этот триггер оттуда.",
"bindingsEmpty": "К этому триггеру пока не привязана ни одна нагрузка. Откройте нагрузку и привяжите этот триггер из её панели «Триггеры».",
"bindingsListItem": {
"openWorkload": "Открыть нагрузку",
"unbind": "Отвязать"
},
"bindingEnabledHint": "Выключите, чтобы оставить привязку, но запретить триггеру передеплоить эту нагрузку.",
"dangerZone": "Опасная зона",
"dangerZoneSub": "Удаление триггера происходит сразу. Все привязки к нему удаляются каскадом.",
"deleteButton": "Удалить триггер",
"deleteTitle": "Удалить триггер?",
"deleteMessage": "Триггер «{name}» будет удалён немедленно вместе с {count} привязкой(-ами). Действие необратимо.",
"rotateTitle": "Сменить секрет webhook?",
"rotateMessage": "Текущий URL приёма перестанет работать сразу. После смены обновите URL во всех внешних интеграциях.",
"rotateConfirm": "Сменить",
"unbindTitle": "Отвязать нагрузку?",
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
"unbindConfirm": "Отвязать"
},
"form": {
"kindLabel": "Вид",
"kindHint": "Выберите источник сигнала передеплоя. Форма ниже подстраивается под вид.",
"name": "Имя",
"namePlaceholder": "например, ghcr.io/me/api · main",
"required": "ОБЯЗАТЕЛЬНО",
"configLabel": "Конфигурация",
"image": "Ссылка на образ",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
"tagPattern": "Шаблон тега",
"tagPatternPlaceholder": "*",
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
"repo": "Репозиторий",
"repoPlaceholder": "owner/name",
"repoHint": "owner/name в формате git-хостинга, не зависит от провайдера.",
"mode": "Режим",
"modePush": "Push в ветку",
"modeTag": "Создание тега",
"branch": "Ветка",
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.",
"advancedToggle": "Расширенный JSON",
"advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.",
"configJson": "JSON конфигурации",
"configJsonHint": "Должен распарситься как корректный JSON-объект. Структура проверяется сервером по виду.",
"invalidJson": "Некорректный JSON — сервер отклонит.",
"webhookEnabled": "Включить webhook-приём сразу",
"webhookEnabledHint": "Генерирует секретный URL, по которому внешние системы могут дёргать триггер.",
"webhookRequireSig": "Требовать HMAC-подпись",
"webhookRequireSigHint": "Отклонять неподписанные запросы. Секрет — тот же, что вшит в URL — подпишите тело HMAC-SHA256 и пришлите в X-Hub-Signature-256.",
"submit": "Создать триггер",
"submitting": "Создание…",
"cancel": "Отмена"
},
"binding": {
"enabled": "Включена",
"disabled": "Выключена"
}
},
"apps": {
"new": {
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
"modeInline": "Создать триггер",
"modeInlineHint": "Создаёт новую запись триггера, привязанную к этому приложению — подходит для частого случая 1:1.",
"modePick": "Выбрать существующий",
"modePickHint": "Привязать существующий триггер, чтобы несколько приложений делили один сигнал.",
"modeSkip": "Пропустить — добавить позже",
"modeSkipHint": "Приложение создаётся без привязки триггера. Ручной деплой по-прежнему работает.",
"switchToPick": "Выбрать существующий →",
"switchToInline": "← Создать новый триггер",
"switchToSkip": "Пропустить",
"pickPlaceholder": "Выберите триггер…",
"pickEmpty": "Триггеров ещё нет — создайте один выше или перейдите в /triggers.",
"pickLabel": "Существующий триггер",
"pickHint": "Один триггер можно привязать к нескольким приложениям. Управление автономными триггерами — в /triggers.",
"pickWebhookOn": "ВЕБХУК ВКЛ",
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
}
},
"detail": {
"manualDeploySub": "Обходит настроенные триггеры и отправляет деплой напрямую через source-плагин.",
"chainTriggersZero": "без триггеров",
"chainTriggersOne": "1 триггер",
"chainTriggersMany": "{count} триггер(ов)",
"bindings": {
"title": "Триггеры",
"subEmpty": "Триггеры не привязаны. Ручной деплой работает — добавьте триггер, чтобы подключить передеплой по реестру / git / вебхуку.",
"subCount": "{count} привязанный триггер",
"subCountMany": "{count} привязанных триггеров",
"addButton": "Добавить триггер",
"openTrigger": "Открыть триггер",
"unbindAction": "Отвязать",
"rowEnabled": "Включён",
"rowDisabled": "Выключен",
"rowEnableHint": "Отключите, чтобы сохранить привязку, но остановить передеплой этого приложения.",
"loading": "Загрузка триггеров…",
"loadError": "Не удалось загрузить привязки триггеров",
"unbindTitle": "Отвязать триггер?",
"unbindMessage": "Триггер «{name}» перестанет передеплоить это приложение. Сам триггер не удаляется — он остаётся в /triggers и сохраняет привязки к другим приложениям.",
"unbindConfirm": "Отвязать",
"modal": {
"title": "Добавить триггер",
"subtitle": "Привяжите триггер к этому приложению — создайте новый или выберите существующий, чтобы делить его.",
"tabInline": "Создать новый",
"tabPick": "Выбрать существующий",
"submitInline": "Создать и привязать",
"submitPick": "Привязать",
"submitting": "Привязка…",
"cancel": "Отмена",
"error": "Не удалось привязать",
"pickPlaceholder": "Выберите триггер…",
"pickEmpty": "Триггеров ещё нет — переключитесь на «Создать новый», чтобы добавить.",
"pickLabel": "Существующий триггер",
"pickKind": "Фильтр по виду",
"pickKindAll": "Все виды"
},
"override": {
"toggle": "Переопределить",
"title": "Переопределения привязки",
"subtitle": "Переопределите поля конфига триггера только для этого приложения. Верхнеуровневые ключи отсюда побеждают; остальное наследуется из триггера.",
"badgeOne": "ПЕРЕОПРЕДЕЛЕНО: 1 ПОЛЕ",
"badgeMany": "ПЕРЕОПРЕДЕЛЕНО ПОЛЕЙ: {count}",
"badgeTitle": "Эта привязка переопределяет одно или несколько полей конфига триггера.",
"baseLabel": "Конфиг триггера",
"baseLoading": "Загрузка конфига триггера…",
"baseHint": "Конфиг родительского триггера в режиме чтения. Редактируйте его на странице триггера, если изменения нужны для всех привязок.",
"editLabel": "Переопределение (JSON-объект)",
"editHint": "Слияние по верхнему уровню: переопределяются только указанные здесь ключи. Оставьте {} — будет наследоваться без изменений.",
"previewLabel": "Итоговый конфиг",
"previewHint": "Предпросмотр того, что увидит эта привязка при срабатывании триггера (конфиг триггера + наложенное переопределение).",
"invalidJson": "Переопределение должно быть JSON-объектом.",
"tooLarge": "Размер переопределения — {size} Б, превышает серверный лимит {limit} Б.",
"errInvalidJson": "Нельзя сохранить: переопределение не является валидным JSON-объектом.",
"errTooLarge": "Нельзя сохранить: переопределение превышает серверный лимит 8 КиБ.",
"saveButton": "Сохранить переопределение",
"saving": "Сохранение…",
"resetButton": "Сбросить к наследованию",
"closeButton": "Закрыть"
}
}
}
}
}