feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s
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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Закрыть"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user