Files
tiny-forge/web/src/routes/triggers/[id]/+page.svelte
T
alexei.dolgolyov ec8c0cd891
Build / build (push) Successful in 11m31s
feat(web): warm-seed cache for triggers/[id] (last detail page)
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.
2026-06-08 16:06:37 +03:00

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>