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": "Закрыть"
}
}
}
}
}
+2 -1
View File
@@ -44,7 +44,8 @@
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Triggers' },
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' },
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
];
File diff suppressed because it is too large Load Diff
+305 -117
View File
@@ -2,19 +2,43 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { HookKinds, PluginWorkloadInput } from '$lib/types';
import type { RedeployTrigger } from '$lib/api';
import * as api from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
buildTriggerInput
} from '$lib/components/TriggerKindForm.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// `triggers` is no longer hardcoded into the workload row — the
// kind list is kept on `kinds` only for reference but the wizard
// now composes a standalone Trigger record (or picks one) and
// binds it after the workload is created.
let kinds = $state<HookKinds>({ sources: [], triggers: [] });
let name = $state('');
let sourceKind = $state('image');
let triggerKind = $state('manual');
let sourceConfig = $state('{}');
let triggerConfig = $state('{}');
let publicSubdomain = $state('');
let publicDomain = $state('');
let publicPort = $state(0);
// Trigger UX modes — three branches:
// inline → create a new trigger inline; bind after workload create.
// pick → select an existing trigger; bind after workload create.
// skip → create the workload without any binding.
type TriggerMode = 'inline' | 'pick' | 'skip';
let triggerMode = $state<TriggerMode>('inline');
let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
// Existing-trigger picker. `existingTriggers` is loaded lazily on
// mount; if the request fails the operator can still create one
// inline or skip altogether — we don't block the wizard.
let existingTriggers = $state<RedeployTrigger[]>([]);
let pickedTriggerId = $state('');
// Kind-aware compose editor — the raw-JSON textarea forces users to
// hand-escape YAML inside a JSON string, which is unusable. When the
// source plugin is "compose" we surface a dedicated YAML textarea and
@@ -79,7 +103,7 @@
schemaCache.set(kind, text);
return text;
} catch {
return sourceConfigSample(kind) || triggerConfigSample(kind) || '{}';
return sourceConfigSample(kind) || '{}';
}
}
@@ -274,11 +298,7 @@
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
sourceKind = kinds.sources[0];
}
if (kinds.triggers.length > 0 && !kinds.triggers.includes(triggerKind)) {
triggerKind = kinds.triggers[0];
}
sourceConfig = await fetchSampleJSON(sourceKind);
triggerConfig = await fetchSampleJSON(triggerKind);
if (sourceKind === 'compose') {
seedComposeFromJSON(sourceConfig);
}
@@ -296,6 +316,14 @@
} catch {
registries = [];
}
// Best-effort fetch of existing triggers — feeds the
// "Pick existing" mode. Failure leaves the picker empty
// and the operator can still create one inline.
try {
existingTriggers = await api.listTriggers();
} catch {
existingTriggers = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
} finally {
@@ -350,26 +378,6 @@
}
}
function triggerConfigSample(kind: string): string {
switch (kind) {
case 'registry':
return JSON.stringify(
{ image: 'registry.example.com/owner/app', tag_pattern: 'v*' },
null,
2
);
case 'git':
return JSON.stringify(
{ repo: 'owner/repo', mode: 'push', branch: 'main', tag_pattern: '' },
null,
2
);
case 'manual':
default:
return '{}';
}
}
async function onSourceChange() {
sourceConfig = await fetchSampleJSON(sourceKind);
// Switching INTO compose / image seeds the form fields from
@@ -389,9 +397,6 @@
advancedJson = false;
}
}
async function onTriggerChange() {
triggerConfig = await fetchSampleJSON(triggerKind);
}
// Toggle between the kind-aware form and the raw JSON editor.
// Direction matters: going to Advanced JSON commits the form fields
@@ -423,12 +428,17 @@
}
}
const sourceValid = $derived(jsonOk(sourceConfig));
const triggerValid = $derived(jsonOk(triggerConfig));
const sourceLines = $derived(sourceConfig.split('\n').length);
const sourceBytes = $derived(new Blob([sourceConfig]).size);
const triggerLines = $derived(triggerConfig.split('\n').length);
const triggerBytes = $derived(new Blob([triggerConfig]).size);
// Trigger-step validity. Inline mode requires a complete kind+name+config;
// pick mode requires a chosen trigger; skip mode is always valid.
const triggerStepValid = $derived.by(() => {
if (triggerMode === 'skip') return true;
if (triggerMode === 'pick') return !!pickedTriggerId;
return isTriggerFormValid(triggerForm);
});
async function submit(e: Event) {
e.preventDefault();
@@ -436,7 +446,6 @@
submitting = true;
try {
let parsedSource: unknown;
let parsedTrigger: unknown;
if (useComposeForm) {
// Form fields are typed primitives — no parse step
// needed. compose_yaml passes through verbatim; the
@@ -459,18 +468,17 @@
throw new Error('source_config is not valid JSON');
}
}
try {
parsedTrigger = JSON.parse(triggerConfig);
} catch {
throw new Error('trigger_config is not valid JSON');
}
// Triggers no longer ride on the workload row; the backend
// still accepts the legacy fields but the new code path
// passes a manual placeholder + empty config and binds a
// real Trigger record after creation.
const body: PluginWorkloadInput = {
name: name.trim(),
source_kind: sourceKind,
source_config: parsedSource,
trigger_kind: triggerKind,
trigger_config: parsedTrigger
trigger_kind: '',
trigger_config: {}
};
if (publicSubdomain || publicDomain || publicPort > 0) {
body.public_faces = [
@@ -486,6 +494,35 @@
}
const created = await api.createPluginWorkload(body);
// Bind a trigger to the freshly-created workload. Keep going
// to the detail page even on bind failure — the operator can
// retry from the workload's Triggers panel without losing
// their work.
if (triggerMode === 'inline' || triggerMode === 'pick') {
try {
if (triggerMode === 'inline') {
const inline = buildTriggerInput(triggerForm);
await api.bindTriggerToWorkload(created.id, { inline });
} else if (pickedTriggerId) {
await api.bindTriggerToWorkload(created.id, {
trigger_id: pickedTriggerId
});
}
} catch (be) {
const msg = be instanceof Error ? be.message : 'unknown';
// Surface the bind failure to the user, then still
// route to the detail page where they can retry.
try {
sessionStorage.setItem(
`tinyforge.bindError.${created.id}`,
$t('apps.new.triggers.bindError', { error: msg })
);
} catch {
// session storage may be disabled — ignore.
}
}
}
goto(`/apps/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
@@ -502,8 +539,7 @@
<div class="forge">
{#snippet newLede()}
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
<em>Trigger</em> = when it redeploys (registry push, git push, manual). Both axes are
independently extensible.
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
{/snippet}
<ForgeHero
@@ -553,39 +589,25 @@
<div class="field">
<div class="field-label">
<span class="num">02</span>
<span class="lbl">Plugins</span>
<span class="opt">SOURCE × TRIGGER</span>
</div>
<div class="row">
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<select
id="app-source"
class="input"
bind:value={sourceKind}
onchange={onSourceChange}
>
{#each kinds.sources as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<label class="sub" for="app-trigger">
<span class="sub-label">Trigger</span>
<select
id="app-trigger"
class="input"
bind:value={triggerKind}
onchange={onTriggerChange}
>
{#each kinds.triggers as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<span class="lbl">Source plugin</span>
<span class="opt">REQUIRED</span>
</div>
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<select
id="app-source"
class="input"
bind:value={sourceKind}
onchange={onSourceChange}
>
{#each kinds.sources as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<p class="hint">
Both pickers are populated from the running daemon — only plugins compiled in show up.
Populated from the running daemon — only plugins compiled in show up. Triggers
(registry / git / manual) are configured below as standalone records.
</p>
</div>
@@ -921,10 +943,10 @@
</span>
</label>
</fieldset>
<label class="checkbox-row">
<input
type="checkbox"
<label class="toggle-row">
<ToggleSwitch
bind:checked={staticRenderMarkdown}
label="Render markdown"
/>
<span>
<strong>Render markdown</strong> — auto-render <code>.md</code>
@@ -982,45 +1004,108 @@
{/if}
</div>
<div class="field">
<div class="field-label">
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num">04</span>
<span class="lbl">Trigger config</span>
<span class="req">JSON</span>
<span class="lbl">{$t('apps.new.triggers.section')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
<!-- Mode selector — three short cards. The "active" card
reveals its sub-form below. Skipping is explicit so
users can ship the workload now and wire triggers
later from the detail page. -->
<div
class="trig-mode-row"
role="radiogroup"
aria-label={$t('apps.new.triggers.section')}
>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'inline'}
class="trig-mode-card"
class:active={triggerMode === 'inline'}
onclick={() => (triggerMode = 'inline')}
>
<span class="trig-mode-tag mono">NEW</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'pick'}
class="trig-mode-card"
class:active={triggerMode === 'pick'}
onclick={() => (triggerMode = 'pick')}
>
<span class="trig-mode-tag mono">PICK</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'skip'}
class="trig-mode-card"
class:active={triggerMode === 'skip'}
onclick={() => (triggerMode = 'skip')}
>
<span class="trig-mode-tag mono">SKIP</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
</button>
</div>
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">trigger_config.json · {triggerKind}</span>
<span class="spacer"></span>
<button
type="button"
class="editor-chip"
onclick={() => (triggerConfig = triggerConfigSample(triggerKind))}
>
Reset sample
</button>
{#if triggerMode === 'inline'}
<div class="trig-sub">
<TriggerKindForm
bind:state={triggerForm}
idPrefix="app-trig"
showName={true}
showWebhook={true}
showKindPicker={true}
/>
</div>
<textarea
id="app-trigger-config"
bind:value={triggerConfig}
rows="7"
spellcheck="false"
class="code-area"
aria-label="Trigger plugin configuration (JSON)"
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!triggerValid}>
<span class="foot-dot" aria-hidden="true"></span>
{triggerValid ? 'JSON OK' : 'JSON INVALID'}
</span>
<span class="sep">·</span>
<span>{triggerLines} lines</span>
<span class="sep">·</span>
<span>{triggerBytes} B</span>
{:else if triggerMode === 'pick'}
<div class="trig-sub">
{#if existingTriggers.length === 0}
<div class="note muted-note">
<span class="note-tag"></span>
<p>{$t('apps.new.triggers.pickEmpty')}</p>
</div>
{:else}
<label class="sub" for="app-trig-pick">
<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
<select
id="app-trig-pick"
class="input"
bind:value={pickedTriggerId}
>
<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
{#each existingTriggers as tr (tr.id)}
<option value={tr.id}>
{tr.name} · {tr.kind}{tr.webhook_enabled
? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
: ''}
</option>
{/each}
</select>
<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
</label>
{/if}
</div>
</div>
</div>
{:else}
<div class="trig-sub">
<div class="note muted-note">
<span class="note-tag">SKIP</span>
<p>{$t('apps.new.triggers.skippedNote')}</p>
</div>
</div>
{/if}
</fieldset>
<fieldset class="field group">
<legend class="field-label as-legend">
@@ -1074,7 +1159,7 @@
<button
class="btn-primary"
type="submit"
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerValid}
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
>
<span>{submitting ? 'Forging…' : 'Forge app'}</span>
<span class="arrow" aria-hidden="true"></span>
@@ -1553,16 +1638,15 @@
color: var(--text-secondary);
cursor: pointer;
}
.radio input,
.checkbox-row input {
.radio input {
margin-top: 0.18rem;
accent-color: var(--color-brand-500);
}
.radio strong,
.checkbox-row strong {
.toggle-row strong {
color: var(--text-primary);
}
.checkbox-row {
.toggle-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
@@ -1571,4 +1655,108 @@
color: var(--text-secondary);
cursor: pointer;
}
.toggle-row :global(.toggle-switch) {
margin-top: 0.1rem;
}
/* ── Trigger mode picker ──────────────────────────
Three short cards (NEW / PICK / SKIP). The active
card lights up its tag in brand colour and reveals
the matching sub-form below in a soft inset panel. */
.trig-mode-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 720px) {
.trig-mode-row {
grid-template-columns: 1fr;
}
}
.trig-mode-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.85rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease,
transform 150ms ease;
}
.trig-mode-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.trig-mode-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.trig-mode-tag {
display: inline-flex;
align-self: flex-start;
padding: 0.18rem 0.5rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.trig-mode-card.active .trig-mode-tag {
background: var(--forge-accent);
}
.trig-mode-name {
font-weight: 600;
font-size: 0.92rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.trig-mode-hint {
font-size: 0.7rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.trig-sub {
margin-top: 0.2rem;
padding: 0.95rem 1rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
}
.note {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.65rem 0.85rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.muted-note {
background: transparent;
}
.note-tag {
padding: 0.16rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
}
</style>
+649
View File
@@ -0,0 +1,649 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { RedeployTrigger } from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
// Known kinds drive the kind-aware form switch in /new and the
// filter chips here. Future kinds are tolerated: an unknown kind
// renders with a generic label + grey badge instead of dropping
// the row.
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
let triggers = $state<RedeployTrigger[]>([]);
let loading = $state(true);
let error = $state('');
let kindFilter = $state<'all' | KnownKind | string>('all');
const filtered = $derived(
kindFilter === 'all' ? triggers : triggers.filter((t) => t.kind === kindFilter)
);
const withWebhook = $derived(triggers.filter((t) => t.webhook_enabled).length);
const totalBindings = $derived(
triggers.reduce((sum, t) => sum + (t.binding_count ?? 0), 0)
);
// Group triggers by kind for the hero stat rail. Caps to first
// three kinds + a roll-up so the rail stays single-line on
// narrow screens; the chip filter row exposes the full breakdown.
const byKind = $derived.by(() => {
const acc: Record<string, number> = {};
for (const t of triggers) acc[t.kind] = (acc[t.kind] ?? 0) + 1;
return acc;
});
const presentKinds = $derived(Object.keys(byKind));
function kindLabel(k: string): string {
const key = `redeployTriggers.kind.${k}`;
const label = $t(key);
return label === key ? k : label;
}
function kindShort(k: string): string {
const key = `redeployTriggers.kindShort.${k}`;
const label = $t(key);
return label === key ? k.slice(0, 3).toUpperCase() : label;
}
function kindClass(k: string): string {
// CSS-only kind colour. Falls through to the neutral
// `kind-other` style for unknown kinds so the row still
// renders cleanly.
return KNOWN_KINDS.includes(k as KnownKind) ? `kind-${k}` : 'kind-other';
}
function fmtCreated(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit'
});
} catch {
return iso;
}
}
async function load(): Promise<void> {
loading = true;
error = '';
try {
triggers = await api.listTriggers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load triggers';
} finally {
loading = false;
}
}
onMount(load);
</script>
<svelte:head>
<title>{$t('redeployTriggers.title')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
{#snippet toolbar()}
<button
class="forge-btn-icon"
onclick={load}
aria-label={$t('observability.refresh')}
disabled={loading}
>
<IconRefresh size={16} />
</button>
<a href="/triggers/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('redeployTriggers.toolbar.newButton')}</span>
</a>
{/snippet}
{#snippet stats()}
<div>
<dt>{$t('redeployTriggers.stat.total')}</dt>
<dd>{loading ? '—' : String(triggers.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('redeployTriggers.stat.withWebhook')}</dt>
<dd class="accent">{loading ? '—' : String(withWebhook).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('redeployTriggers.stat.boundWorkloads')}</dt>
<dd>{loading ? '—' : String(totalBindings).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet lede()}
{$t('redeployTriggers.lede')}
{/snippet}
<ForgeHero
eyebrowSuffix={$t('redeployTriggers.section').toUpperCase()}
title={$t('redeployTriggers.title')}
size="lg"
toolbar={toolbar}
lede_html={lede}
stats={stats}
/>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if !loading && triggers.length > 0}
<!-- Kind filter chips. ALL is always present; per-kind chips are
rendered only for kinds present in the result set so the
row stays scannable when the operator only uses two kinds. -->
<div class="filter-row" role="group" aria-label={$t('redeployTriggers.filter.ariaLabel')}>
<button
type="button"
class="chip"
class:active={kindFilter === 'all'}
aria-pressed={kindFilter === 'all'}
onclick={() => (kindFilter = 'all')}
>
<span class="chip-label">{$t('redeployTriggers.filter.all')}</span>
<span class="chip-count">{String(triggers.length).padStart(2, '0')}</span>
</button>
{#each presentKinds as k}
<button
type="button"
class="chip"
class:active={kindFilter === k}
aria-pressed={kindFilter === k}
onclick={() => (kindFilter = k)}
>
<span class="chip-label">{kindLabel(k)}</span>
<span class="chip-count">{String(byKind[k]).padStart(2, '0')}</span>
</button>
{/each}
</div>
{/if}
{#if loading}
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
{#each Array(3) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else if triggers.length === 0}
<div class="empty">
<div class="empty-mark" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<h2>{$t('redeployTriggers.empty.heading')}</h2>
<p>{$t('redeployTriggers.empty.body')}</p>
<a href="/triggers/new" class="forge-btn">
<IconPlus size={14} /><span>{$t('redeployTriggers.empty.cta')}</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('redeployTriggers.list.name')}</th>
<th>{$t('redeployTriggers.list.kind')}</th>
<th>{$t('redeployTriggers.list.bindings')}</th>
<th>{$t('redeployTriggers.list.webhook')}</th>
<th class="hide-md">{$t('redeployTriggers.list.created')}</th>
<th class="t-right">{$t('redeployTriggers.list.open')}</th>
</tr>
</thead>
<tbody>
{#each filtered as trig, i (trig.id)}
<tr>
<td>
<a class="row-link" href={`/triggers/${trig.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{trig.name}</span>
</a>
</td>
<td>
<span class="badge kind {kindClass(trig.kind)}">
<span class="kind-tag">{kindShort(trig.kind)}</span>
<span class="kind-name">{kindLabel(trig.kind)}</span>
</span>
</td>
<td>
{#if trig.binding_count > 0}
<span class="bindings-pill" title={String(trig.binding_count)}>
<span class="bp-num">{trig.binding_count}</span>
<span class="bp-bar" aria-hidden="true">
{#each Array(Math.min(trig.binding_count, 6)) as _}
<span></span>
{/each}
</span>
</span>
{:else}
<span class="muted mono small">{$t('redeployTriggers.list.noBindings')}</span>
{/if}
</td>
<td>
<span class="status" class:on={trig.webhook_enabled} class:off={!trig.webhook_enabled}>
<span class="status-dot" aria-hidden="true"></span>
{trig.webhook_enabled
? $t('redeployTriggers.list.webhookOn')
: $t('redeployTriggers.list.webhookOff')}
</span>
</td>
<td class="muted mono small hide-md">{fmtCreated(trig.created_at)}</td>
<td class="actions-cell">
<a class="row-action" href={`/triggers/${trig.id}`}>
{$t('observability.open')} <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1100px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Filter chips ──────────────────────────────── */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
cursor: pointer;
color: var(--text-secondary);
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
transform 150ms ease;
}
.chip:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.chip.active {
background: var(--text-primary);
color: var(--surface-card);
border-color: var(--text-primary);
}
.chip-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.chip-count {
font-family: var(--forge-mono);
font-size: 0.6rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 52px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex;
gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--forge-accent);
animation: ember 2.4s ease-in-out infinite;
}
@keyframes ember {
0%,
100% {
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
}
.empty h2 {
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.empty p {
color: var(--text-secondary);
margin: 0 auto 1.5rem;
font-size: 0.95rem;
max-width: 52ch;
line-height: 1.5;
}
/* ── Table ─────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: var(--surface-card);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-wrap :global(.forge-table) {
min-width: 720px;
}
.t-right {
text-align: right;
}
.actions-cell {
text-align: right;
}
@media (max-width: 820px) {
.hide-md {
display: none;
}
}
/* ── Row link / action ─────────────────────────── */
.row-link {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
color: var(--text-primary);
text-decoration: none;
transition: color 120ms ease;
}
.row-link:hover {
color: var(--forge-accent);
}
.row-link:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-ref {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.row-name {
font-weight: 600;
}
.row-action {
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--forge-accent);
text-decoration: none;
}
.row-action:hover {
color: var(--color-brand-500);
}
.arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
/* ── Kind badge ─────────────────────────────────
Two-segment pill: a tight monospace tag (REG, GIT…)
followed by the human-readable kind name. The tag
carries the colour so the eye can pick out the kind
even when the operator filters all rows down to two
kinds and the names line up.
*/
.badge.kind {
display: inline-flex;
align-items: stretch;
overflow: hidden;
border-radius: var(--radius-full);
border: 1px solid var(--border-primary);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.kind-tag {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.5rem;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--surface-card);
background: var(--text-primary);
}
.kind-name {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem 0.18rem 0.5rem;
}
/* Per-kind colour. The pattern matches the rest of the app:
coloured tag + soft-tinted body that reads in both themes. */
.badge.kind.kind-registry .kind-tag {
background: var(--color-brand-600);
}
.badge.kind.kind-registry {
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
border-color: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
color: var(--color-brand-600);
}
.badge.kind.kind-git .kind-tag {
background: #6b46c1;
}
.badge.kind.kind-git {
background: color-mix(in srgb, #8b5cf6 12%, transparent);
border-color: color-mix(in srgb, #8b5cf6 32%, transparent);
color: #6b46c1;
}
:global([data-theme='dark']) .badge.kind.kind-git {
color: #c4b5fd;
}
.badge.kind.kind-manual .kind-tag {
background: var(--text-secondary);
}
.badge.kind.kind-schedule .kind-tag {
background: var(--color-warning, #f59e0b);
}
.badge.kind.kind-schedule {
background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent);
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
color: var(--color-warning-dark, #b45309);
}
.badge.kind.kind-webhook .kind-tag {
background: var(--forge-accent);
}
.badge.kind.kind-webhook {
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
color: var(--forge-accent);
}
.badge.kind.kind-logscan .kind-tag {
background: #0891b2;
}
.badge.kind.kind-logscan {
background: color-mix(in srgb, #06b6d4 12%, transparent);
border-color: color-mix(in srgb, #06b6d4 32%, transparent);
color: #0e7490;
}
:global([data-theme='dark']) .badge.kind.kind-logscan {
color: #67e8f9;
}
/* ── Bindings pill ──────────────────────────────
Number + miniature segmented bar that visually
communicates fan-out without taking a whole column.
*/
.bindings-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.18rem 0.55rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
}
.bp-num {
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.bp-bar {
display: inline-flex;
gap: 2px;
}
.bp-bar span {
width: 4px;
height: 10px;
background: var(--forge-accent);
border-radius: 1px;
opacity: 0.85;
}
.bp-bar span:nth-child(2) { opacity: 0.7; }
.bp-bar span:nth-child(3) { opacity: 0.6; }
.bp-bar span:nth-child(4) { opacity: 0.5; }
.bp-bar span:nth-child(5) { opacity: 0.4; }
.bp-bar span:nth-child(6) { opacity: 0.3; }
.small {
font-size: 0.72rem;
}
/* ── Status (webhook on/off) ──────────────────── */
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status.on {
color: var(--color-success-dark);
}
.status.on .status-dot {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
}
.status.off {
color: var(--text-tertiary);
}
.status.off .status-dot {
background: var(--text-tertiary);
opacity: 0.5;
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
}
</style>
File diff suppressed because it is too large Load Diff
+767
View File
@@ -0,0 +1,767 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { TriggerInput } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// Three kinds have hand-rolled forms today; anything else falls
// back to the JSON editor. KNOWN_KINDS gates the structured form
// switch — see formNote() for the manual/unknown explainer text.
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
let kind = $state<KnownKind | string>('registry');
let name = $state('');
let webhookEnabled = $state(false);
let webhookRequireSig = $state(true);
let useAdvancedJson = $state(false);
let submitting = $state(false);
let error = $state('');
// Per-kind structured fields. They mirror the Go config shapes
// documented in the parent task description — see TriggerInput
// in $lib/api. Keeping them as separate $state slots lets the
// kind switch persist values across kind flips (operator typo
// recovery) without juggling a discriminated union.
let regImage = $state('');
let regTagPattern = $state('*');
let gitRepo = $state('');
let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main');
let gitTagPattern = $state('v*');
// Advanced JSON editor — primed with the sample shape for the
// current kind on first toggle so the operator has something to
// edit. We only auto-prime when the field is blank to avoid
// nuking deliberate edits on re-toggle.
let jsonText = $state('');
let jsonLoading = $state(false);
const jsonValid = $derived.by(() => {
if (!useAdvancedJson) return true;
if (!jsonText.trim()) return true; // blank treated as empty object server-side
try {
const parsed = JSON.parse(jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function buildConfig(): unknown {
if (useAdvancedJson) {
if (!jsonText.trim()) return {};
return JSON.parse(jsonText);
}
switch (kind) {
case 'registry':
return {
image: regImage.trim(),
tag_pattern: regTagPattern.trim() || '*'
};
case 'git':
return gitMode === 'push'
? { repo: gitRepo.trim(), mode: 'push', branch: gitBranch.trim() || 'main' }
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual':
return {};
default:
// Unknown kind reached the structured path — fall back
// to an empty object; advanced JSON would normally be
// on by this point.
return {};
}
}
function canSubmit(): boolean {
if (submitting) return false;
if (!name.trim()) return false;
if (useAdvancedJson) return jsonValid;
switch (kind) {
case 'registry':
return !!regImage.trim();
case 'git':
return !!gitRepo.trim();
case 'manual':
return true;
default:
return false; // unknown kinds force advanced JSON
}
}
async function loadSampleIntoJson(): Promise<void> {
jsonLoading = true;
try {
const schema = await api.getHookKindSchema(kind);
jsonText = JSON.stringify(schema.sample ?? {}, null, 2);
} catch {
// Best-effort prime — operator can paste their own.
jsonText = '{\n \n}';
} finally {
jsonLoading = false;
}
}
function toggleAdvanced(): void {
useAdvancedJson = !useAdvancedJson;
if (useAdvancedJson && !jsonText.trim()) {
// Seed with current structured values (or schema sample
// as fallback) so the operator can refine instead of
// retyping.
try {
jsonText = JSON.stringify(buildConfig(), null, 2);
} catch {
void loadSampleIntoJson();
}
}
}
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit()) return;
error = '';
submitting = true;
try {
const body: TriggerInput = {
kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
};
const created = await api.createTrigger(body);
goto(`/triggers/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
</script>
<svelte:head>
<title>{$t('redeployTriggers.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('redeployTriggers.ledeNew')}
{/snippet}
<ForgeHero
backHref="/triggers"
backLabel={$t('redeployTriggers.toolbar.backToList')}
eyebrowSuffix={$t('redeployTriggers.toolbar.newButton').toUpperCase()}
title={$t('redeployTriggers.titleNew')}
size="lg"
lede_html={lede}
/>
<form onsubmit={submit} class="form" novalidate aria-busy={submitting}>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
<!-- Step 01 · Kind picker. Renders as a grid of square cards
so the kind is the first visual commitment of the wizard. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('redeployTriggers.form.kindLabel')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</legend>
<p class="hint">{$t('redeployTriggers.form.kindHint')}</p>
<div class="kind-grid" role="radiogroup" aria-label={$t('redeployTriggers.form.kindLabel')}>
{#each ALL_PICKABLE as k}
<button
type="button"
role="radio"
aria-checked={kind === k}
class="kind-card"
class:active={kind === k}
onclick={() => (kind = k)}
>
<span class="kind-card-tag mono">{$t(`redeployTriggers.kindShort.${k}`)}</span>
<span class="kind-card-name">{$t(`redeployTriggers.kind.${k}`)}</span>
<span class="kind-card-hint">{kindHint(k)}</span>
</button>
{/each}
</div>
</fieldset>
<!-- Step 02 · Name. -->
<div class="field">
<label for="trig-name" class="field-label">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('redeployTriggers.form.name')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="trig-name"
type="text"
bind:value={name}
class="input"
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
<!-- Step 03 · Config — kind-aware switch. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if useAdvancedJson}
<p class="hint">{$t('redeployTriggers.form.advancedHint')}</p>
<label class="sub" for="trig-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="trig-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={jsonText}
rows="8"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
aria-describedby={!jsonValid ? 'trig-json-err' : 'trig-json-hint'}
></textarea>
<span id="trig-json-hint" class="hint">
{$t('redeployTriggers.form.configJsonHint')}
{#if jsonLoading} <em>· loading sample…</em>{/if}
</span>
{#if !jsonValid}
<span id="trig-json-err" class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{/if}
</label>
{:else if kind === 'registry'}
<label class="sub" for="trig-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="trig-image"
type="text"
class="input mono"
bind:value={regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="trig-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-tag"
type="text"
class="input mono"
bind:value={regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if kind === 'git'}
<label class="sub" for="trig-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="trig-repo"
type="text"
class="input mono"
bind:value={gitRepo}
placeholder={$t('redeployTriggers.form.repoPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
</label>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.mode')}</span>
<div class="mode-row" role="radiogroup" aria-label={$t('redeployTriggers.form.mode')}>
<button
type="button"
role="radio"
aria-checked={gitMode === 'push'}
class="mode-chip"
class:active={gitMode === 'push'}
onclick={() => (gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={gitMode === 'tag'}
class="mode-chip"
class:active={gitMode === 'tag'}
onclick={() => (gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if gitMode === 'push'}
<label class="sub" for="trig-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="trig-branch"
type="text"
class="input mono"
bind:value={gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="trig-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-gtag"
type="text"
class="input mono"
bind:value={gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if kind === 'manual'}
<div class="note">
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</div>
{:else}
<div class="note">
<span class="note-tag">?</span>
<p>{$t('redeployTriggers.form.unknownNote')}</p>
</div>
{/if}
</fieldset>
<!-- Step 04 · Webhook ingress. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">04</span>
<span class="lbl">{$t('redeployTriggers.detail.webhook')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookEnabled')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookEnabledHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if webhookEnabled}
<div class="row-toggle indent">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookRequireSig')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookRequireSigHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
<div class="actions">
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={!canSubmit()}
aria-busy={submitting}
>
{submitting
? $t('redeployTriggers.form.submitting')
: $t('redeployTriggers.form.submit')}
</button>
</div>
</form>
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 760px;
margin: 0 auto;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
@media (max-width: 600px) {
.form {
padding: 1.1rem;
gap: 1.25rem;
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Field structure ────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group { gap: 0.75rem; }
.field-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0;
}
.field-label.as-legend { float: none; width: 100%; }
.num {
display: inline-flex;
width: 26px; height: 26px;
justify-content: center; align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem; font-weight: 700;
flex: 0 0 auto;
}
.lbl {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--text-primary);
}
.lbl.small { font-size: 0.95rem; }
.req, .opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req { color: var(--color-danger); }
.opt { color: var(--text-tertiary); }
/* Advanced JSON pill-toggle lives in the same legend row as
the section number. Visually it's a quiet outlined button
that fills in when active. */
.adv-toggle {
margin-left: auto;
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
}
.adv-toggle:hover {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.adv-toggle.on {
background: var(--forge-accent);
border-color: var(--forge-accent);
color: var(--surface-card);
}
/* ── Inputs ─────────────────────────────────────── */
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.6rem 0.8rem;
font-size: 0.92rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono { font-family: var(--forge-mono); font-size: 0.85rem; }
.input.code {
resize: vertical;
min-height: 140px;
line-height: 1.5;
}
.input.bad { border-color: var(--color-danger); }
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger { color: var(--color-danger); }
.hint em {
font-style: italic;
color: var(--forge-accent);
}
/* ── Note banner (manual/unknown) ─────────────────── */
.note {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.note-tag {
padding: 0.18rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── Kind picker grid ─────────────────────────────
Each card has a monospace tag and a soft name. The
active card lights up the tag in brand colour and
adds a subtle inner glow so the choice is obvious. */
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
@media (max-width: 600px) {
.kind-grid { grid-template-columns: 1fr; }
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 0.9rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease,
box-shadow 150ms ease;
position: relative;
overflow: hidden;
}
.kind-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.kind-card-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Mode chips (git push vs tag) ─────────────── */
.mode-row {
display: inline-flex;
gap: 0;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
padding: 2px;
width: fit-content;
}
.mode-chip {
padding: 0.32rem 0.85rem;
background: transparent;
border: 0;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.mode-chip:hover { color: var(--text-primary); }
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border-primary);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.1rem;
padding-left: 1rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Actions ────────────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
}
</style>