feat(service-integrations): phase 2 — integration widget & app form UI

- Add 6 renderer components: StatCard, Gauge, List, Progress, AlertBanner, Chart
- Add IntegrationWidget container with auto-refresh, loading, error states
- Add IntegrationAlertOverlay for layout-level critical alerts
- Add IntegrationConfigFields for dynamic form generation from Zod schemas
- Register integration type in WidgetRenderer
- Extend WidgetCreationForm with integration app/endpoint pickers
- Extend AppForm with integration config section and test connection button
- Add /api/integrations/alerts endpoint
This commit is contained in:
2026-03-25 22:07:51 +03:00
parent 114dee57a8
commit 50e8519220
25 changed files with 1360 additions and 1 deletions
+140
View File
@@ -4,6 +4,7 @@
import type { z } from 'zod';
import type { createAppSchema } from '$lib/utils/validators.js';
import AppIconPicker from './AppIconPicker.svelte';
import IntegrationConfigFields from './IntegrationConfigFields.svelte';
import AppUrlPreview from './AppUrlPreview.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
@@ -22,6 +23,53 @@
});
let showAdvanced = $state(false);
let showIntegration = $state(false);
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
let integrationConfig = $state<Record<string, unknown>>({});
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
$effect(() => {
fetch('/api/integrations')
.then((r) => r.json())
.then((json) => {
if (json.success) availableIntegrations = json.data ?? [];
})
.catch(() => {});
});
const selectedIntegration = $derived(
availableIntegrations.find((i) => i.id === ($form.integrationType ?? ''))
);
async function handleTestConnection() {
if (!$form.integrationType || !$form.url) return;
testingConnection = true;
testResult = null;
try {
const res = await fetch('/api/integrations/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
integrationType: $form.integrationType,
appUrl: $form.url,
config: integrationConfig
})
});
const json = await res.json();
testResult = json.data ?? { success: false, message: json.error ?? 'Unknown error' };
} catch {
testResult = { success: false, message: 'Network error' };
} finally {
testingConnection = false;
}
}
$effect(() => {
if ($form.integrationType && Object.keys(integrationConfig).length > 0) {
$form.integrationConfig = JSON.stringify(integrationConfig);
}
});
const healthcheckMethodItems: IconGridItem[] = [
{ value: 'GET', icon: '🔍', label: 'GET', desc: 'Full response' },
@@ -234,6 +282,98 @@
</div>
{/if}
<!-- Integration Section -->
<button
type="button"
onclick={() => (showIntegration = !showIntegration)}
class="text-sm text-muted-foreground hover:text-foreground"
>
{showIntegration ? 'Hide' : 'Show'} Integration Settings
</button>
{#if showIntegration}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
id="integrationEnabled"
name="integrationEnabled"
type="checkbox"
bind:checked={$form.integrationEnabled}
class="rounded border-input"
/>
<label for="integrationEnabled" class="text-sm text-card-foreground">
Enable Integration
</label>
</div>
{#if $form.integrationEnabled}
<div>
<label for="integrationType" class="mb-1 block text-sm font-medium text-card-foreground">
Integration Type
</label>
<select
id="integrationType"
name="integrationType"
bind:value={$form.integrationType}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">None</option>
{#each availableIntegrations as integration (integration.id)}
<option value={integration.id}>{integration.name}</option>
{/each}
</select>
</div>
{#if selectedIntegration}
<div>
<h4 class="mb-2 text-sm font-medium text-card-foreground">Authentication</h4>
<IntegrationConfigFields
fields={selectedIntegration.authConfigFields}
values={integrationConfig}
onchange={(name, value) => {
integrationConfig = { ...integrationConfig, [name]: value };
}}
idPrefix="int-auth"
/>
</div>
{#if selectedIntegration.extraConfigFields.length > 0}
<div>
<h4 class="mb-2 text-sm font-medium text-card-foreground">Extra Configuration</h4>
<IntegrationConfigFields
fields={selectedIntegration.extraConfigFields}
values={integrationConfig}
onchange={(name, value) => {
integrationConfig = { ...integrationConfig, [name]: value };
}}
idPrefix="int-extra"
/>
</div>
{/if}
<div class="flex items-center gap-3">
<button
type="button"
onclick={handleTestConnection}
disabled={testingConnection}
class="rounded-md border border-border px-3 py-1.5 text-sm text-foreground hover:bg-accent disabled:opacity-50"
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</button>
{#if testResult}
<span class="text-sm {testResult.success ? 'text-green-500' : 'text-destructive'}">
{testResult.message}
</span>
{/if}
</div>
{/if}
{/if}
<input type="hidden" name="integrationConfig" value={$form.integrationConfig ?? ''} />
<input type="hidden" name="integrationType" value={$form.integrationType ?? ''} />
</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
@@ -0,0 +1,57 @@
<script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
interface Props {
fields: IntegrationFieldDescriptor[];
values: Record<string, unknown>;
onchange: (name: string, value: unknown) => void;
idPrefix?: string;
}
let { fields, values, onchange, idPrefix = 'int' }: Props = $props();
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
</script>
<div class="space-y-3">
{#each fields as field (field.name)}
<div>
<label for="{idPrefix}-{field.name}" class="mb-1 block text-sm font-medium text-card-foreground">
{field.label}
{#if field.required}
<span class="text-destructive">*</span>
{/if}
</label>
{#if field.type === 'boolean'}
<label class="flex items-center gap-2">
<input
id="{idPrefix}-{field.name}"
type="checkbox"
checked={!!values[field.name]}
onchange={(e) => onchange(field.name, e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
/>
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</label>
{:else if field.type === 'number'}
<input
id="{idPrefix}-{field.name}"
type="number"
value={values[field.name] ?? ''}
oninput={(e) => onchange(field.name, parseInt(e.currentTarget.value) || 0)}
class={inputClass}
placeholder={field.description ?? ''}
/>
{:else}
<input
id="{idPrefix}-{field.name}"
type={field.name.toLowerCase().includes('password') || field.name.toLowerCase().includes('secret') ? 'password' : 'text'}
value={values[field.name] ?? ''}
oninput={(e) => onchange(field.name, e.currentTarget.value)}
class={inputClass}
placeholder={field.description ?? field.label}
/>
{/if}
</div>
{/each}
</div>
@@ -84,6 +84,13 @@
let cameraRefreshInterval = $state(10);
let cameraAspectRatio = $state('16/9');
// Integration fields
let integrationAppId = $state('');
let integrationEndpointId = $state('');
let integrationRefreshInterval = $state(60);
let integrationApps = $state<Array<{ id: string; name: string; integrationType: string | null; integrationEnabled: boolean }>>([]);
let integrationEndpoints = $state<Array<{ id: string; name: string }>>([]);
const widgetTypeItems: IconGridItem[] = [
{ value: 'app', icon: '🖥️', label: 'App' },
{ value: 'bookmark', icon: '🔖', label: 'Bookmark' },
@@ -97,7 +104,8 @@
{ value: 'markdown', icon: '📄', label: 'Markdown' },
{ value: 'metric', icon: '📈', label: 'Metric' },
{ value: 'link_group', icon: '🔗', label: 'Links' },
{ value: 'camera', icon: '📷', label: 'Camera' }
{ value: 'camera', icon: '📷', label: 'Camera' },
{ value: 'integration', icon: '🔌', label: 'Integration' }
];
const noteFormatItems: IconGridItem[] = [
@@ -117,6 +125,36 @@
{ value: 'prometheus', icon: '📊', label: 'Prometheus' }
];
$effect(() => {
if (selectedWidgetType === 'integration') {
fetch('/api/apps')
.then((r) => r.json())
.then((json) => {
if (json.success) {
integrationApps = (json.data ?? []).filter((a: any) => a.integrationEnabled && a.integrationType);
}
})
.catch(() => {});
}
});
$effect(() => {
if (integrationAppId) {
const app = integrationApps.find((a) => a.id === integrationAppId);
if (app?.integrationType) {
fetch('/api/integrations')
.then((r) => r.json())
.then((json) => {
if (json.success) {
const integration = (json.data ?? []).find((i: any) => i.id === app.integrationType);
integrationEndpoints = integration?.endpoints ?? [];
}
})
.catch(() => {});
}
}
});
const appPickerItems: EntityPickerItem[] = $derived(
apps.map((app) => ({
value: app.id,
@@ -166,6 +204,11 @@
cameraType = 'image';
cameraRefreshInterval = 10;
cameraAspectRatio = '16/9';
integrationAppId = '';
integrationEndpointId = '';
integrationRefreshInterval = 60;
integrationApps = [];
integrationEndpoints = [];
}
function handleSubmitWidget() {
@@ -265,6 +308,12 @@
widgetData.refreshInterval = cameraRefreshInterval;
widgetData.aspectRatio = cameraAspectRatio;
break;
case 'integration':
if (!integrationAppId || !integrationEndpointId) return;
widgetData.appId = integrationAppId;
widgetData.endpointId = integrationEndpointId;
widgetData.refreshInterval = integrationRefreshInterval;
break;
default:
return;
}
@@ -917,6 +966,56 @@
</div>
</div>
</div>
{:else if selectedWidgetType === 'integration'}
<div class="space-y-3">
<div>
<label for="int-app-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">App with Integration</label>
{#if integrationApps.length === 0}
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
{:else}
<select
id="int-app-{sectionId}"
bind:value={integrationAppId}
class={inputClass}
>
<option value="">Select an app...</option>
{#each integrationApps as app (app.id)}
<option value={app.id}>{app.name} ({app.integrationType})</option>
{/each}
</select>
{/if}
</div>
{#if integrationAppId && integrationEndpoints.length > 0}
<div>
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
<select
id="int-endpoint-{sectionId}"
bind:value={integrationEndpointId}
class={inputClass}
>
<option value="">Select endpoint...</option>
{#each integrationEndpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
</div>
<div>
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {integrationRefreshInterval}s
</label>
<input
id="int-refresh-{sectionId}"
type="range"
bind:value={integrationRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
/>
</div>
{/if}
</div>
{/if}
<div class="mt-3">
@@ -13,6 +13,7 @@
import MetricWidget from './MetricWidget.svelte';
import LinkGroupWidget from './LinkGroupWidget.svelte';
import CameraStreamWidget from './CameraStreamWidget.svelte';
import IntegrationWidget from './integration/IntegrationWidget.svelte';
interface AppData {
id: string;
@@ -116,6 +117,12 @@
refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} />
{:else if widget.type === 'integration'}
<IntegrationWidget config={{
appId: parsedConfig.appId ?? '',
endpointId: parsedConfig.endpointId ?? '',
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{:else}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { AlertBannerData } from '$lib/server/integrations/types.js';
interface Props {
data: AlertBannerData;
}
let { data }: Props = $props();
const severityStyles = $derived.by(() => {
switch (data.severity) {
case 'critical': return 'border-red-500/50 bg-red-500/10 text-red-400';
case 'warning': return 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400';
default: return 'border-blue-500/50 bg-blue-500/10 text-blue-400';
}
});
const severityIcon = $derived(
data.severity === 'critical' ? '!!' : data.severity === 'warning' ? '!' : 'i'
);
</script>
<div class="flex items-start gap-3 rounded-lg border p-3 {severityStyles}">
<span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-bold">
{severityIcon}
</span>
<div class="min-w-0 flex-1">
<p class="font-semibold">{data.title}</p>
<p class="text-sm opacity-80">{data.message}</p>
</div>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { ChartData } from '$lib/server/integrations/types.js';
interface Props {
data: ChartData;
}
let { data }: Props = $props();
const maxValue = $derived(
Math.max(...data.datasets.flatMap((d) => d.values), 1)
);
const barWidth = $derived(
data.labels.length > 0 ? Math.max(100 / data.labels.length - 2, 4) : 10
);
const defaultColors = ['#6366f1', '#22c55e', '#eab308', '#ef4444', '#06b6d4'];
</script>
<div class="p-3">
{#if data.labels.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">No chart data</p>
{:else}
<svg viewBox="0 0 100 60" class="h-40 w-full" preserveAspectRatio="none">
{#each data.datasets as dataset, di}
{#each dataset.values as value, i}
{@const barHeight = (value / maxValue) * 50}
{@const x = (i / data.labels.length) * 100 + 1 + di * (barWidth / data.datasets.length)}
<rect
{x}
y={55 - barHeight}
width={barWidth / data.datasets.length}
height={barHeight}
fill={dataset.color ?? defaultColors[di % defaultColors.length]}
rx="1"
class="transition-all duration-300"
/>
{/each}
{/each}
<line x1="0" y1="55" x2="100" y2="55" stroke="currentColor" stroke-width="0.3" class="text-border" />
</svg>
{#if data.datasets.length > 1}
<div class="mt-2 flex flex-wrap justify-center gap-3">
{#each data.datasets as dataset, di}
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full" style="background-color: {dataset.color ?? defaultColors[di % defaultColors.length]}"></span>
{dataset.label}
</div>
{/each}
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { GaugeData } from '$lib/server/integrations/types.js';
interface Props {
data: GaugeData;
}
let { data }: Props = $props();
const percentage = $derived(data.max > 0 ? Math.min((data.value / data.max) * 100, 100) : 0);
const color = $derived.by(() => {
const warn = data.thresholds?.warning ?? 60;
const crit = data.thresholds?.critical ?? 85;
if (percentage >= crit) return '#ef4444'; // red
if (percentage >= warn) return '#eab308'; // yellow
return '#22c55e'; // green
});
// SVG circle math
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = $derived(circumference - (percentage / 100) * circumference);
</script>
<div class="flex flex-col items-center justify-center gap-2 p-3">
<div class="relative h-24 w-24">
<svg viewBox="0 0 100 100" class="h-full w-full -rotate-90">
<circle cx="50" cy="50" r={radius} fill="none" stroke="currentColor" stroke-width="8" class="text-muted/20" />
<circle
cx="50" cy="50" r={radius} fill="none"
stroke={color} stroke-width="8"
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={strokeDashoffset}
class="transition-all duration-700 ease-out"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-lg font-bold text-foreground">{Math.round(percentage)}%</span>
</div>
</div>
<div class="text-center">
<span class="text-sm font-medium text-muted-foreground">{data.label}</span>
<span class="block text-xs text-muted-foreground/70">{data.value}{data.unit} / {data.max}{data.unit}</span>
</div>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import AlertBannerRenderer from './AlertBannerRenderer.svelte';
import type { AlertBannerData } from '$lib/server/integrations/types.js';
let alerts = $state<AlertBannerData[]>([]);
async function fetchAlerts() {
try {
const res = await fetch('/api/integrations/alerts');
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
alerts = json.data;
}
} catch {
// Silently fail — alerts are supplementary
}
}
$effect(() => {
fetchAlerts();
const interval = setInterval(fetchAlerts, 30_000);
return () => clearInterval(interval);
});
</script>
{#if alerts.length > 0}
<div class="space-y-2 px-4 pt-2">
{#each alerts as alert}
<AlertBannerRenderer data={alert} />
{/each}
</div>
{/if}
@@ -0,0 +1,78 @@
<script lang="ts">
import StatCardRenderer from './StatCardRenderer.svelte';
import GaugeRenderer from './GaugeRenderer.svelte';
import ListRenderer from './ListRenderer.svelte';
import ProgressRenderer from './ProgressRenderer.svelte';
import AlertBannerRenderer from './AlertBannerRenderer.svelte';
import ChartRenderer from './ChartRenderer.svelte';
import type { IntegrationData } from '$lib/server/integrations/types.js';
interface Props {
config: {
appId: string;
endpointId: string;
refreshInterval?: number;
};
}
let { config }: Props = $props();
let integrationData = $state<IntegrationData | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
async function fetchData() {
try {
const res = await fetch(`/api/integrations/${config.appId}/data/${config.endpointId}`);
const json = await res.json();
if (json.success) {
integrationData = json.data;
error = null;
} else {
error = json.error ?? 'Failed to fetch data';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Network error';
} finally {
loading = false;
}
}
$effect(() => {
fetchData();
const interval = setInterval(fetchData, (config.refreshInterval ?? 60) * 1000);
return () => clearInterval(interval);
});
</script>
{#if loading}
<div class="flex h-full items-center justify-center p-4">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent"></div>
</div>
{:else if error}
<div class="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<span class="text-sm text-destructive">{error}</span>
<button
onclick={fetchData}
class="text-xs text-primary hover:underline"
>
Retry
</button>
</div>
{:else if integrationData}
{#if integrationData.renderer === 'stat-card'}
<StatCardRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'gauge'}
<GaugeRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'list'}
<ListRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'progress'}
<ProgressRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'alert-banner'}
<AlertBannerRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'chart'}
<ChartRenderer data={integrationData.data} />
{:else}
<div class="p-4 text-sm text-muted-foreground">Unknown renderer: {integrationData.renderer}</div>
{/if}
{/if}
@@ -0,0 +1,44 @@
<script lang="ts">
import type { ListData } from '$lib/server/integrations/types.js';
interface Props {
data: ListData;
}
let { data }: Props = $props();
</script>
<div class="max-h-64 space-y-1 overflow-y-auto p-2">
{#if data.items.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">No items</p>
{:else}
{#each data.items as item (item.id)}
{@const Tag = item.url ? 'a' : 'div'}
<svelte:element
this={Tag}
href={item.url ?? undefined}
target={item.url ? '_blank' : undefined}
rel={item.url ? 'noopener noreferrer' : undefined}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent/50"
>
{#if item.icon}
<span class="shrink-0 text-base">{item.icon}</span>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-foreground">{item.title}</p>
{#if item.subtitle}
<p class="truncate text-xs text-muted-foreground">{item.subtitle}</p>
{/if}
</div>
{#if item.badge}
<span
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
style="background-color: {item.badge.color}20; color: {item.badge.color}"
>
{item.badge.text}
</span>
{/if}
</svelte:element>
{/each}
{/if}
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { ProgressData } from '$lib/server/integrations/types.js';
interface Props {
data: ProgressData;
}
let { data }: Props = $props();
</script>
<div class="max-h-64 space-y-3 overflow-y-auto p-3">
{#if data.items.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">No active items</p>
{:else}
{#each data.items as item (item.id)}
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="truncate font-medium text-foreground">{item.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{Math.round(item.progress)}%
{#if item.speed}
&middot; {item.speed}
{/if}
</span>
</div>
{#if item.subtitle}
<p class="text-xs text-muted-foreground">{item.subtitle}</p>
{/if}
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-500 ease-out"
style="width: {Math.min(item.progress, 100)}%"
></div>
</div>
</div>
{/each}
{/if}
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { StatCardData } from '$lib/server/integrations/types.js';
interface Props {
data: StatCardData;
}
let { data }: Props = $props();
const trendIcon = $derived(
data.trend === 'up' ? '↑' : data.trend === 'down' ? '↓' : ''
);
const trendColor = $derived(
data.trend === 'up' ? 'text-green-500' : data.trend === 'down' ? 'text-red-500' : 'text-muted-foreground'
);
</script>
<div class="flex flex-col items-center justify-center gap-1 p-4 text-center">
<div class="flex items-baseline gap-1">
<span class="text-3xl font-bold text-foreground">{data.value}</span>
{#if data.unit}
<span class="text-sm text-muted-foreground">{data.unit}</span>
{/if}
{#if trendIcon}
<span class="text-sm font-medium {trendColor}">{trendIcon}</span>
{/if}
</div>
<span class="text-sm font-medium text-muted-foreground">{data.label}</span>
{#if data.subtitle}
<span class="text-xs text-muted-foreground/70">{data.subtitle}</span>
{/if}
</div>