Files
web-app-launcher/src/lib/components/admin/DiscoveryPanel.svelte
T
alexei.dolgolyov 7d8a8fb0fc feat(phase3): phase 7 - integration & polish
Fix all build/type/lint errors, write 46 new tests (222 total across
20 files), regenerate Prisma client, update seed with user preferences.
Fix SvelteSet usage, add {#each} keys, clean unused imports.
2026-03-25 01:12:11 +03:00

249 lines
7.5 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface DiscoveredService {
name: string;
url: string;
source: 'docker' | 'traefik';
icon?: string;
description?: string;
alreadyRegistered: boolean;
}
let {
dockerSocketPath = $bindable('/var/run/docker.sock'),
traefikApiUrl = $bindable('')
}: {
dockerSocketPath?: string;
traefikApiUrl?: string;
} = $props();
let scanning = $state(false);
let approving = $state(false);
let services = $state<DiscoveredService[]>([]);
let scanErrors = $state<string[]>([]);
let selected = new SvelteSet<number>();
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
function clearStatus() {
statusMessage = '';
statusType = '';
}
async function handleScan() {
clearStatus();
scanning = true;
services = [];
scanErrors = [];
selected.clear();
try {
const response = await fetch('/api/admin/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dockerSocketPath: dockerSocketPath || undefined,
traefikApiUrl: traefikApiUrl || undefined
})
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Discovery scan failed');
}
services = result.data.services;
scanErrors = result.data.errors;
if (services.length === 0) {
statusMessage = $t('admin.discovery_no_results');
statusType = 'error';
}
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Scan failed';
statusType = 'error';
} finally {
scanning = false;
}
}
function toggleSelect(index: number) {
if (selected.has(index)) {
selected.delete(index);
} else {
selected.add(index);
}
}
function toggleSelectAll() {
const selectableIndices = services
.map((s, i) => (s.alreadyRegistered ? -1 : i))
.filter((i) => i >= 0);
const allSelected = selected.size === selectableIndices.length;
selected.clear();
if (!allSelected) {
for (const idx of selectableIndices) {
selected.add(idx);
}
}
}
async function handleApprove() {
if (selected.size === 0) return;
clearStatus();
approving = true;
const toApprove = Array.from(selected).map((i) => services[i]);
try {
const response = await fetch('/api/admin/discover/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services: toApprove })
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Approval failed');
}
const { created, errors: approveErrors } = result.data;
const parts: string[] = [];
if (created > 0) parts.push(`${created} app(s) created`);
if (approveErrors.length > 0) parts.push(approveErrors.join('; '));
statusMessage = `${$t('admin.discovery_approve')}: ${parts.join('. ')}`;
statusType = approveErrors.length > 0 ? 'error' : 'success';
// Mark approved services as registered
services = services.map((s, i) =>
selected.has(i) ? { ...s, alreadyRegistered: true } : s
);
selected.clear();
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Approval failed';
statusType = 'error';
} finally {
approving = false;
}
}
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
</script>
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
<!-- Scan Button -->
<div class="mb-6">
<button
type="button"
onclick={handleScan}
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
class="rounded-md bg-primary px-4 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"
>
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button>
</div>
<!-- Scan Errors -->
{#if scanErrors.length > 0}
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
{#each scanErrors as scanError, idx (idx)}
<p>{scanError}</p>
{/each}
</div>
{/if}
<!-- Results Table -->
{#if services.length > 0}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="px-2 py-2 text-left">
<input
type="checkbox"
checked={selected.size === selectableCount && selectableCount > 0}
onchange={toggleSelectAll}
disabled={selectableCount === 0}
class="h-4 w-4 rounded border-input"
/>
</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">URL</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_source')}</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_status')}</th>
</tr>
</thead>
<tbody>
{#each services as service, i (service.url)}
<tr class="border-b border-border/50 hover:bg-muted/50">
<td class="px-2 py-2">
<input
type="checkbox"
checked={selected.has(i)}
onchange={() => toggleSelect(i)}
disabled={service.alreadyRegistered}
class="h-4 w-4 rounded border-input"
/>
</td>
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
<td class="px-2 py-2">
<a href={service.url} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">
{service.url}
</a>
</td>
<td class="px-2 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{service.source === 'docker'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}"
>
{service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')}
</span>
</td>
<td class="px-2 py-2">
{#if service.alreadyRegistered}
<span class="text-xs text-muted-foreground">{$t('admin.discovery_already_registered')}</span>
{:else}
<span class="text-xs font-medium text-green-600 dark:text-green-400">{$t('admin.discovery_new')}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Approve button -->
{#if selectableCount > 0}
<div class="mt-4">
<button
type="button"
onclick={handleApprove}
disabled={approving || selected.size === 0}
class="rounded-md bg-primary px-4 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"
>
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button>
</div>
{/if}
{/if}
<!-- Status Message -->
{#if statusMessage}
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
{statusMessage}
</div>
{/if}
</section>