8651767112
Adds bot commands for the bridge_self provider so operators can inspect and manage bridge health from chat: /status, /thresholds, /reset, /health. Includes Jinja2 templates for both locales, seed data, capability slots, and a handler that exposes pending deferred backlog plus per-counter reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
460 lines
18 KiB
Svelte
460 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import { providerTypeItems, providerDefaultIcon, webhookAuthModeItems } from '$lib/grid-items';
|
|
|
|
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
|
import { onDestroy } from 'svelte';
|
|
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
|
import { highlightFromUrl } from '$lib/highlight';
|
|
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
|
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
|
|
import type { ServiceProvider } from '$lib/types';
|
|
|
|
let allProviders = $derived(providersCache.items);
|
|
let filterText = $state('');
|
|
let providers = $derived(allProviders.filter(p =>
|
|
(!filterText || p.name.toLowerCase().includes(filterText.toLowerCase()) || p.type.toLowerCase().includes(filterText.toLowerCase())) &&
|
|
(!globalProviderFilter.id || p.id === globalProviderFilter.id)
|
|
));
|
|
let showForm = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let form = $state(buildProviderFormDefaults());
|
|
let nameManuallyEdited = $state(false);
|
|
let error = $state('');
|
|
let loadError = $state('');
|
|
let submitting = $state(false);
|
|
let loaded = $state(false);
|
|
let confirmDelete = $state<ServiceProvider | null>(null);
|
|
|
|
let descriptor = $derived(getDescriptor(form.type));
|
|
let externalUrl = $derived(externalUrlCache.value);
|
|
|
|
function buildWebhookUrl(pattern: string, token: string): string {
|
|
const path = pattern.replace('{token}', token ?? '');
|
|
return externalUrl ? `${externalUrl}${path}` : path;
|
|
}
|
|
|
|
/**
|
|
* Build meta tiles for a provider row. Filled into the dead middle space
|
|
* on wide displays; on narrow screens the secondary text line takes over.
|
|
*/
|
|
function providerTiles(provider: ServiceProvider): MetaTile[] {
|
|
const tiles: MetaTile[] = [];
|
|
const h = health[provider.id];
|
|
const provDesc = getDescriptor(provider.type);
|
|
// Status — first tile, color-coded
|
|
if (h === true) {
|
|
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
|
|
} else if (h === false) {
|
|
tiles.push({ icon: 'mdiCloseCircle', label: t('providers.offline'), tone: 'coral' });
|
|
} else {
|
|
tiles.push({ icon: 'mdiTimerSand', label: t('providers.checking'), tone: 'citrus' });
|
|
}
|
|
// Type / connection address
|
|
const cfg = provider.config as Record<string, any> | undefined;
|
|
if (cfg?.url) {
|
|
tiles.push({
|
|
icon: 'mdiLinkVariant',
|
|
label: shortenUrl(cfg.url),
|
|
hint: cfg.url,
|
|
href: cfg.url,
|
|
tone: 'sky',
|
|
mono: true,
|
|
});
|
|
} else if (cfg?.host) {
|
|
tiles.push({
|
|
icon: 'mdiServer',
|
|
label: `${cfg.host}:${cfg.port || 3493}`,
|
|
tone: 'sky',
|
|
mono: true,
|
|
});
|
|
}
|
|
// Webhook URL (copy to clipboard)
|
|
if (provDesc?.webhookUrlPattern) {
|
|
const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token);
|
|
tiles.push({
|
|
icon: 'mdiContentCopy',
|
|
label: t('providers.webhookUrl'),
|
|
hint: webhookUrl,
|
|
tone: 'orchid',
|
|
onclick: (e) => copyWebhookUrl(e, webhookUrl),
|
|
});
|
|
}
|
|
return tiles;
|
|
}
|
|
|
|
/** Trim the visible URL so it fits a meta tile; keep host + first path segment. */
|
|
function shortenUrl(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
const segments = u.pathname.split('/').filter(Boolean);
|
|
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
|
return `${u.host}${tail}`;
|
|
} catch {
|
|
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
|
}
|
|
}
|
|
|
|
function copyWebhookUrl(e: Event, url: string) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (navigator.clipboard?.writeText) {
|
|
navigator.clipboard.writeText(url);
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = url;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
snackInfo(`${t('snack.copied')}: ${url}`);
|
|
}
|
|
|
|
// Auto-update name when provider type changes (unless user manually edited)
|
|
$effect(() => {
|
|
const desc = getDescriptor(form.type);
|
|
if (!nameManuallyEdited && !editing && desc) {
|
|
form.name = desc.defaultName;
|
|
}
|
|
});
|
|
|
|
let health = $state<Record<number, boolean | null>>({});
|
|
|
|
// Status pill row for the page header — derived from health probes.
|
|
const headerPills = $derived.by(() => {
|
|
const onlineCount = Object.values(health).filter(v => v === true).length;
|
|
const offlineCount = Object.values(health).filter(v => v === false).length;
|
|
const checkingCount = Math.max(0, providers.length - onlineCount - offlineCount);
|
|
const typeCount = new Set(providers.map(p => p.type)).size;
|
|
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'coral' | 'citrus' }> = [];
|
|
if (onlineCount > 0) pills.push({ label: `${onlineCount} ${t('providers.online')}`, tone: 'mint' });
|
|
if (offlineCount > 0) pills.push({ label: `${offlineCount} ${t('providers.offline')}`, tone: 'coral' });
|
|
if (checkingCount > 0 && providers.length > 0) pills.push({ label: `${checkingCount} ${t('providers.checking')}`, tone: 'citrus' });
|
|
if (typeCount > 0) pills.push({ label: `${typeCount} ${typeCount === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
|
|
return pills;
|
|
});
|
|
|
|
onMount(() => {
|
|
topbarAction.set({
|
|
label: t('providers.addProvider'),
|
|
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
|
|
});
|
|
load();
|
|
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
|
|
});
|
|
onDestroy(() => topbarAction.clear());
|
|
async function load() {
|
|
try {
|
|
await providersCache.fetch(true);
|
|
loadError = '';
|
|
} catch (err: unknown) {
|
|
loadError = errMsg(err, t('providers.loadError'));
|
|
} finally { loaded = true; highlightFromUrl(); }
|
|
// Ping all providers in background (use unfiltered list)
|
|
for (const p of allProviders) {
|
|
health = { ...health, [p.id]: null };
|
|
api(`/providers/${p.id}/test`, { method: 'POST' })
|
|
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
|
|
.catch(() => { health = { ...health, [p.id]: false }; });
|
|
}
|
|
}
|
|
|
|
function openNew() {
|
|
form = buildProviderFormDefaults();
|
|
nameManuallyEdited = false;
|
|
editing = null; showForm = true;
|
|
}
|
|
|
|
function edit(p: any) {
|
|
const cfg = p.config || {};
|
|
const base = buildProviderFormDefaults();
|
|
const desc = getDescriptor(p.type);
|
|
// Populate common fields
|
|
base.name = p.name;
|
|
base.type = p.type;
|
|
base.icon = p.icon || '';
|
|
base.url = cfg.url || '';
|
|
// Populate provider-specific fields from config using configKey mapping
|
|
if (desc) {
|
|
for (const field of desc.configFields) {
|
|
const cfgKey = field.configKey || field.key;
|
|
// Secrets (password fields) are blank on edit; non-secret fields load from config
|
|
if (field.type === 'password') {
|
|
base[field.key] = '';
|
|
} else {
|
|
base[field.key] = cfg[cfgKey] ?? field.defaultValue ?? '';
|
|
}
|
|
}
|
|
}
|
|
form = base;
|
|
nameManuallyEdited = true;
|
|
editing = p.id; showForm = true;
|
|
}
|
|
|
|
async function save(e: SubmitEvent) {
|
|
e.preventDefault(); error = ''; submitting = true;
|
|
try {
|
|
const desc = getDescriptor(form.type);
|
|
if (!desc) {
|
|
error = `Unknown provider type: ${form.type}`;
|
|
snackError(error); submitting = false; return;
|
|
}
|
|
const { config, error: buildError } = desc.buildConfig(form, !!editing);
|
|
if (buildError) {
|
|
error = t(buildError);
|
|
snackError(error); submitting = false; return;
|
|
}
|
|
if (editing) {
|
|
const existing = providers.find(p => p.id === editing)?.config || {};
|
|
const hasConfigChange = desc.hasConfigChanged(form, existing);
|
|
const body: any = { name: form.name, icon: form.icon };
|
|
if (hasConfigChange) body.config = config;
|
|
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
} else {
|
|
await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) });
|
|
}
|
|
showForm = false; editing = null; providersCache.invalidate(); await load();
|
|
snackSuccess(t('snack.providerSaved'));
|
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
|
submitting = false;
|
|
}
|
|
|
|
function startDelete(provider: any) { confirmDelete = provider; }
|
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
|
async function doDelete() {
|
|
if (!confirmDelete) return;
|
|
const id = confirmDelete.id;
|
|
confirmDelete = null;
|
|
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
|
catch (err: unknown) {
|
|
const bb = getBlockedBy(err);
|
|
if (bb) { blockedBy = bb; return; }
|
|
const m = errMsg(err); error = m; snackError(m);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('providers.title')}
|
|
emphasis={t('providers.titleEmphasis')}
|
|
description={t('providers.description')}
|
|
crumb={t('crumbs.serviceConnections')}
|
|
count={providers.length}
|
|
countLabel={t('dashboard.providersShort')}
|
|
pills={headerPills}
|
|
>
|
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
|
{showForm ? t('providers.cancel') : t('providers.addProvider')}
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else}
|
|
|
|
{#if loadError}
|
|
<Card class="mb-6">
|
|
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiAlertCircle" size={18} />
|
|
{loadError}
|
|
</div>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if showForm}
|
|
<div in:slide={{ duration: 200 }} class="list-stack">
|
|
<Card class="mb-6">
|
|
<ErrorBanner message={error} />
|
|
<form onsubmit={save} class="space-y-3">
|
|
<div>
|
|
<div class="block text-sm font-medium mb-1">{t('providers.type')}</div>
|
|
{#if !editing}
|
|
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
|
|
{:else}
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">{form.type}</p>
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
|
|
<div class="flex gap-2">
|
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
|
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
</div>
|
|
{#if descriptor?.hasUrl}
|
|
<div>
|
|
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
|
<input id="prv-url" bind:value={form.url} required placeholder={descriptor.urlPlaceholder || t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
{/if}
|
|
{#each descriptor?.configFields ?? [] as field (field.key)}
|
|
<div>
|
|
<label for="prv-{field.key}" class="block text-sm font-medium mb-1">
|
|
{t(editing && field.editLabel ? field.editLabel : field.label)}
|
|
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
|
|
</label>
|
|
{#if field.type === 'grid-select' && field.gridItems}
|
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
|
{:else if field.type === 'number'}
|
|
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
|
min={field.min} max={field.max}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{:else if field.type === 'toggle'}
|
|
<label class="toggle-switch">
|
|
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
|
<span class="toggle-track"></span>
|
|
</label>
|
|
{:else}
|
|
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
|
required={field.required === true || (field.required === 'create-only' && !editing)}
|
|
placeholder={field.placeholder || ''}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{/if}
|
|
{#if field.hint}
|
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t(field.hint)}</p>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{#if descriptor?.webhookUrlPattern && editing}
|
|
{@const editingWebhookUrl = buildWebhookUrl(descriptor.webhookUrlPattern, providers.find(p => p.id === editing)?.webhook_token ?? '')}
|
|
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
|
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
|
|
<button type="button"
|
|
onclick={(e) => copyWebhookUrl(e, editingWebhookUrl)}
|
|
title={t('providers.webhookUrlCopyTitle')}
|
|
class="text-xs break-all text-left hover:text-[var(--color-primary)] cursor-pointer font-mono w-full">
|
|
<code class="bg-transparent">{editingWebhookUrl}</code>
|
|
</button>
|
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
|
</div>
|
|
{/if}
|
|
<Button type="submit" disabled={submitting}>
|
|
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
|
|
</Button>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !showForm && allProviders.length > 0}
|
|
<div class="list-stack mb-3">
|
|
<div class="flex items-center gap-2">
|
|
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
|
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if allProviders.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiServer" message={t('providers.noProviders')} />
|
|
</Card>
|
|
{:else if providers.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="list-stack stagger-children">
|
|
{#each providers as provider}
|
|
{@const provDesc = getDescriptor(provider.type)}
|
|
<Card hover entityId={provider.id}>
|
|
<div class="list-row">
|
|
<div class="list-row__identity">
|
|
<div class="flex items-center gap-3">
|
|
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
|
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
|
<div class="min-w-0">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<p class="font-medium truncate">{provider.name}</p>
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
|
|
</div>
|
|
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
|
|
<div class="list-row__secondary">
|
|
{#if provider.config?.url}
|
|
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
|
|
{:else if provider.config?.host}
|
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
|
{/if}
|
|
{#if provDesc?.webhookUrlPattern}
|
|
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
|
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
|
|
{t('providers.webhookUrl')}:
|
|
<button type="button"
|
|
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
|
|
title={t('providers.webhookUrlCopyTitle')}
|
|
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<MetaStrip tiles={providerTiles(provider)} />
|
|
<div class="list-row__actions">
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
{#if provDesc?.payloadHistory && !showForm}
|
|
<WebhookPayloadHistory providerId={provider.id} />
|
|
{/if}
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
|
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
|
|
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
|
|
|
<style>
|
|
.health-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.health-dot.online {
|
|
background: var(--color-success-fg);
|
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-success-fg) 40%, transparent);
|
|
}
|
|
.health-dot.offline {
|
|
background: var(--color-error-fg);
|
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-error-fg) 30%, transparent);
|
|
}
|
|
.health-dot.checking {
|
|
background: var(--color-warning-fg);
|
|
animation: pulseCheck 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes pulseCheck {
|
|
0%, 100% { box-shadow: 0 0 4px color-mix(in srgb, var(--color-warning-fg) 30%, transparent); }
|
|
50% { box-shadow: 0 0 12px color-mix(in srgb, var(--color-warning-fg) 60%, transparent); }
|
|
}
|
|
</style>
|