refactor(triggers): review followups — fire-now, dedupe trigger pages, hardening
Build / build (push) Failing after 34s
Build / build (push) Failing after 34s
Follow-ups on commit 39e1e36 addressing review feedback from
go-reviewer / security-reviewer / typescript-reviewer.
Backend:
- New POST /api/triggers/{id}/fire (AdminOnly, schedule-only): operator
"Fire now" button — dispatches immediately without waiting for the
next natural interval. Persists last_fired_at BEFORE dispatch, same
ordering as the scheduler. Per-trigger in-flight guard (429 if a
fire is already running) to defend against rapid double-clicks /
runaway scripts. Refuses request when AdminOnly claims are absent
rather than logging an unattributable deploy.
- SetTriggerLastFired now validates timestamp parses as RFC3339 before
writing. Rejects empty string explicitly — empty-clears semantics
were dead (no caller) and would silently re-fire on next tick if
ever accidentally written. A future reset-cadence flow must add a
dedicated ClearTriggerLastFired so the call site is grep-able and
separately auditable.
- Scheduler logs WARN on catch-up fires (now - lastFired > 2× interval)
so the "surprise burst at restart" pattern shows up in audit logs.
- BindingResult reason strings extracted to package consts
(webhook.Reason*) so the scheduler and api fire-now classifications
stay in sync without string-matching drift.
- SECURITY NOTE on FanOutForTrigger documents that the
WebhookRequireSignature gate is ingress-only by design.
Frontend:
- Refactored /triggers/new (770 LOC → 155 LOC) and /triggers/[id]
(~350 LOC dropped) to use the shared TriggerKindForm. Eliminates the
triplicated per-kind state + buildConfig + canSubmit + template that
caused the d-unit regex drift in the prior commit.
- New seedTriggerKindFormState helper on TriggerKindForm primes the
form from a server-returned trigger config with defensive type
guards; resets per-kind slots first so re-seeding across kinds
doesn't inherit stale state.
- /triggers/[id] gains a Schedule status panel with Last Fired + Fire
Now button (gated on binding_count > 0). Confirmation dialog,
result flash, timer cleanup on unmount + new-fire (no stale-closure
race). EN+RU i18n parity.
This commit is contained in:
@@ -801,6 +801,20 @@ export function regenerateTriggerWebhook(id: string): Promise<{ secret: string;
|
||||
return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`);
|
||||
}
|
||||
|
||||
export interface FireNowResponse {
|
||||
trigger: string;
|
||||
fired_at: string;
|
||||
bindings: number;
|
||||
deployed: number;
|
||||
errored: number;
|
||||
}
|
||||
|
||||
/** Fire a schedule trigger immediately without waiting for the next
|
||||
* natural fire window. Backend rejects with 400 for non-schedule kinds. */
|
||||
export function fireTriggerNow(id: string): Promise<FireNowResponse> {
|
||||
return post<FireNowResponse>(`/api/triggers/${id}/fire`);
|
||||
}
|
||||
|
||||
export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise<TriggerBinding[]> {
|
||||
return get<TriggerBinding[]>(`/api/triggers/${id}/bindings`, signal);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,84 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset every per-kind slot to its default. Called by
|
||||
* seedTriggerKindFormState before re-seeding so a caller that
|
||||
* re-seeds across kinds (draft restore, future flows) does not
|
||||
* inherit stale state from the previous kind's slots. The factory
|
||||
* defaults live in createTriggerKindFormState — we restate them
|
||||
* here rather than re-instantiating because the parent binds
|
||||
* the state object by reference. */
|
||||
function resetKindSlots(s: TriggerKindFormState): void {
|
||||
s.regImage = '';
|
||||
s.regTagPattern = '*';
|
||||
s.gitRepo = '';
|
||||
s.gitMode = 'push';
|
||||
s.gitBranch = 'main';
|
||||
s.gitTagPattern = 'v*';
|
||||
s.schInterval = '24h';
|
||||
s.schReference = '';
|
||||
}
|
||||
|
||||
/** Seed an existing form state in place from a server-returned
|
||||
* trigger config blob. Used by the /triggers/[id] edit page so the
|
||||
* same component renders identically on create + edit. Unknown
|
||||
* kinds force the advanced-JSON fallback. Typed defensively — a
|
||||
* malformed config value falls back to the default rather than
|
||||
* stringifying garbage into an input box. Safe to call repeatedly
|
||||
* across kinds: every per-kind slot is reset before the switch. */
|
||||
export function seedTriggerKindFormState(
|
||||
s: TriggerKindFormState,
|
||||
kind: string,
|
||||
name: string,
|
||||
config: unknown,
|
||||
webhookEnabled: boolean,
|
||||
webhookRequireSig: boolean
|
||||
): void {
|
||||
resetKindSlots(s);
|
||||
s.kind = kind;
|
||||
s.name = name;
|
||||
s.webhookEnabled = webhookEnabled;
|
||||
s.webhookRequireSig = webhookRequireSig;
|
||||
const cfg = (config ?? {}) as Record<string, unknown>;
|
||||
// Prime the JSON text so toggling Advanced reveals the canonical
|
||||
// shape rather than a blank box. JSON.stringify of a plain
|
||||
// object only throws on cyclic refs, which a JSON-deserialized
|
||||
// response cannot contain — no try/catch needed.
|
||||
s.jsonText = JSON.stringify(cfg, null, 2);
|
||||
// Force JSON-only mode for unknown kinds — the structured form
|
||||
// has no branch for them.
|
||||
const isKnown = (KNOWN_KINDS as readonly string[]).includes(kind);
|
||||
if (!isKnown) {
|
||||
s.useAdvancedJson = true;
|
||||
return;
|
||||
}
|
||||
s.useAdvancedJson = false;
|
||||
switch (kind) {
|
||||
case 'registry':
|
||||
s.regImage = typeof cfg.image === 'string' ? cfg.image : '';
|
||||
s.regTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : '*';
|
||||
break;
|
||||
case 'git':
|
||||
s.gitRepo = typeof cfg.repo === 'string' ? cfg.repo : '';
|
||||
// Backend Validate enforces mode ∈ {push, tag}. Anything
|
||||
// else (undefined, "PUSH" case mismatch) collapses to
|
||||
// "push" — the safe default, but worth flagging so an
|
||||
// operator who hand-edited an invalid mode in the DB
|
||||
// understands the silent rewrite.
|
||||
s.gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
|
||||
s.gitBranch = typeof cfg.branch === 'string' ? cfg.branch : 'main';
|
||||
s.gitTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : 'v*';
|
||||
break;
|
||||
case 'manual':
|
||||
// no structured fields
|
||||
break;
|
||||
case 'schedule':
|
||||
s.schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h';
|
||||
s.schReference = typeof cfg.reference === 'string' ? cfg.reference : '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTriggerInput(s: TriggerKindFormState): TriggerInput {
|
||||
let config: unknown;
|
||||
if (s.useAdvancedJson) {
|
||||
|
||||
@@ -1085,7 +1085,17 @@
|
||||
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
|
||||
"unbindConfirm": "Unbind",
|
||||
"lastFired": "Last fired",
|
||||
"lastFiredNever": "Never fired"
|
||||
"lastFiredNever": "Never fired",
|
||||
"scheduleStatus": "Schedule status",
|
||||
"scheduleStatusSub": "Operational state of the internal scheduler for this trigger. Fire-now skips ahead of the next natural window and resets the cadence to start counting from now.",
|
||||
"fireNow": "Fire now",
|
||||
"fireNowTitle": "Dispatch this trigger immediately and reset the next-fire window.",
|
||||
"fireNowDisabledTitle": "Bind at least one workload before firing.",
|
||||
"firing": "Firing…",
|
||||
"fireConfirmTitle": "Fire schedule trigger?",
|
||||
"fireConfirmMessage": "Trigger \"{name}\" will fire immediately and fan out to its {count} bound workload(s). The next natural fire window will be one full interval from now.",
|
||||
"fireConfirm": "Fire now",
|
||||
"fireResult": "Fired · deployed {deployed}/{bindings} · errored {errored}"
|
||||
},
|
||||
"form": {
|
||||
"kindLabel": "Kind",
|
||||
|
||||
@@ -1085,7 +1085,17 @@
|
||||
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
|
||||
"unbindConfirm": "Отвязать",
|
||||
"lastFired": "Последний запуск",
|
||||
"lastFiredNever": "Ни разу не срабатывал"
|
||||
"lastFiredNever": "Ни разу не срабатывал",
|
||||
"scheduleStatus": "Состояние расписания",
|
||||
"scheduleStatusSub": "Рабочее состояние внутреннего планировщика для этого триггера. «Запустить сейчас» сдвигает следующий запуск и начинает отсчёт нового интервала с этого момента.",
|
||||
"fireNow": "Запустить сейчас",
|
||||
"fireNowTitle": "Запустить триггер немедленно и сбросить окно следующего срабатывания.",
|
||||
"fireNowDisabledTitle": "Привяжите хотя бы одну нагрузку перед запуском.",
|
||||
"firing": "Запуск…",
|
||||
"fireConfirmTitle": "Запустить триггер расписания?",
|
||||
"fireConfirmMessage": "Триггер «{name}» сработает немедленно и развернёт {count} связанных нагрузок. Следующий естественный запуск будет через полный интервал от текущего момента.",
|
||||
"fireConfirm": "Запустить",
|
||||
"fireResult": "Сработал · задеплоено {deployed}/{bindings} · ошибок {errored}"
|
||||
},
|
||||
"form": {
|
||||
"kindLabel": "Вид",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import * as api from '$lib/api';
|
||||
@@ -12,6 +12,12 @@
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import TriggerKindForm, {
|
||||
createTriggerKindFormState,
|
||||
seedTriggerKindFormState,
|
||||
isTriggerFormValid,
|
||||
buildTriggerInput
|
||||
} from '$lib/components/TriggerKindForm.svelte';
|
||||
import { IconCopy, IconRefresh, IconTrash, IconExternalLink } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
@@ -21,29 +27,6 @@
|
||||
// the type checker — server validation rejects empty ids anyway.
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
|
||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ key: 'hourly', value: '1h' },
|
||||
{ key: 'daily', value: '24h' },
|
||||
{ key: 'weekly', value: '168h' }
|
||||
] as const;
|
||||
|
||||
function isValidInterval(s: string): boolean {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return false;
|
||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
||||
if (single) {
|
||||
const n = parseInt(single[1], 10);
|
||||
const unit = single[2].toLowerCase();
|
||||
if (!Number.isFinite(n) || n <= 0) return false;
|
||||
if (unit === 's' && n < 60) return false;
|
||||
return true;
|
||||
}
|
||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
function formatLastFired(ts: string): string {
|
||||
if (!ts) return $t('redeployTriggers.detail.lastFiredNever');
|
||||
const d = new Date(ts);
|
||||
@@ -70,106 +53,12 @@
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
// Form fields. Mirrors the new-page wizard but seeded from
|
||||
// the loaded trigger's config. The kind field stays read-only
|
||||
// after creation (changing kind would invalidate the config
|
||||
// shape and the server doesn't support it).
|
||||
let name = $state('');
|
||||
let webhookEnabled = $state(false);
|
||||
let webhookRequireSig = $state(true);
|
||||
let useAdvancedJson = $state(false);
|
||||
|
||||
// Per-kind structured slots.
|
||||
let regImage = $state('');
|
||||
let regTagPattern = $state('*');
|
||||
let gitRepo = $state('');
|
||||
let gitMode = $state<'push' | 'tag'>('push');
|
||||
let gitBranch = $state('main');
|
||||
let gitTagPattern = $state('v*');
|
||||
let schInterval = $state('24h');
|
||||
let schReference = $state('');
|
||||
|
||||
let jsonText = $state('');
|
||||
|
||||
const jsonValid = $derived.by(() => {
|
||||
if (!useAdvancedJson) return true;
|
||||
if (!jsonText.trim()) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function isKnownKind(k: string): k is KnownKind {
|
||||
return (KNOWN_KINDS as readonly string[]).includes(k);
|
||||
}
|
||||
|
||||
function seedFormFromConfig(trig: RedeployTrigger): void {
|
||||
name = trig.name;
|
||||
webhookEnabled = trig.webhook_enabled;
|
||||
webhookRequireSig = trig.webhook_require_signature;
|
||||
const cfg = (trig.config ?? {}) as Record<string, unknown>;
|
||||
// Always prime the JSON text so toggling Advanced never
|
||||
// flashes an empty box.
|
||||
try {
|
||||
jsonText = JSON.stringify(cfg, null, 2);
|
||||
} catch {
|
||||
jsonText = '{}';
|
||||
}
|
||||
// Fall back to advanced JSON for kinds without a structured
|
||||
// form so the operator can still edit unknown kinds safely.
|
||||
if (!isKnownKind(trig.kind)) {
|
||||
useAdvancedJson = true;
|
||||
return;
|
||||
}
|
||||
switch (trig.kind) {
|
||||
case 'registry':
|
||||
regImage = String(cfg.image ?? '');
|
||||
regTagPattern = String(cfg.tag_pattern ?? '*');
|
||||
break;
|
||||
case 'git':
|
||||
gitRepo = String(cfg.repo ?? '');
|
||||
gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
|
||||
gitBranch = String(cfg.branch ?? 'main');
|
||||
gitTagPattern = String(cfg.tag_pattern ?? 'v*');
|
||||
break;
|
||||
case 'manual':
|
||||
// no fields
|
||||
break;
|
||||
case 'schedule':
|
||||
schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h';
|
||||
schReference = typeof cfg.reference === 'string' ? cfg.reference : '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfig(): unknown {
|
||||
if (!trigger) return {};
|
||||
if (useAdvancedJson) {
|
||||
if (!jsonText.trim()) return {};
|
||||
return JSON.parse(jsonText);
|
||||
}
|
||||
switch (trigger.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 {};
|
||||
case 'schedule': {
|
||||
const ref = schReference.trim();
|
||||
return ref
|
||||
? { interval: schInterval.trim(), reference: ref }
|
||||
: { interval: schInterval.trim() };
|
||||
}
|
||||
default:
|
||||
return JSON.parse(jsonText || '{}');
|
||||
}
|
||||
}
|
||||
// All kind-aware form state lives in the shared TriggerKindForm
|
||||
// component. seedTriggerKindFormState() primes it from the loaded
|
||||
// trigger; buildTriggerInput() reads it back on save. Kind stays
|
||||
// read-only after creation — TriggerKindForm renders a static
|
||||
// kind tag when showKindPicker=false.
|
||||
let formState = $state(createTriggerKindFormState());
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading = true;
|
||||
@@ -177,7 +66,14 @@
|
||||
try {
|
||||
const tr = await api.getTrigger(id);
|
||||
trigger = tr;
|
||||
seedFormFromConfig(tr);
|
||||
seedTriggerKindFormState(
|
||||
formState,
|
||||
tr.kind,
|
||||
tr.name,
|
||||
tr.config,
|
||||
tr.webhook_enabled,
|
||||
tr.webhook_require_signature
|
||||
);
|
||||
// Fetch the webhook info only when ingress is enabled —
|
||||
// otherwise the secret/url panel stays in the disabled
|
||||
// state. Bindings always load.
|
||||
@@ -197,19 +93,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
const canSave = $derived(!!trigger && !saving && isTriggerFormValid(formState));
|
||||
|
||||
async function save(e?: Event): Promise<void> {
|
||||
e?.preventDefault();
|
||||
if (!trigger || saving) return;
|
||||
if (useAdvancedJson && !jsonValid) return;
|
||||
if (!trigger || saving || !canSave) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
try {
|
||||
// Kind is immutable post-create. The form is rendered with
|
||||
// showKindPicker=false so the UI can't mutate it, but pinning
|
||||
// to the loaded value here keeps the contract explicit and
|
||||
// guards against future regressions if someone re-enables
|
||||
// the picker on this page.
|
||||
const body: TriggerInput = {
|
||||
kind: trigger.kind,
|
||||
name: name.trim(),
|
||||
config: buildConfig(),
|
||||
webhook_enabled: webhookEnabled,
|
||||
webhook_require_signature: webhookRequireSig
|
||||
...buildTriggerInput(formState),
|
||||
kind: trigger.kind
|
||||
};
|
||||
const updated = await api.updateTrigger(id, body);
|
||||
trigger = updated;
|
||||
@@ -241,6 +140,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
let confirmFire = $state(false);
|
||||
let firing = $state(false);
|
||||
let fireResult = $state<{ deployed: number; errored: number; bindings: number } | null>(null);
|
||||
|
||||
// Auto-clear the fire-result flash after a few seconds. Tracked so
|
||||
// a rapid second fire (or component unmount) cancels the prior
|
||||
// timer instead of having two writers race to null/replace state.
|
||||
const FIRE_FLASH_MS = 5000;
|
||||
let fireFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleFireFlashClear(): void {
|
||||
if (fireFlashTimer !== null) clearTimeout(fireFlashTimer);
|
||||
fireFlashTimer = setTimeout(() => {
|
||||
fireResult = null;
|
||||
fireFlashTimer = null;
|
||||
}, FIRE_FLASH_MS);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (fireFlashTimer !== null) clearTimeout(fireFlashTimer);
|
||||
});
|
||||
|
||||
async function doFireNow(): Promise<void> {
|
||||
if (!trigger) return;
|
||||
firing = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.fireTriggerNow(trigger.id);
|
||||
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
|
||||
scheduleFireFlashClear();
|
||||
// Refresh the trigger so the "last fired" row reflects the new ts.
|
||||
trigger = await api.getTrigger(trigger.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fire failed';
|
||||
} finally {
|
||||
firing = false;
|
||||
confirmFire = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doRotate(): Promise<void> {
|
||||
rotating = true;
|
||||
error = '';
|
||||
@@ -251,7 +190,8 @@
|
||||
webhook = {
|
||||
url: res.url,
|
||||
secret: res.secret,
|
||||
webhook_require_signature: webhook?.webhook_require_signature ?? webhookRequireSig
|
||||
webhook_require_signature:
|
||||
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
||||
};
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Rotate failed';
|
||||
@@ -377,217 +317,18 @@
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="t-name" class="sub-label">{$t('redeployTriggers.form.name')}</label>
|
||||
<input id="t-name" type="text" class="input" bind:value={name} required />
|
||||
</div>
|
||||
|
||||
<!-- Kind row is read-only — changing kind would invalidate
|
||||
the config payload and isn't supported by the server. -->
|
||||
<div class="field row-meta">
|
||||
<div class="meta-block">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.kindLabel')}</span>
|
||||
<span class="kind-static">
|
||||
<span class="kind-tag mono">{$t(`redeployTriggers.kindShort.${trigger.kind}`)}</span>
|
||||
<span>{kindLabel(trigger.kind)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="adv-toggle"
|
||||
class:on={useAdvancedJson}
|
||||
onclick={() => (useAdvancedJson = !useAdvancedJson)}
|
||||
>
|
||||
{$t('redeployTriggers.form.advancedToggle')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if useAdvancedJson || !isKnownKind(trigger.kind)}
|
||||
<div class="field">
|
||||
<label for="t-json" class="sub-label">{$t('redeployTriggers.form.configJson')}</label>
|
||||
<textarea
|
||||
id="t-json"
|
||||
class="input mono code"
|
||||
class:bad={!jsonValid}
|
||||
bind:value={jsonText}
|
||||
rows="8"
|
||||
spellcheck="false"
|
||||
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}
|
||||
</div>
|
||||
{:else if trigger.kind === 'registry'}
|
||||
<div class="field">
|
||||
<label for="t-image" class="sub-label">{$t('redeployTriggers.form.image')}</label>
|
||||
<input
|
||||
id="t-image"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={regImage}
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-tag" class="sub-label">{$t('redeployTriggers.form.tagPattern')}</label>
|
||||
<input
|
||||
id="t-tag"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={regTagPattern}
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
|
||||
</div>
|
||||
{:else if trigger.kind === 'git'}
|
||||
<div class="field">
|
||||
<label for="t-repo" class="sub-label">{$t('redeployTriggers.form.repo')}</label>
|
||||
<input
|
||||
id="t-repo"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={gitRepo}
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<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'}
|
||||
<div class="field">
|
||||
<label for="t-branch" class="sub-label">{$t('redeployTriggers.form.branch')}</label>
|
||||
<input id="t-branch" type="text" class="input mono" bind:value={gitBranch} />
|
||||
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label for="t-gtag" class="sub-label">{$t('redeployTriggers.form.tagPattern')}</label>
|
||||
<input id="t-gtag" type="text" class="input mono" bind:value={gitTagPattern} />
|
||||
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if trigger.kind === 'manual'}
|
||||
<div class="note">
|
||||
<span class="note-tag">MANUAL</span>
|
||||
<p>{$t('redeployTriggers.form.manualNote')}</p>
|
||||
</div>
|
||||
{:else if trigger.kind === 'schedule'}
|
||||
<div class="note">
|
||||
<span class="note-tag">CRN</span>
|
||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
||||
<div
|
||||
class="mode-row"
|
||||
role="radiogroup"
|
||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
||||
>
|
||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={schInterval === p.value}
|
||||
class="mode-chip"
|
||||
class:active={schInterval === p.value}
|
||||
onclick={() => (schInterval = p.value)}
|
||||
>
|
||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-interval" class="sub-label">{$t('redeployTriggers.form.interval')}</label>
|
||||
<input
|
||||
id="t-interval"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class:bad={!isValidInterval(schInterval)}
|
||||
bind:value={schInterval}
|
||||
placeholder="24h"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-schref" class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</label>
|
||||
<input
|
||||
id="t-schref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={schReference}
|
||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
||||
</div>
|
||||
<div class="field schedule-status">
|
||||
<span class="sub-label">{$t('redeployTriggers.detail.lastFired')}</span>
|
||||
<span class="mono">{formatLastFired(trigger.last_fired_at)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Webhook ingress toggles live in the same form so a
|
||||
single Save commits the config + ingress state. -->
|
||||
<div class="row-toggle">
|
||||
<div class="toggle-copy">
|
||||
<span class="lbl">{$t('redeployTriggers.detail.webhookEnable')}</span>
|
||||
<p class="hint">{$t('redeployTriggers.detail.webhookEnableHint')}</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={webhookEnabled}
|
||||
label={$t('redeployTriggers.detail.webhookEnable')}
|
||||
/>
|
||||
</div>
|
||||
{#if webhookEnabled}
|
||||
<div class="row-toggle indent">
|
||||
<div class="toggle-copy">
|
||||
<span class="lbl">{$t('redeployTriggers.detail.webhookRequireSig')}</span>
|
||||
<p class="hint">{$t('redeployTriggers.detail.webhookRequireSigHint')}</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={webhookRequireSig}
|
||||
label={$t('redeployTriggers.detail.webhookRequireSig')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Kind picker is hidden on edit — kind is immutable post-
|
||||
create. Everything else (name, kind-aware config form,
|
||||
advanced JSON, webhook toggles) lives in the shared
|
||||
TriggerKindForm so /triggers/new and /triggers/[id]
|
||||
stay in lockstep. -->
|
||||
<TriggerKindForm bind:state={formState} idPrefix="t" showKindPicker={false} />
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="forge-btn"
|
||||
disabled={saving || !name.trim() || (useAdvancedJson && !jsonValid)}
|
||||
disabled={!canSave}
|
||||
aria-busy={saving}
|
||||
>
|
||||
{saving ? $t('observability.saving') : $t('observability.save')}
|
||||
@@ -595,6 +336,45 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if trigger.kind === 'schedule'}
|
||||
<!-- ── Schedule status panel ─────────────────── -->
|
||||
<section class="panel" aria-labelledby="sched-heading">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title" id="sched-heading">
|
||||
{$t('redeployTriggers.detail.scheduleStatus')}<span class="title-accent">.</span>
|
||||
</h2>
|
||||
<span class="panel-sub">{$t('redeployTriggers.detail.scheduleStatusSub')}</span>
|
||||
</header>
|
||||
|
||||
<div class="sched-row">
|
||||
<div class="sched-block">
|
||||
<span class="sub-label">{$t('redeployTriggers.detail.lastFired')}</span>
|
||||
<span class="mono">{formatLastFired(trigger.last_fired_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost xs"
|
||||
onclick={() => (confirmFire = true)}
|
||||
disabled={firing || trigger.binding_count === 0}
|
||||
title={trigger.binding_count === 0
|
||||
? $t('redeployTriggers.detail.fireNowDisabledTitle')
|
||||
: $t('redeployTriggers.detail.fireNowTitle')}
|
||||
>
|
||||
{firing ? $t('redeployTriggers.detail.firing') : $t('redeployTriggers.detail.fireNow')}
|
||||
</button>
|
||||
</div>
|
||||
{#if fireResult}
|
||||
<div class="fire-flash" role="status">
|
||||
{$t('redeployTriggers.detail.fireResult', {
|
||||
deployed: String(fireResult.deployed),
|
||||
bindings: String(fireResult.bindings),
|
||||
errored: String(fireResult.errored)
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Webhook ingress panel ───────────────────── -->
|
||||
<section class="panel" aria-labelledby="webhook-heading">
|
||||
<header class="panel-head">
|
||||
@@ -699,7 +479,7 @@
|
||||
<div class="b-actions">
|
||||
<ToggleSwitch
|
||||
checked={b.enabled}
|
||||
onchange={(next) => toggleBinding(b, next)}
|
||||
onchange={(next: boolean) => toggleBinding(b, next)}
|
||||
label={$t('redeployTriggers.binding.enabled')}
|
||||
/>
|
||||
<a
|
||||
@@ -750,7 +530,7 @@
|
||||
open={confirmDelete}
|
||||
title={$t('redeployTriggers.detail.deleteTitle')}
|
||||
message={$t('redeployTriggers.detail.deleteMessage', {
|
||||
name: name.trim() || trigger.name,
|
||||
name: formState.name.trim() || trigger.name,
|
||||
count: String(trigger.binding_count)
|
||||
})}
|
||||
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
|
||||
@@ -771,6 +551,20 @@
|
||||
oncancel={() => (confirmRotate = false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmFire}
|
||||
title={$t('redeployTriggers.detail.fireConfirmTitle')}
|
||||
message={$t('redeployTriggers.detail.fireConfirmMessage', {
|
||||
name: trigger.name,
|
||||
count: String(trigger.binding_count)
|
||||
})}
|
||||
confirmLabel={firing
|
||||
? $t('redeployTriggers.detail.firing')
|
||||
: $t('redeployTriggers.detail.fireConfirm')}
|
||||
onconfirm={doFireNow}
|
||||
oncancel={() => (confirmFire = false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmUnbindId !== null}
|
||||
title={$t('redeployTriggers.detail.unbindTitle')}
|
||||
@@ -887,6 +681,29 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Schedule status panel ─────────────────── */
|
||||
.sched-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sched-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.fire-flash {
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--forge-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--forge-accent) 45%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--forge-mono);
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────── */
|
||||
.field {
|
||||
display: flex;
|
||||
@@ -901,36 +718,6 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.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: 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);
|
||||
}
|
||||
|
||||
/* ── Hints ─────────────────────────────────────── */
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
@@ -938,98 +725,8 @@
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.hint.danger { color: var(--color-danger); }
|
||||
.hint.foot { margin-top: 0.6rem; }
|
||||
|
||||
/* ── Read-only kind row + advanced toggle ─────── */
|
||||
.row-meta {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.meta-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.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;
|
||||
align-items: center;
|
||||
padding: 0.18rem 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;
|
||||
}
|
||||
.adv-toggle {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── Mode chips ─────────────────────────────────── */
|
||||
.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.3rem 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);
|
||||
}
|
||||
|
||||
/* ── Note banner ────────────────────────────────── */
|
||||
.note {
|
||||
display: flex;
|
||||
@@ -1060,35 +757,6 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
.lbl {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Actions ────────────────────────────────────── */
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,172 +1,32 @@
|
||||
<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 TriggerKindForm, {
|
||||
createTriggerKindFormState,
|
||||
isTriggerFormValid,
|
||||
buildTriggerInput
|
||||
} from '$lib/components/TriggerKindForm.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
// Four 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', 'schedule'] as const;
|
||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
||||
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
|
||||
|
||||
// Suggested intervals for schedule triggers. Operators can always
|
||||
// type a custom Go duration ("90m", "1h30m", "168h") into the input.
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ key: 'hourly', value: '1h' },
|
||||
{ key: 'daily', value: '24h' },
|
||||
{ key: 'weekly', value: '168h' }
|
||||
] as const;
|
||||
|
||||
function isValidInterval(s: string): boolean {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return false;
|
||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
||||
if (single) {
|
||||
const n = parseInt(single[1], 10);
|
||||
const unit = single[2].toLowerCase();
|
||||
if (!Number.isFinite(n) || n <= 0) return false;
|
||||
if (unit === 's' && n < 60) return false;
|
||||
return true;
|
||||
}
|
||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
// Kind is always one of KNOWN_KINDS — the picker only emits those.
|
||||
// Keeping the literal union (no `| string`) preserves discriminated
|
||||
// narrowing inside buildConfig/canSubmit.
|
||||
let kind = $state<KnownKind>('registry');
|
||||
let name = $state('');
|
||||
let webhookEnabled = $state(false);
|
||||
let webhookRequireSig = $state(true);
|
||||
let useAdvancedJson = $state(false);
|
||||
// All kind-aware form state lives in the shared component. The page
|
||||
// just owns submit state + the navigation that follows a successful
|
||||
// create. Eliminates the per-kind state slots / buildConfig /
|
||||
// canSubmit / template duplication that previously caused regex
|
||||
// drift between /triggers/new, /triggers/[id], and TriggerKindForm.
|
||||
let formState = $state(createTriggerKindFormState({ kind: 'registry' }));
|
||||
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*');
|
||||
let schInterval = $state('24h');
|
||||
let schReference = $state('');
|
||||
|
||||
// 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 {};
|
||||
case 'schedule': {
|
||||
const ref = schReference.trim();
|
||||
return ref
|
||||
? { interval: schInterval.trim(), reference: ref }
|
||||
: { interval: schInterval.trim() };
|
||||
}
|
||||
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;
|
||||
case 'schedule':
|
||||
return isValidInterval(schInterval);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
const canSubmit = $derived(!submitting && isTriggerFormValid(formState));
|
||||
|
||||
async function submit(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!canSubmit()) return;
|
||||
if (!canSubmit) return;
|
||||
error = '';
|
||||
submitting = true;
|
||||
try {
|
||||
const body: TriggerInput = {
|
||||
kind,
|
||||
name: name.trim(),
|
||||
config: buildConfig(),
|
||||
webhook_enabled: webhookEnabled,
|
||||
webhook_require_signature: webhookRequireSig
|
||||
};
|
||||
const body = buildTriggerInput(formState);
|
||||
const created = await api.createTrigger(body);
|
||||
goto(`/triggers/${created.id}`);
|
||||
} catch (e) {
|
||||
@@ -175,12 +35,6 @@
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function kindHint(k: string): string {
|
||||
const key = `redeployTriggers.kindHint.${k}`;
|
||||
const v = $t(key);
|
||||
return v === key ? '' : v;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -208,293 +62,14 @@
|
||||
</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 if kind === 'schedule'}
|
||||
<div class="note">
|
||||
<span class="note-tag">CRN</span>
|
||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
||||
<div
|
||||
class="mode-row"
|
||||
role="radiogroup"
|
||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
||||
>
|
||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={schInterval === p.value}
|
||||
class="mode-chip"
|
||||
class:active={schInterval === p.value}
|
||||
onclick={() => (schInterval = p.value)}
|
||||
>
|
||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="trig-interval">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.interval')}</span>
|
||||
<input
|
||||
id="trig-interval"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class:bad={!isValidInterval(schInterval)}
|
||||
bind:value={schInterval}
|
||||
placeholder="24h"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="trig-schref">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</span>
|
||||
<input
|
||||
id="trig-schref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={schReference}
|
||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
||||
</label>
|
||||
{: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>
|
||||
<TriggerKindForm bind:state={formState} idPrefix="trig" />
|
||||
|
||||
<div class="actions">
|
||||
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="forge-btn"
|
||||
disabled={!canSubmit()}
|
||||
disabled={!canSubmit}
|
||||
aria-busy={submitting}
|
||||
>
|
||||
{submitting
|
||||
@@ -529,7 +104,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Alert ─────────────────────────────────────── */
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
@@ -557,289 +131,6 @@
|
||||
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(4, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.kind-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user