Files
FEDEO/frontend/components/AgentSetupWizard.vue

289 lines
17 KiB
Vue

<script setup lang="ts">
import type { InstanceAgent } from "~/composables/useAdmin"
import FileScanModal from "~/components/FileScanModal.vue"
import bundle from "~/assets/device-agent/bundle.json"
import { agentBackendUrl, agentSetupReady, ix1600ScanDefaults, renderAgentInstaller } from "~/utils/agentInstaller.mjs"
const props = defineProps<{ initialAgent?: InstanceAgent | null }>()
const emit = defineEmits<{ updated: [id: string]; close: [] }>()
const admin = useAdmin()
const toast = useToast()
const modal = useModal()
const runtimeConfig = useRuntimeConfig()
const steps = ["Agent", "Installation", "Verbindung", "Scanner"]
const step = ref(0)
const agent = ref<InstanceAgent | null>(props.initialAgent || null)
const name = ref(props.initialAgent?.name || "Linux Büro")
const description = ref(props.initialAgent?.description || "")
const backendUrl = ref("")
const token = ref("")
const autostart = ref(true)
const rotateConfirmed = ref(false)
const busy = ref(false)
const downloaded = ref(false)
const saved = ref(false)
const selectedScanner = ref(props.initialAgent?.preferredScannerName || "")
const profile = ref(props.initialAgent && !/ix1600/i.test(props.initialAgent.preferredScannerName || "") ? "existing" : "ix1600")
const now = ref(Date.now())
const checkingSince = ref(0)
const checking = ref(false)
const connectionError = ref("")
const configurationError = ref("")
let disposed = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let clockTimer: ReturnType<typeof setInterval> | undefined
const filename = computed(() => 'fedeo-agent-' + (agent.value?.id || 'setup') + '.sh')
const installCommand = computed(() => 'bash ' + filename.value)
const tokenReady = computed(() => /^fedeo_agent_[a-f0-9]{64}$/.test(token.value.trim()))
const online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.value))
const scannerOptions = computed(() => (agent.value?.scannerNames || []).map(value => ({ label: value, value })))
const setupScanDefaults = computed(() => profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false,
backgroundCleanup: true, ocr: true, ocrLanguage: 'deu+eng', ...agent.value?.scanDefaults,
})
const processingMissing = computed(() =>
(setupScanDefaults.value.format === 'pdf' && setupScanDefaults.value.ocr && !agent.value?.capabilities?.ocr) ||
(setupScanDefaults.value.backgroundCleanup && !agent.value?.capabilities?.backgroundCleanup))
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && !processingMissing.value && scannerOptions.value.some(item => item.value === selectedScanner.value))
const endpointPreview = computed(() => {
try { return agentBackendUrl(backendUrl.value) + '/instance-agent/heartbeat' } catch { return '' }
})
// Tokens stay in component memory and in the user's downloaded file only.
watch([backendUrl, token, autostart], () => { downloaded.value = false; configurationError.value = '' })
const fail = (error: any, title: string) => toast.add({ title, description: error?.data?.error || error?.message || 'Unbekannter Fehler', color: 'error' })
const prepareAgent = async () => {
if (busy.value || (!agent.value && !name.value.trim())) return
busy.value = true
try {
if (!agent.value) {
const result = await admin.createInstanceAgent({ name: name.value.trim(), description: description.value.trim() || null })
agent.value = result.agent
token.value = result.token
emit('updated', result.agent.id)
} else if (!agent.value.active) {
await admin.updateInstanceAgent(agent.value.id, { active: true })
agent.value = { ...agent.value, active: true }
emit('updated', agent.value.id)
}
step.value = 1
} catch (error) { fail(error, 'Agent konnte nicht vorbereitet werden') }
finally { busy.value = false }
}
const rotateToken = async () => {
if (!agent.value || !rotateConfirmed.value || busy.value) return
busy.value = true
try {
token.value = (await admin.rotateInstanceAgentToken(agent.value.id)).token
rotateConfirmed.value = false
emit('updated', agent.value.id)
} catch (error) { fail(error, 'Token konnte nicht erneuert werden') }
finally { busy.value = false }
}
const downloadInstaller = () => {
if (!agent.value) return
try {
const script = renderAgentInstaller(bundle, { agentId: agent.value.id, token: token.value.trim(), backendUrl: backendUrl.value, autostart: autostart.value })
const url = URL.createObjectURL(new Blob([script], { type: 'text/x-shellscript;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = filename.value
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
downloaded.value = true
} catch (error: any) { configurationError.value = error.message }
}
const copyCommand = async () => {
try {
await navigator.clipboard.writeText(installCommand.value)
toast.add({ title: 'Installationsbefehl kopiert', color: 'success' })
} catch { toast.add({ title: 'Bitte den angezeigten Befehl manuell kopieren', color: 'info' }) }
}
const stopPolling = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined }
const checkConnection = async () => {
if (!agent.value || checking.value || disposed) return
stopPolling()
checking.value = true
connectionError.value = ''
try {
const result = await admin.getInstanceAgents(true)
if (disposed) return
const current = result.agents.find(item => item.id === agent.value?.id)
if (!current) throw new Error('Dieser Agent wurde nicht gefunden. Bitte die Verwaltung aktualisieren.')
agent.value = current
now.value = Date.now()
if (!current.scannerNames?.includes(selectedScanner.value)) {
selectedScanner.value = current.scannerNames?.find(scanner => /ix1600/i.test(scanner)) || current.scannerNames?.[0] || ''
}
} catch (error: any) { connectionError.value = error?.data?.error || error?.message || 'Verbindung zu FEDEO fehlgeschlagen' }
finally {
checking.value = false
if (!disposed && step.value >= 2 && !saved.value && !connectionError.value) pollTimer = setTimeout(checkConnection, 5000)
}
}
const startConnectionCheck = () => { step.value = 2; checkingSince.value = Date.now() - 1000; checkConnection() }
const backToInstallation = () => { step.value = 1; stopPolling() }
const finishSetup = async () => {
if (!agent.value || !readyToSave.value || busy.value) return
busy.value = true
try {
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults: setupScanDefaults.value })
saved.value = true
token.value = ''
stopPolling()
emit('updated', agent.value.id)
} catch (error) { fail(error, 'Scanner konnte nicht gespeichert werden') }
finally { busy.value = false }
}
const openTestScan = () => modal.open(FileScanModal, { scanData: { agentId: agent.value?.id, folder: null, type: null } })
onMounted(() => {
backendUrl.value = agentBackendUrl(runtimeConfig.public.apiBase, window.location.origin)
clockTimer = setInterval(() => { now.value = Date.now() }, 1000)
})
onBeforeUnmount(() => {
disposed = true
token.value = ''
stopPolling()
if (clockTimer) clearInterval(clockTimer)
})
</script>
<template>
<UCard class="mb-6" :ui="{ root: 'rounded-lg border-primary/40' }">
<template #header>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-highlighted">{{ agent ? agent.name + ' einrichten' : 'Scanner-Agent einrichten' }}</h2>
<p class="mt-1 text-sm text-muted">Schritt für Schritt auf deinem Linux-Rechner installieren und mit FEDEO verbinden.</p>
</div>
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" aria-label="Einrichtung schließen" :disabled="busy" @click="emit('close')" />
</div>
</template>
<ol class="mb-7 grid grid-cols-2 gap-3 sm:grid-cols-4" aria-label="Einrichtungsschritte">
<li v-for="(label, index) in steps" :key="label" :aria-current="step === index ? 'step' : undefined"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
:class="step === index ? 'bg-primary/10 font-semibold text-primary' : 'bg-muted/40 text-muted'">
<span class="flex size-6 shrink-0 items-center justify-center rounded-full border border-current text-xs">{{ saved || index < step ? '✓' : index + 1 }}</span>
{{ label }}
</li>
</ol>
<div v-if="step === 0" class="max-w-2xl space-y-5">
<p class="text-sm text-muted">Der Agent läuft auf dem Rechner, an dem dein Scanner per USB angeschlossen ist. Der Download unterstützt Linux Mint, Ubuntu und Debian.</p>
<template v-if="!agent">
<UFormField label="Name des Rechners" required>
<UInput v-model="name" placeholder="Linux Büro" class="w-full" :disabled="busy" @keydown.enter.prevent="prepareAgent" />
</UFormField>
<UFormField label="Beschreibung">
<UInput v-model="description" placeholder="Standort oder Arbeitsplatz" class="w-full" :disabled="busy" />
</UFormField>
</template>
<UAlert v-else color="info" variant="soft" title="Vorhandenen Agenten einrichten"
:description="agent.active ? 'Du kannst die Installation auf diesem Rechner erneuern.' : 'Beim Fortsetzen wird dieser Agent wieder aktiviert.'" />
<UButton :loading="busy" :disabled="!agent && !name.trim()" @click="prepareAgent">{{ agent ? 'Weiter zur Installation' : 'Agent anlegen und weiter' }}</UButton>
</div>
<div v-else-if="step === 1" class="grid gap-6 lg:grid-cols-2">
<div class="space-y-5">
<UFormField label="FEDEO-Serveradresse" description="Vom Scanner-Rechner erreichbare Adresse. Ein vorhandener Pfad wie /backend muss erhalten bleiben.">
<UInput v-model="backendUrl" class="w-full" placeholder="https://fedeo.example.com/backend" />
</UFormField>
<p v-if="endpointPreview" class="break-all text-xs text-muted">Verbindungsziel: {{ endpointPreview }}</p>
<UAlert v-if="tokenReady" color="success" variant="soft" title="Zugangstoken bereit" description="Der Token wird in deine Installationsdatei übernommen. Er bleibt auf dieser Seite nur bis zum Schließen verfügbar." />
<template v-else>
<UFormField label="Vorhandener Agent-Token" description="Falls du ihn bereits gesichert hast, hier einfügen.">
<UInput v-model="token" type="password" autocomplete="off" class="w-full" placeholder="fedeo_agent_…" />
</UFormField>
<div class="space-y-3 rounded-lg border border-default p-4">
<p class="text-sm text-muted">Token nicht mehr vorhanden? Ein neuer Token ersetzt den bisherigen Zugang.</p>
<UCheckbox v-model="rotateConfirmed" label="Die bisherige Installation darf ihren Zugang verlieren." />
<UButton color="neutral" variant="outline" :disabled="!rotateConfirmed" :loading="busy" @click="rotateToken">Neuen Token erzeugen</UButton>
</div>
</template>
<UCheckbox v-model="autostart" label="Agent bei meiner Linux-Anmeldung automatisch starten" />
<p class="text-xs text-muted">Ohne Autostart läuft der Agent im Terminal. Der Rechner muss zum Scannen eingeschaltet und angemeldet sein.</p>
</div>
<div class="space-y-4 rounded-lg bg-muted/40 p-5">
<h3 class="font-semibold text-highlighted">Installation herunterladen</h3>
<p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie installiert bei Bedarf Node.js ab Version 20, SANE, Bildverarbeitung und OCR für Deutsch/Englisch mit sudo.</p>
<p class="text-sm text-muted">Die Installation erfolgt in deinem Benutzerverzeichnis. Starte die Datei auf dem Rechner mit dem Scanner.</p>
<UAlert v-if="configurationError" color="error" variant="soft" :title="configurationError" />
<UButton icon="i-heroicons-arrow-down-tray" :disabled="!tokenReady || busy" @click="downloadInstaller">Linux-Installation herunterladen</UButton>
<p class="text-xs text-muted">Die Datei enthält Zugangsdaten. Bewahre sie geschützt auf oder lösche sie nach erfolgreicher Einrichtung.</p>
<UButton v-if="downloaded" icon="i-heroicons-arrow-right" @click="startConnectionCheck">Weiter: Starten und Verbindung prüfen</UButton>
</div>
</div>
<div v-else-if="step === 2" class="grid gap-6 lg:grid-cols-2">
<div class="space-y-4">
<h3 class="font-semibold text-highlighted">Auf dem Scanner-Rechner starten</h3>
<ol class="list-decimal space-y-3 pl-5 text-sm text-muted">
<li>Scanner einschalten und per USB anschließen.</li>
<li>Im Download-Ordner ein Terminal öffnen und diesen Befehl ausführen:</li>
</ol>
<div class="rounded-lg bg-muted p-3">
<code class="block break-all text-sm text-highlighted">{{ installCommand }}</code>
<UButton class="mt-3" size="sm" color="neutral" variant="outline" icon="i-heroicons-clipboard-document" @click="copyCommand">Befehl kopieren</UButton>
</div>
<p class="text-sm text-muted">Ohne sudo starten. Falls Systempakete fehlen, fragt die Installation nach deinem Passwort. Lass diese Seite geöffnet; der Status wird alle fünf Sekunden geprüft.</p>
<UButton color="neutral" variant="ghost" @click="backToInstallation">Zurück zur Konfiguration</UButton>
</div>
<div class="space-y-4" aria-live="polite">
<UAlert :color="online ? 'success' : 'info'" variant="soft" :title="online ? 'Agent mit FEDEO verbunden' : 'Warte auf Verbindung vom Agenten'"
:description="online ? 'Rechner: ' + (agent?.lastDebugInfo?.hostname || agent?.name) : 'Sobald die Installation gestartet ist und sich dieser Agent meldet, erscheint er hier.'" />
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
<p class="text-sm text-muted">Erkannte Scanner: {{ agent?.scannerNames?.length || 0 }}</p>
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Jetzt prüfen</UButton>
<UButton :disabled="!online" @click="step = 3">Weiter: Scanner auswählen</UButton>
<details class="text-sm text-muted">
<summary class="cursor-pointer font-medium">Verbindung kommt nicht zustande?</summary>
<p class="mt-3">Die Installationsausgabe auf Fehler prüfen. Die FEDEO-Adresse muss von diesem Rechner erreichbar sein und den Backend-Pfad enthalten. „localhost“ verweist immer auf den Scanner-Rechner. Bei einem erneuerten Token muss die neue Installationsdatei ausgeführt werden.</p>
</details>
</div>
</div>
<div v-else-if="!saved" class="max-w-2xl space-y-5">
<UAlert v-if="!online" color="warning" variant="soft" title="Agent ist derzeit nicht verbunden" description="Agent starten und die Verbindung erneut prüfen." />
<UAlert v-else-if="!agent?.capabilities?.scan || !scannerOptions.length" color="warning" variant="soft" title="Noch kein Scanner verfügbar"
description="USB-Verbindung und Stromversorgung prüfen. scanimage -L muss den Scanner für denselben Linux-Benutzer anzeigen." />
<UAlert v-else-if="processingMissing" color="warning" variant="soft" title="Scan-Verarbeitung noch nicht bereit"
description="Für dieses Profil fehlen OCR oder Hintergrundbereinigung. Die aktuelle Linux-Installation aus Schritt 2 ausführen und anschließend den Agenten neu starten." />
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
<UFormField label="Scanner" required>
<USelectMenu v-model="selectedScanner" :items="scannerOptions" value-key="value" label-key="label" class="w-full" placeholder="Erkannten Scanner auswählen" />
</UFormField>
<UFormField label="Scan-Profil">
<USelectMenu v-model="profile" :items="[
{ label: 'ScanSnap iX1600: A4, Duplex, PDF, 300 dpi', value: 'ix1600' },
{ label: 'Bisherige Einstellungen / allgemeiner Scanner', value: 'existing' }
]" value-key="value" label-key="label" class="w-full" />
</UFormField>
<p class="text-sm text-muted">Das iX1600-Profil speichert Vorder- und Rückseiten als durchsuchbares PDF. Erkannter Scannerhintergrund außerhalb des Papiers wird weiß; das A4-Format bleibt erhalten.</p>
<div class="flex flex-wrap gap-3">
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton>
<UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton>
</div>
</div>
<div v-else class="space-y-4">
<UAlert color="success" variant="soft" title="Dein Scanner ist eingerichtet" description="Du kannst jetzt unter Dateien → Scan scannen. Das PDF wird im geöffneten Ordner gespeichert." />
<p class="text-sm text-muted">Für einen Test zwei Blätter einlegen. Der Testscan wird im Hauptordner des aktuell ausgewählten Mandanten gespeichert; der Scan startet erst nach deinem Klick im Scan-Dialog.</p>
<div class="flex flex-wrap gap-3">
<UButton icon="i-heroicons-document-magnifying-glass" @click="openTestScan">Testscan öffnen</UButton>
<UButton color="neutral" variant="outline" @click="emit('close')">Zur Scannerverwaltung</UButton>
</div>
</div>
</UCard>
</template>