ec8c0cd891
Build / build (push) Successful in 11m31s
Trigger detail re-fetched on every visit, flashing a skeleton. Warm-seed the trigger + config form from triggerDetailCache so the hero + form render instantly on revisit; gate the webhook + bindings panels on a detailsLoaded flag (loading row until their data lands) so they never flash a wrong "OFF"/"empty" state. The rotated webhook secret lives in the separate (uncached) webhook object and is always fetched fresh — never persisted. Hardened against reused-component nav races (review): a monotonic loadSeq token makes the latest load() the sole writer (fixes A→B→A same-id concurrency), and each cache-writing mutation handler pins the id for its round-trip so a mid-mutation nav can't poison another id's cache entry or surface its secret.
1035 lines
31 KiB
Svelte
1035 lines
31 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/stores';
|
|
import { get } from 'svelte/store';
|
|
import * as api from '$lib/api';
|
|
import type {
|
|
RedeployTrigger,
|
|
TriggerBinding,
|
|
TriggerInput,
|
|
TriggerWebhook
|
|
} from '$lib/api';
|
|
import { triggerDetailCache } from '$lib/stores/caches';
|
|
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';
|
|
|
|
// Trigger ids are server-generated (string). The route guarantees
|
|
// `id` is set when this page renders, but SvelteKit types it as
|
|
// `string | undefined`; the empty-string fallback simply quiets
|
|
// the type checker — server validation rejects empty ids anyway.
|
|
const id = $derived($page.params.id ?? '');
|
|
|
|
function formatLastFired(ts: string): string {
|
|
if (!ts) return $t('redeployTriggers.detail.lastFiredNever');
|
|
const d = new Date(ts);
|
|
// Defensive: a malformed timestamp from a future writer should
|
|
// not leak raw bytes into the UI. Fall back to the never-fired
|
|
// label rather than render an unparseable string.
|
|
if (Number.isNaN(d.getTime())) return $t('redeployTriggers.detail.lastFiredNever');
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
// Warm-seed the trigger from the per-id cache so the hero + config form
|
|
// render instantly on revisit; `loading` clears as soon as the trigger is
|
|
// ready. The webhook/bindings panels gate on `detailsLoaded` instead, since
|
|
// the webhook carries a rotated secret and is never cached.
|
|
const _seed = triggerDetailCache.peek(get(page).params.id ?? '').value;
|
|
let trigger = $state<RedeployTrigger | null>(_seed);
|
|
let webhook = $state<TriggerWebhook | null>(null);
|
|
let bindings = $state<TriggerBinding[]>([]);
|
|
let loading = $state(_seed === null);
|
|
let detailsLoaded = $state(false);
|
|
let saving = $state(false);
|
|
let error = $state('');
|
|
|
|
// Confirmation gates for destructive actions.
|
|
let confirmDelete = $state(false);
|
|
let confirmRotate = $state(false);
|
|
let confirmUnbindId = $state<string | null>(null);
|
|
let deleting = $state(false);
|
|
let rotating = $state(false);
|
|
|
|
let copied = $state(false);
|
|
|
|
// 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());
|
|
|
|
// Seed the editable form ONCE per id (plain, non-reactive guard so the load
|
|
// effect tracks only `id`). On a warm fresh mount, seed from the cached
|
|
// trigger right away; load() handles cold + reused-component (A→B) nav.
|
|
let seededKey: string | null = null;
|
|
function seedFormFrom(tr: RedeployTrigger): void {
|
|
seedTriggerKindFormState(
|
|
formState,
|
|
tr.kind,
|
|
tr.name,
|
|
tr.config,
|
|
tr.webhook_enabled,
|
|
tr.webhook_require_signature
|
|
);
|
|
}
|
|
if (_seed) {
|
|
seedFormFrom(_seed);
|
|
seededKey = get(page).params.id ?? '';
|
|
}
|
|
|
|
// Monotonic load token — each invocation claims the latest seq, so an
|
|
// earlier in-flight load (even for the same id on A→B→A nav, where the
|
|
// id-only guard can't tell two same-id loads apart) is stale and cannot
|
|
// write trigger / bindings / webhook / detailsLoaded.
|
|
let loadSeq = 0;
|
|
|
|
async function load(): Promise<void> {
|
|
const k = id;
|
|
const my = ++loadSeq;
|
|
// New id: its secondary panels (bindings/webhook) aren't loaded yet.
|
|
detailsLoaded = false;
|
|
const cached = triggerDetailCache.peek(k);
|
|
if (cached.value) {
|
|
trigger = cached.value;
|
|
if (seededKey !== k) {
|
|
seedFormFrom(cached.value);
|
|
seededKey = k;
|
|
}
|
|
loading = false; // warm: render the hero + config form immediately
|
|
} else {
|
|
loading = true;
|
|
}
|
|
error = '';
|
|
try {
|
|
const tr = await api.getTrigger(id);
|
|
if (my !== loadSeq) return; // a newer load owns the state now
|
|
trigger = tr;
|
|
triggerDetailCache.set(k, tr);
|
|
if (seededKey !== k) {
|
|
seedFormFrom(tr);
|
|
seededKey = k;
|
|
}
|
|
loading = false;
|
|
// Secondary panels. The webhook carries the rotated secret, so it is
|
|
// fetched fresh every time and NEVER cached. Reset both first so a
|
|
// reused-component warm nav (A→B) can't show A's data under B.
|
|
webhook = null;
|
|
bindings = [];
|
|
const [b, w] = await Promise.all([
|
|
api.listBindingsForTrigger(id),
|
|
tr.webhook_enabled ? api.getTriggerWebhook(id) : Promise.resolve(null)
|
|
]);
|
|
if (my !== loadSeq) return;
|
|
bindings = b;
|
|
webhook = w;
|
|
detailsLoaded = true;
|
|
} catch (e) {
|
|
if (my !== loadSeq) return;
|
|
// Clear so a 404 (e.g. a deleted trigger revisited from cache) shows
|
|
// the error state instead of a stale, interactive phantom.
|
|
trigger = null;
|
|
error = e instanceof Error ? e.message : 'Failed to load trigger';
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
const canSave = $derived(!!trigger && !saving && isTriggerFormValid(formState));
|
|
|
|
async function save(e?: Event): Promise<void> {
|
|
e?.preventDefault();
|
|
if (!trigger || saving || !canSave) return;
|
|
// Pin the id for the round-trip so a mid-save nav can't write another
|
|
// trigger's state or poison its cache entry (the component is reused).
|
|
const k = id;
|
|
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 = {
|
|
...buildTriggerInput(formState),
|
|
kind: trigger.kind
|
|
};
|
|
const updated = await api.updateTrigger(k, body);
|
|
if (id !== k) return;
|
|
trigger = updated;
|
|
triggerDetailCache.set(k, updated);
|
|
// Webhook info comes/goes with the toggle. Keep state in
|
|
// sync so the panel doesn't show stale secrets after
|
|
// turning ingress off-then-on.
|
|
if (updated.webhook_enabled) {
|
|
const w = await api.getTriggerWebhook(k);
|
|
if (id !== k) return;
|
|
webhook = w;
|
|
} else {
|
|
webhook = null;
|
|
}
|
|
} catch (e) {
|
|
if (id !== k) return;
|
|
error = e instanceof Error ? e.message : 'Save failed';
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function doDelete(): Promise<void> {
|
|
deleting = true;
|
|
error = '';
|
|
try {
|
|
await api.deleteTrigger(id);
|
|
// Drop the cached entry so a back-nav to this deleted id doesn't
|
|
// warm-seed a phantom detail page.
|
|
triggerDetailCache.remove(id);
|
|
goto('/triggers');
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Delete failed';
|
|
deleting = false;
|
|
confirmDelete = false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
const k = id;
|
|
const tid = trigger.id;
|
|
firing = true;
|
|
error = '';
|
|
try {
|
|
const res = await api.fireTriggerNow(tid);
|
|
if (id !== k) return;
|
|
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
|
|
scheduleFireFlashClear();
|
|
// Refresh the trigger so the "last fired" row reflects the new ts.
|
|
const fresh = await api.getTrigger(tid);
|
|
if (id !== k) return;
|
|
trigger = fresh;
|
|
triggerDetailCache.set(k, fresh);
|
|
} catch (e) {
|
|
if (id !== k) return;
|
|
error = e instanceof Error ? e.message : 'Fire failed';
|
|
} finally {
|
|
firing = false;
|
|
confirmFire = false;
|
|
}
|
|
}
|
|
|
|
async function doRotate(): Promise<void> {
|
|
const k = id;
|
|
rotating = true;
|
|
error = '';
|
|
try {
|
|
const res = await api.regenerateTriggerWebhook(k);
|
|
// Don't surface this trigger's new secret if we've navigated to
|
|
// another trigger while the rotate was in flight.
|
|
if (id !== k) return;
|
|
// regenerate returns the new url+secret but no signing
|
|
// flag — preserve the current toggle state.
|
|
webhook = {
|
|
url: res.url,
|
|
secret: res.secret,
|
|
webhook_require_signature:
|
|
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
|
};
|
|
} catch (e) {
|
|
if (id !== k) return;
|
|
error = e instanceof Error ? e.message : 'Rotate failed';
|
|
} finally {
|
|
rotating = false;
|
|
confirmRotate = false;
|
|
}
|
|
}
|
|
|
|
async function toggleBinding(b: TriggerBinding, next: boolean): Promise<void> {
|
|
const k = id;
|
|
try {
|
|
const updated = await api.updateBinding(b.id, { enabled: next });
|
|
if (id !== k) return;
|
|
bindings = bindings.map((x) => (x.id === b.id ? { ...x, enabled: updated.enabled } : x));
|
|
} catch (e) {
|
|
if (id !== k) return;
|
|
error = e instanceof Error ? e.message : 'Update failed';
|
|
}
|
|
}
|
|
|
|
async function doUnbind(): Promise<void> {
|
|
if (!confirmUnbindId) return;
|
|
const bid = confirmUnbindId;
|
|
const k = id;
|
|
try {
|
|
await api.deleteBinding(bid);
|
|
if (id !== k) return;
|
|
bindings = bindings.filter((b) => b.id !== bid);
|
|
// Reflect the new binding count in the hero.
|
|
if (trigger) {
|
|
trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) };
|
|
triggerDetailCache.set(k, trigger);
|
|
}
|
|
} catch (e) {
|
|
if (id !== k) return;
|
|
error = e instanceof Error ? e.message : 'Unbind failed';
|
|
} finally {
|
|
confirmUnbindId = null;
|
|
}
|
|
}
|
|
|
|
function fullWebhookUrl(): string {
|
|
if (!webhook) return '';
|
|
if (typeof window === 'undefined') return webhook.url;
|
|
// Backend returns a relative path; prefix with the current
|
|
// origin so the operator can copy a paste-ready value.
|
|
return webhook.url.startsWith('http') ? webhook.url : window.location.origin + webhook.url;
|
|
}
|
|
|
|
async function copyWebhook(): Promise<void> {
|
|
const url = fullWebhookUrl();
|
|
if (!url || typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
copied = true;
|
|
setTimeout(() => (copied = false), 1500);
|
|
} catch {
|
|
// best-effort — silent failure is fine
|
|
}
|
|
}
|
|
|
|
const unbindTarget = $derived(
|
|
confirmUnbindId ? bindings.find((b) => b.id === confirmUnbindId) ?? null : null
|
|
);
|
|
|
|
function kindLabel(k: string): string {
|
|
const key = `redeployTriggers.kind.${k}`;
|
|
const v = $t(key);
|
|
return v === key ? k : v;
|
|
}
|
|
|
|
// SvelteKit reuses this component instance across /triggers/A → /triggers/B,
|
|
// so onMount(load) would only fire once. The id-keyed effect reloads on
|
|
// param change.
|
|
$effect(() => {
|
|
const _ = id;
|
|
load();
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
|
|
</svelte:head>
|
|
|
|
<div class="forge" aria-busy={loading || !detailsLoaded}>
|
|
{#snippet stats()}
|
|
<div>
|
|
<dt>KIND</dt>
|
|
<dd>{trigger ? kindLabel(trigger.kind).toUpperCase() : '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('redeployTriggers.stat.boundWorkloads')}</dt>
|
|
<dd class="accent">{trigger ? String(trigger.binding_count).padStart(2, '0') : '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>WEBHOOK</dt>
|
|
<dd>{trigger ? (trigger.webhook_enabled ? 'ON' : '—') : '—'}</dd>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<ForgeHero
|
|
backHref="/triggers"
|
|
backLabel={$t('redeployTriggers.toolbar.backToList')}
|
|
eyebrowSuffix={$t('redeployTriggers.titleSingular').toUpperCase()}
|
|
title={trigger?.name ?? $t('observability.loading')}
|
|
size="md"
|
|
stats={stats}
|
|
/>
|
|
|
|
{#if error}
|
|
<div class="alert" role="alert">
|
|
<span class="alert-tag">ERR</span><span>{error}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if loading || !trigger}
|
|
<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}
|
|
<!-- ── Config panel ────────────────────────────── -->
|
|
<form class="panel" onsubmit={save} aria-busy={saving}>
|
|
<header class="panel-head">
|
|
<h2 class="panel-title">
|
|
{$t('redeployTriggers.detail.config')}<span class="title-accent">.</span>
|
|
</h2>
|
|
<span class="panel-sub">
|
|
{$t('redeployTriggers.detail.configSub', {
|
|
kind: trigger.kind,
|
|
id: trigger.id,
|
|
updatedAt: trigger.updated_at
|
|
})}
|
|
</span>
|
|
</header>
|
|
|
|
<!-- 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={!canSave}
|
|
aria-busy={saving}
|
|
>
|
|
{saving ? $t('observability.saving') : $t('observability.save')}
|
|
</button>
|
|
</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">
|
|
<h2 class="panel-title" id="webhook-heading">
|
|
{$t('redeployTriggers.detail.webhook')}<span class="title-accent">.</span>
|
|
</h2>
|
|
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
|
|
</header>
|
|
|
|
{#if !detailsLoaded}
|
|
<div class="skeleton-rows" aria-busy="true"><div class="skeleton-row"></div></div>
|
|
{:else if webhook && trigger.webhook_enabled}
|
|
<div class="field">
|
|
<span class="sub-label">{$t('redeployTriggers.detail.webhookUrlLabel')}</span>
|
|
<div class="url-box">
|
|
<code class="url-text mono">{fullWebhookUrl()}</code>
|
|
<div class="url-actions">
|
|
<button
|
|
type="button"
|
|
class="forge-btn-icon"
|
|
onclick={copyWebhook}
|
|
aria-label={$t('redeployTriggers.detail.webhookCopy')}
|
|
title={copied
|
|
? $t('redeployTriggers.detail.webhookCopied')
|
|
: $t('redeployTriggers.detail.webhookCopy')}
|
|
>
|
|
<IconCopy size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="forge-btn-ghost xs"
|
|
onclick={() => (confirmRotate = true)}
|
|
disabled={rotating}
|
|
>
|
|
<IconRefresh size={12} />
|
|
<span>
|
|
{rotating
|
|
? $t('redeployTriggers.detail.webhookRotating')
|
|
: $t('redeployTriggers.detail.webhookRotate')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<span class="hint">{$t('redeployTriggers.detail.webhookUrlNote')}</span>
|
|
{#if copied}
|
|
<span class="copied-flag" role="status">
|
|
{$t('redeployTriggers.detail.webhookCopied')}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="signing-line">
|
|
<span
|
|
class="sig-pill"
|
|
class:on={trigger.webhook_require_signature}
|
|
class:off={!trigger.webhook_require_signature}
|
|
>
|
|
<span class="status-dot" aria-hidden="true"></span>
|
|
<span class="mono">{$t('redeployTriggers.detail.webhookRequireSig')}</span>
|
|
<span class="mono small">
|
|
{trigger.webhook_require_signature ? 'ON' : 'OFF'}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<div class="note muted-note">
|
|
<span class="note-tag">OFF</span>
|
|
<p>{$t('redeployTriggers.detail.webhookDisabledNote')}</p>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- ── Bindings panel ──────────────────────────── -->
|
|
<section class="panel" aria-labelledby="bindings-heading">
|
|
<header class="panel-head">
|
|
<h2 class="panel-title" id="bindings-heading">
|
|
{$t('redeployTriggers.detail.bindings')}<span class="title-accent">.</span>
|
|
</h2>
|
|
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
|
|
</header>
|
|
|
|
{#if !detailsLoaded}
|
|
<div class="skeleton-rows" aria-busy="true"><div class="skeleton-row"></div></div>
|
|
{:else if bindings.length === 0}
|
|
<div class="note muted-note">
|
|
<span class="note-tag">∅</span>
|
|
<p>{$t('redeployTriggers.detail.bindingsEmpty')}</p>
|
|
</div>
|
|
{:else}
|
|
<ul class="bindings-list">
|
|
{#each bindings as b, i (b.id)}
|
|
<li class="binding">
|
|
<div class="b-main">
|
|
<span class="b-ref mono">{String(i + 1).padStart(2, '0')}</span>
|
|
<span class="b-name">{b.workload_name || b.workload_id}</span>
|
|
<span
|
|
class="b-state mono"
|
|
class:on={b.enabled}
|
|
class:off={!b.enabled}
|
|
>
|
|
{b.enabled
|
|
? $t('redeployTriggers.binding.enabled')
|
|
: $t('redeployTriggers.binding.disabled')}
|
|
</span>
|
|
</div>
|
|
<div class="b-actions">
|
|
<ToggleSwitch
|
|
checked={b.enabled}
|
|
onchange={(next: boolean) => toggleBinding(b, next)}
|
|
label={$t('redeployTriggers.binding.enabled')}
|
|
/>
|
|
<a
|
|
class="forge-btn-icon"
|
|
href={`/apps?workload=${b.workload_id}`}
|
|
aria-label={$t('redeployTriggers.detail.bindingsListItem.openWorkload')}
|
|
title={$t('redeployTriggers.detail.bindingsListItem.openWorkload')}
|
|
>
|
|
<IconExternalLink size={14} />
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="forge-btn-icon danger"
|
|
onclick={() => (confirmUnbindId = b.id)}
|
|
aria-label={$t('redeployTriggers.detail.bindingsListItem.unbind')}
|
|
title={$t('redeployTriggers.detail.bindingsListItem.unbind')}
|
|
>
|
|
<IconTrash size={14} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
<p class="hint foot">{$t('redeployTriggers.detail.bindingEnabledHint')}</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- ── Danger zone ────────────────────────────── -->
|
|
<section class="panel danger-panel" aria-labelledby="danger-heading">
|
|
<header class="panel-head">
|
|
<h2 class="panel-title" id="danger-heading">
|
|
{$t('redeployTriggers.detail.dangerZone')}<span class="title-accent">.</span>
|
|
</h2>
|
|
<span class="panel-sub">{$t('redeployTriggers.detail.dangerZoneSub')}</span>
|
|
</header>
|
|
<div class="danger-actions">
|
|
<button
|
|
type="button"
|
|
class="forge-btn-ghost forge-btn-danger"
|
|
onclick={() => (confirmDelete = true)}
|
|
>
|
|
{$t('redeployTriggers.detail.deleteButton')}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<ConfirmDialog
|
|
open={confirmDelete}
|
|
title={$t('redeployTriggers.detail.deleteTitle')}
|
|
message={$t('redeployTriggers.detail.deleteMessage', {
|
|
name: formState.name.trim() || trigger.name,
|
|
count: String(trigger.binding_count)
|
|
})}
|
|
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
|
|
confirmVariant="danger"
|
|
onconfirm={doDelete}
|
|
oncancel={() => (confirmDelete = false)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={confirmRotate}
|
|
title={$t('redeployTriggers.detail.rotateTitle')}
|
|
message={$t('redeployTriggers.detail.rotateMessage')}
|
|
confirmLabel={rotating
|
|
? $t('redeployTriggers.detail.webhookRotating')
|
|
: $t('redeployTriggers.detail.rotateConfirm')}
|
|
confirmVariant="danger"
|
|
onconfirm={doRotate}
|
|
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')}
|
|
message={$t('redeployTriggers.detail.unbindMessage', {
|
|
name: unbindTarget?.workload_name || unbindTarget?.workload_id || ''
|
|
})}
|
|
confirmLabel={$t('redeployTriggers.detail.unbindConfirm')}
|
|
confirmVariant="danger"
|
|
onconfirm={doUnbind}
|
|
oncancel={() => (confirmUnbindId = null)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.forge {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
max-width: 820px;
|
|
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));
|
|
}
|
|
|
|
/* ── Skeleton ──────────────────────────────────── */
|
|
.skeleton-rows {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
}
|
|
.skeleton-row {
|
|
height: 64px;
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-2xl);
|
|
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; }
|
|
}
|
|
|
|
/* ── Panel ─────────────────────────────────────── */
|
|
.panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.9rem;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-2xl);
|
|
padding: 1.5rem;
|
|
}
|
|
@media (max-width: 600px) {
|
|
.panel {
|
|
padding: 1.1rem;
|
|
gap: 1rem;
|
|
}
|
|
}
|
|
.panel.danger-panel {
|
|
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--border-primary));
|
|
}
|
|
.panel-head {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
.panel-title {
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.01em;
|
|
color: var(--text-primary);
|
|
}
|
|
.title-accent { color: var(--forge-accent); }
|
|
.panel-sub {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.7rem;
|
|
color: var(--text-tertiary);
|
|
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;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.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.foot { margin-top: 0.6rem; }
|
|
|
|
/* ── Note banner ────────────────────────────────── */
|
|
.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);
|
|
}
|
|
.muted-note { background: transparent; }
|
|
.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;
|
|
}
|
|
|
|
/* ── Actions ────────────────────────────────────── */
|
|
.actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.55rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.danger-actions { display: flex; justify-content: flex-end; }
|
|
@media (max-width: 480px) {
|
|
.actions, .danger-actions {
|
|
flex-direction: column-reverse;
|
|
align-items: stretch;
|
|
}
|
|
.actions :global(.forge-btn),
|
|
.actions :global(.forge-btn-ghost),
|
|
.danger-actions :global(.forge-btn-ghost) {
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
/* ── URL display ────────────────────────────────
|
|
The URL gets its own framed slot with a left-side
|
|
monospace text region and a right-side action stack.
|
|
The text wraps so long secrets don't horizontally
|
|
blow out the panel on narrow viewports. */
|
|
.url-box {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: stretch;
|
|
padding: 0.65rem 0.75rem;
|
|
background: var(--surface-card-hover);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
flex-wrap: wrap;
|
|
}
|
|
.url-text {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.78rem;
|
|
color: var(--text-primary);
|
|
word-break: break-all;
|
|
line-height: 1.5;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.url-actions {
|
|
display: inline-flex;
|
|
gap: 0.4rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
:global(.forge-btn-ghost.xs) {
|
|
padding: 0.25rem 0.55rem;
|
|
font-size: 0.6rem;
|
|
}
|
|
.copied-flag {
|
|
align-self: flex-end;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.6rem;
|
|
color: var(--color-success-dark);
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* ── Signing pill ───────────────────────────────── */
|
|
.signing-line {
|
|
display: flex;
|
|
gap: 0.55rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.sig-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.25rem 0.65rem;
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-full);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
background: var(--surface-card-hover);
|
|
color: var(--text-secondary);
|
|
}
|
|
.sig-pill.on {
|
|
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
|
|
color: var(--color-success-dark);
|
|
}
|
|
.sig-pill.off {
|
|
background: color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
|
|
color: var(--color-warning-dark, #b45309);
|
|
}
|
|
.status-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
}
|
|
.sig-pill.on .status-dot {
|
|
background: var(--color-success);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
|
|
}
|
|
.sig-pill.off .status-dot {
|
|
background: var(--color-warning, #f59e0b);
|
|
}
|
|
.small { font-size: 0.6rem; opacity: 0.7; }
|
|
.mono { font-family: var(--forge-mono); }
|
|
|
|
/* ── Bindings list ──────────────────────────────
|
|
List, not table — bindings are short and the
|
|
actions column makes a one-row-per-binding card
|
|
stack a more comfortable read at this width. */
|
|
.bindings-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.binding {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.7rem 0.85rem;
|
|
background: var(--surface-card-hover);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
flex-wrap: wrap;
|
|
}
|
|
.b-main {
|
|
display: inline-flex;
|
|
align-items: baseline;
|
|
gap: 0.7rem;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.b-ref {
|
|
font-size: 0.66rem;
|
|
letter-spacing: 0.1em;
|
|
color: var(--text-tertiary);
|
|
flex: 0 0 auto;
|
|
}
|
|
.b-name {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
}
|
|
.b-state {
|
|
font-size: 0.58rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.16em;
|
|
text-transform: uppercase;
|
|
padding: 0.12rem 0.45rem;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.b-state.on {
|
|
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
|
color: var(--color-success-dark);
|
|
}
|
|
.b-state.off {
|
|
background: var(--surface-card);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.b-actions {
|
|
display: inline-flex;
|
|
gap: 0.35rem;
|
|
align-items: center;
|
|
}
|
|
:global(.forge-btn-icon.danger) {
|
|
color: var(--color-danger);
|
|
}
|
|
:global(.forge-btn-icon.danger:hover:not(:disabled)) {
|
|
background: var(--color-danger-light);
|
|
}
|
|
:global([data-theme='dark']) :global(.forge-btn-icon.danger:hover:not(:disabled)) {
|
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
|
}
|
|
</style>
|