7d8a8fb0fc
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.
249 lines
7.5 KiB
Svelte
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>
|