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 { onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type {
|
import type {
|
||||||
RedeployTrigger,
|
RedeployTrigger,
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
TriggerInput,
|
TriggerInput,
|
||||||
TriggerWebhook
|
TriggerWebhook
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { triggerDetailCache } from '$lib/stores/caches';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -37,10 +39,16 @@
|
|||||||
return d.toLocaleString();
|
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 webhook = $state<TriggerWebhook | null>(null);
|
||||||
let bindings = $state<TriggerBinding[]>([]);
|
let bindings = $state<TriggerBinding[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(_seed === null);
|
||||||
|
let detailsLoaded = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@@ -60,12 +68,11 @@
|
|||||||
// kind tag when showKindPicker=false.
|
// kind tag when showKindPicker=false.
|
||||||
let formState = $state(createTriggerKindFormState());
|
let formState = $state(createTriggerKindFormState());
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
// Seed the editable form ONCE per id (plain, non-reactive guard so the load
|
||||||
loading = true;
|
// effect tracks only `id`). On a warm fresh mount, seed from the cached
|
||||||
error = '';
|
// trigger right away; load() handles cold + reused-component (A→B) nav.
|
||||||
try {
|
let seededKey: string | null = null;
|
||||||
const tr = await api.getTrigger(id);
|
function seedFormFrom(tr: RedeployTrigger): void {
|
||||||
trigger = tr;
|
|
||||||
seedTriggerKindFormState(
|
seedTriggerKindFormState(
|
||||||
formState,
|
formState,
|
||||||
tr.kind,
|
tr.kind,
|
||||||
@@ -74,21 +81,64 @@
|
|||||||
tr.webhook_enabled,
|
tr.webhook_enabled,
|
||||||
tr.webhook_require_signature
|
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;
|
|
||||||
}
|
}
|
||||||
await Promise.all(tasks);
|
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) {
|
} 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';
|
error = e instanceof Error ? e.message : 'Failed to load trigger';
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +148,9 @@
|
|||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!trigger || saving || !canSave) return;
|
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;
|
saving = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
@@ -110,17 +163,22 @@
|
|||||||
...buildTriggerInput(formState),
|
...buildTriggerInput(formState),
|
||||||
kind: trigger.kind
|
kind: trigger.kind
|
||||||
};
|
};
|
||||||
const updated = await api.updateTrigger(id, body);
|
const updated = await api.updateTrigger(k, body);
|
||||||
|
if (id !== k) return;
|
||||||
trigger = updated;
|
trigger = updated;
|
||||||
|
triggerDetailCache.set(k, updated);
|
||||||
// Webhook info comes/goes with the toggle. Keep state in
|
// Webhook info comes/goes with the toggle. Keep state in
|
||||||
// sync so the panel doesn't show stale secrets after
|
// sync so the panel doesn't show stale secrets after
|
||||||
// turning ingress off-then-on.
|
// turning ingress off-then-on.
|
||||||
if (updated.webhook_enabled) {
|
if (updated.webhook_enabled) {
|
||||||
webhook = await api.getTriggerWebhook(id);
|
const w = await api.getTriggerWebhook(k);
|
||||||
|
if (id !== k) return;
|
||||||
|
webhook = w;
|
||||||
} else {
|
} else {
|
||||||
webhook = null;
|
webhook = null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Save failed';
|
error = e instanceof Error ? e.message : 'Save failed';
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -132,6 +190,9 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await api.deleteTrigger(id);
|
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');
|
goto('/triggers');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Delete failed';
|
error = e instanceof Error ? e.message : 'Delete failed';
|
||||||
@@ -164,15 +225,22 @@
|
|||||||
|
|
||||||
async function doFireNow(): Promise<void> {
|
async function doFireNow(): Promise<void> {
|
||||||
if (!trigger) return;
|
if (!trigger) return;
|
||||||
|
const k = id;
|
||||||
|
const tid = trigger.id;
|
||||||
firing = true;
|
firing = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
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 };
|
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
|
||||||
scheduleFireFlashClear();
|
scheduleFireFlashClear();
|
||||||
// Refresh the trigger so the "last fired" row reflects the new ts.
|
// 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) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Fire failed';
|
error = e instanceof Error ? e.message : 'Fire failed';
|
||||||
} finally {
|
} finally {
|
||||||
firing = false;
|
firing = false;
|
||||||
@@ -181,10 +249,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doRotate(): Promise<void> {
|
async function doRotate(): Promise<void> {
|
||||||
|
const k = id;
|
||||||
rotating = true;
|
rotating = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
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
|
// regenerate returns the new url+secret but no signing
|
||||||
// flag — preserve the current toggle state.
|
// flag — preserve the current toggle state.
|
||||||
webhook = {
|
webhook = {
|
||||||
@@ -194,6 +266,7 @@
|
|||||||
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Rotate failed';
|
error = e instanceof Error ? e.message : 'Rotate failed';
|
||||||
} finally {
|
} finally {
|
||||||
rotating = false;
|
rotating = false;
|
||||||
@@ -202,10 +275,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBinding(b: TriggerBinding, next: boolean): Promise<void> {
|
async function toggleBinding(b: TriggerBinding, next: boolean): Promise<void> {
|
||||||
|
const k = id;
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateBinding(b.id, { enabled: next });
|
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));
|
bindings = bindings.map((x) => (x.id === b.id ? { ...x, enabled: updated.enabled } : x));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Update failed';
|
error = e instanceof Error ? e.message : 'Update failed';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,14 +289,18 @@
|
|||||||
async function doUnbind(): Promise<void> {
|
async function doUnbind(): Promise<void> {
|
||||||
if (!confirmUnbindId) return;
|
if (!confirmUnbindId) return;
|
||||||
const bid = confirmUnbindId;
|
const bid = confirmUnbindId;
|
||||||
|
const k = id;
|
||||||
try {
|
try {
|
||||||
await api.deleteBinding(bid);
|
await api.deleteBinding(bid);
|
||||||
|
if (id !== k) return;
|
||||||
bindings = bindings.filter((b) => b.id !== bid);
|
bindings = bindings.filter((b) => b.id !== bid);
|
||||||
// Reflect the new binding count in the hero.
|
// Reflect the new binding count in the hero.
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) };
|
trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) };
|
||||||
|
triggerDetailCache.set(k, trigger);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Unbind failed';
|
error = e instanceof Error ? e.message : 'Unbind failed';
|
||||||
} finally {
|
} finally {
|
||||||
confirmUnbindId = null;
|
confirmUnbindId = null;
|
||||||
@@ -270,7 +350,7 @@
|
|||||||
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
|
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="forge" aria-busy={loading}>
|
<div class="forge" aria-busy={loading || !detailsLoaded}>
|
||||||
{#snippet stats()}
|
{#snippet stats()}
|
||||||
<div>
|
<div>
|
||||||
<dt>KIND</dt>
|
<dt>KIND</dt>
|
||||||
@@ -390,7 +470,9 @@
|
|||||||
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
|
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
|
||||||
</header>
|
</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">
|
<div class="field">
|
||||||
<span class="sub-label">{$t('redeployTriggers.detail.webhookUrlLabel')}</span>
|
<span class="sub-label">{$t('redeployTriggers.detail.webhookUrlLabel')}</span>
|
||||||
<div class="url-box">
|
<div class="url-box">
|
||||||
@@ -460,7 +542,9 @@
|
|||||||
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
|
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
|
||||||
</header>
|
</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">
|
<div class="note muted-note">
|
||||||
<span class="note-tag">∅</span>
|
<span class="note-tag">∅</span>
|
||||||
<p>{$t('redeployTriggers.detail.bindingsEmpty')}</p>
|
<p>{$t('redeployTriggers.detail.bindingsEmpty')}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user