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:
@@ -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}
|
||||
· {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>
|
||||
Reference in New Issue
Block a user