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
@@ -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>