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>
|
||||
Reference in New Issue
Block a user