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"