Files
tiny-forge/web/src/routes/settings/integrations/+page.svelte
T
alexei.dolgolyov 192204a51c
Build / build (push) Failing after 4m51s
feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
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).
2026-06-08 15:39:25 +03:00

134 lines
4.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>