192204a51c
Build / build (push) Failing after 4m51s
Sidebar tabs, Settings, and drill-in detail pages re-fetched on every
visit (loading=true + onMount), flashing an empty skeleton frame on each
navigation. Add an SWR cache layer so revisiting a view renders cached
data instantly while refreshing in the background.
- resourceCache.ts: single-value + keyed (per-id) SWR cache factories
- caches.ts: per-resource cache instances; resetAllCaches() on logout
- eventsSnapshot.ts: warm-seed snapshot for the SSE/paginated events page
- List/sidebar pages read $cache.value via $derived, refresh() on mount;
mutations refresh the cache
- Settings forms seed once from settingsCache (edit-safe) and refetch
after save (PUT /api/settings returns {status}, not the Settings object)
- Detail [id] pages warm-seed per id; apps/[id] seeds {workload,containers},
resets non-seeded panels on warm nav, clears workload on 404, and
invalidates its cache entry on delete
Deferred (still cold-fetch): triggers/[id] (webhook secret + multi-fetch
body gate), apps/new (create wizard).
134 lines
4.9 KiB
Svelte
134 lines
4.9 KiB
Svelte
<!--
|
||
Settings › Integrations
|
||
|
||
Outward-facing hooks: where Tinyforge *sends* events.
|
||
1. URL field (global / fallback) — saved via /api/settings.
|
||
2. Outgoing-webhook panel — secret rotate, disable signing, send test.
|
||
Inbound webhooks are per-project / per-site and live on their respective
|
||
detail pages — this page deliberately does not surface them.
|
||
-->
|
||
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { get } from 'svelte/store';
|
||
import {
|
||
updateSettings,
|
||
getSettingsNotificationSecret,
|
||
regenerateSettingsNotificationSecret,
|
||
disableSettingsNotificationSigning,
|
||
testSettingsNotification,
|
||
} from '$lib/api';
|
||
import { settingsCache } from '$lib/stores/caches';
|
||
import FormField from '$lib/components/FormField.svelte';
|
||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||
import { toasts } from '$lib/stores/toast';
|
||
import { t } from '$lib/i18n';
|
||
import { IconLoader } from '$lib/components/icons';
|
||
|
||
let loading = $state(true);
|
||
let saving = $state(false);
|
||
|
||
let notificationUrl = $state('');
|
||
// Tracks the last persisted URL so the OutgoingWebhookPanel's hasUrl
|
||
// flag reflects what the backend actually has, not unsaved input.
|
||
let savedNotificationUrl = $state('');
|
||
let errors = $state<Record<string, string>>({});
|
||
|
||
function validateUrl(value: string): string {
|
||
if (!value.trim()) return '';
|
||
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
|
||
}
|
||
|
||
// Seed once from the shared settings cache (warm → no skeleton on revisit);
|
||
// never re-applied during background refresh, so unsaved edits are safe.
|
||
async function load() {
|
||
const cached = get(settingsCache).value;
|
||
if (cached) {
|
||
notificationUrl = cached.notification_url ?? '';
|
||
savedNotificationUrl = notificationUrl;
|
||
loading = false;
|
||
}
|
||
await settingsCache.refresh();
|
||
const fresh = get(settingsCache).value;
|
||
if (fresh && loading) {
|
||
notificationUrl = fresh.notification_url ?? '';
|
||
savedNotificationUrl = notificationUrl;
|
||
}
|
||
loading = false;
|
||
const { error } = get(settingsCache);
|
||
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
|
||
}
|
||
|
||
async function handleSave() {
|
||
const urlErr = validateUrl(notificationUrl);
|
||
errors = urlErr ? { notificationUrl: urlErr } : {};
|
||
if (urlErr) return;
|
||
saving = true;
|
||
try {
|
||
await updateSettings({ notification_url: notificationUrl.trim() });
|
||
// PUT returns {status:"updated"}, not Settings — refetch the cache.
|
||
await settingsCache.refresh();
|
||
savedNotificationUrl = notificationUrl.trim();
|
||
toasts.success($t('settingsGeneral.saved'));
|
||
} catch (err) {
|
||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
onMount(load);
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{$t('settingsIntegrations.title')} - {$t('app.name')}</title>
|
||
</svelte:head>
|
||
|
||
<div class="space-y-6">
|
||
{#if loading}
|
||
<div class="space-y-4">
|
||
<Skeleton height="2rem" width="12rem" />
|
||
<Skeleton height="6rem" />
|
||
</div>
|
||
{:else}
|
||
<!-- Outgoing: notification URL -->
|
||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.outgoing')}</h2>
|
||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsIntegrations.outgoingDesc')}</p>
|
||
|
||
<FormField
|
||
label={$t('settingsGeneral.notificationUrl')}
|
||
name="notificationUrl"
|
||
bind:value={notificationUrl}
|
||
placeholder="https://notify.example.com/webhook"
|
||
error={errors.notificationUrl ?? ''}
|
||
helpText={$t('settingsGeneral.notificationUrlHelp')}
|
||
/>
|
||
|
||
<div class="mt-6">
|
||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||
{#if saving}<IconLoader size={16} />{/if}
|
||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Outgoing: signing secret + send test -->
|
||
<OutgoingWebhookPanel
|
||
title={$t('outgoingWebhook.signingSecret')}
|
||
description={$t('settingsIntegrations.outgoingDesc')}
|
||
hasUrl={!!savedNotificationUrl}
|
||
fetchSecret={getSettingsNotificationSecret}
|
||
regenerateSecret={regenerateSettingsNotificationSecret}
|
||
disableSigning={disableSettingsNotificationSigning}
|
||
sendTest={testSettingsNotification}
|
||
/>
|
||
|
||
<!-- Inbound hooks now live per-entity. -->
|
||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.incoming')}</h2>
|
||
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsIntegrations.incomingMovedDesc')}</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|