Files
web-app-launcher/src/lib/components/app/AppForm.svelte
T
alexei.dolgolyov 50e8519220 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
2026-03-25 22:07:51 +03:00

391 lines
12 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
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';
type AppSchema = z.infer<typeof createAppSchema>;
interface Props {
form: SuperValidated<AppSchema>;
action?: string;
}
let { form: formData, action = '?/create' }: Props = $props();
const { form, errors, enhance, submitting } = superForm(formData, {
resetForm: true
});
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' },
{ value: 'HEAD', icon: '📋', label: 'HEAD', desc: 'Headers only' }
];
</script>
<form method="POST" {action} use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.name')} <span class="text-destructive">{$t('common.required')}</span>
</label>
<input
id="name"
name="name"
type="text"
bind:value={$form.name}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={$t('app.name_placeholder')}
/>
{#if $errors.name}
<p class="mt-1 text-sm text-destructive">{$errors.name[0]}</p>
{/if}
</div>
<div>
<label for="url" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.url')} <span class="text-destructive">{$t('common.required')}</span>
</label>
<input
id="url"
name="url"
type="url"
bind:value={$form.url}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={$t('app.url_placeholder')}
/>
{#if $errors.url}
<p class="mt-1 text-sm text-destructive">{$errors.url[0]}</p>
{/if}
</div>
</div>
<!-- URL Preview / Test Connection -->
<AppUrlPreview
url={$form.url ?? ''}
currentIcon={$form.icon ?? ''}
currentName={$form.name ?? ''}
onApplyFavicon={(favicon) => {
$form.icon = favicon;
$form.iconType = 'url';
}}
onApplyTitle={(title) => {
$form.name = title;
}}
/>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.description')}
</label>
<input
id="description"
name="description"
type="text"
bind:value={$form.description}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={$t('app.description_placeholder')}
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.category')}
</label>
<input
id="category"
name="category"
type="text"
bind:value={$form.category}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={$t('app.category_placeholder')}
/>
</div>
<div>
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.tags')}
</label>
<input
id="tags"
name="tags"
type="text"
bind:value={$form.tags}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={$t('app.tags_placeholder')}
/>
</div>
</div>
<AppIconPicker
iconType={$form.iconType ?? 'lucide'}
iconValue={$form.icon ?? ''}
onchange={(type, value) => {
$form.iconType = type as typeof $form.iconType;
$form.icon = value;
}}
/>
<input type="hidden" name="icon" value={$form.icon ?? ''} />
<input type="hidden" name="iconType" value={$form.iconType ?? 'lucide'} />
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
class="text-sm text-muted-foreground hover:text-foreground"
>
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
</button>
{#if showAdvanced}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
id="healthcheckEnabled"
name="healthcheckEnabled"
type="checkbox"
bind:checked={$form.healthcheckEnabled}
class="rounded border-input"
/>
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
{$t('app.healthcheck_enabled')}
</label>
</div>
{#if $form.healthcheckEnabled}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_method')}
</label>
<IconGrid
items={healthcheckMethodItems}
value={$form.healthcheckMethod ?? 'GET'}
onchange={(v) => ($form.healthcheckMethod = v as 'GET' | 'HEAD')}
name="healthcheckMethod"
columns={2}
/>
</div>
<div>
<label
for="healthcheckExpectedStatus"
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_expected_status')}
</label>
<input
id="healthcheckExpectedStatus"
name="healthcheckExpectedStatus"
type="number"
bind:value={$form.healthcheckExpectedStatus}
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"
min="100"
max="599"
/>
</div>
<div>
<label
for="healthcheckTimeout"
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_timeout')}
</label>
<input
id="healthcheckTimeout"
name="healthcheckTimeout"
type="number"
bind:value={$form.healthcheckTimeout}
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"
min="1000"
max="30000"
step="1000"
/>
</div>
</div>
<div>
<label
for="healthcheckInterval"
class="mb-1 block text-sm font-medium text-card-foreground"
>
{$t('app.healthcheck_interval')}
</label>
<input
id="healthcheckInterval"
name="healthcheckInterval"
type="number"
bind:value={$form.healthcheckInterval}
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"
min="30"
max="86400"
/>
</div>
{/if}
</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"
disabled={$submitting}
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
{$t('app.saving')}
{:else}
{$t('app.save')}
{/if}
</button>
</div>
</form>