feat(web): warm-seed cache for triggers/[id] (last detail page)
Build / build (push) Successful in 11m31s
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.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
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,
|
||||
@@ -9,6 +10,7 @@
|
||||
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';
|
||||
@@ -37,10 +39,16 @@
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
let trigger = $state<RedeployTrigger | null>(null);
|
||||
// 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(true);
|
||||
let loading = $state(_seed === null);
|
||||
let detailsLoaded = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
@@ -60,35 +68,77 @@
|
||||
// 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> {
|
||||
loading = true;
|
||||
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;
|
||||
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.
|
||||
const tasks: Array<Promise<unknown>> = [
|
||||
api.listBindingsForTrigger(id).then((b) => (bindings = b))
|
||||
];
|
||||
if (tr.webhook_enabled) {
|
||||
tasks.push(api.getTriggerWebhook(id).then((w) => (webhook = w)));
|
||||
} else {
|
||||
webhook = null;
|
||||
triggerDetailCache.set(k, tr);
|
||||
if (seededKey !== k) {
|
||||
seedFormFrom(tr);
|
||||
seededKey = k;
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
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';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +148,9 @@
|
||||
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 {
|
||||
@@ -110,17 +163,22 @@
|
||||
...buildTriggerInput(formState),
|
||||
kind: trigger.kind
|
||||
};
|
||||
const updated = await api.updateTrigger(id, body);
|
||||
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) {
|
||||
webhook = await api.getTriggerWebhook(id);
|
||||
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;
|
||||
@@ -132,6 +190,9 @@
|
||||
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';
|
||||
@@ -164,15 +225,22 @@
|
||||
|
||||
async function doFireNow(): Promise<void> {
|
||||
if (!trigger) return;
|
||||
const k = id;
|
||||
const tid = trigger.id;
|
||||
firing = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.fireTriggerNow(trigger.id);
|
||||
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.
|
||||
trigger = await api.getTrigger(trigger.id);
|
||||
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;
|
||||
@@ -181,10 +249,14 @@
|
||||
}
|
||||
|
||||
async function doRotate(): Promise<void> {
|
||||
const k = id;
|
||||
rotating = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.regenerateTriggerWebhook(id);
|
||||
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 = {
|
||||
@@ -194,6 +266,7 @@
|
||||
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
||||
};
|
||||
} catch (e) {
|
||||
if (id !== k) return;
|
||||
error = e instanceof Error ? e.message : 'Rotate failed';
|
||||
} finally {
|
||||
rotating = false;
|
||||
@@ -202,10 +275,13 @@
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -213,14 +289,18 @@
|
||||
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;
|
||||
@@ -270,7 +350,7 @@
|
||||
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge" aria-busy={loading}>
|
||||
<div class="forge" aria-busy={loading || !detailsLoaded}>
|
||||
{#snippet stats()}
|
||||
<div>
|
||||
<dt>KIND</dt>
|
||||
@@ -390,7 +470,9 @@
|
||||
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
|
||||
</header>
|
||||
|
||||
{#if webhook && trigger.webhook_enabled}
|
||||
{#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">
|
||||
@@ -460,7 +542,9 @@
|
||||
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
|
||||
</header>
|
||||
|
||||
{#if bindings.length === 0}
|
||||
{#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>
|
||||
|
||||
Reference in New Issue
Block a user