feat(scan): add guided Linux agent setup in administration
This commit is contained in:
282
frontend/components/AgentSetupWizard.vue
Normal file
282
frontend/components/AgentSetupWizard.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<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 readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && 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 {
|
||||
const scanDefaults = profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
|
||||
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false, ...agent.value.scanDefaults,
|
||||
}
|
||||
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults })
|
||||
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 prüft Node.js ab Version 20, SANE und Python/Pillow und installiert fehlende Systempakete 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-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 den gesamten Stapel mit Vorder- und Rückseiten in einem PDF. Weitere Einstellungen kannst du anschließend in der Scannerverwaltung ändern.</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>
|
||||
@@ -101,7 +101,9 @@ const loadAgents = async () => {
|
||||
agents.value = response?.agents || []
|
||||
|
||||
const firstOnlineAgent = activeAgents.value.find(isOnline)
|
||||
selectedAgentId.value = firstOnlineAgent?.id || activeAgents.value[0]?.id || null
|
||||
selectedAgentId.value = props.scanData.agentId
|
||||
? activeAgents.value.find(agent => agent.id === props.scanData.agentId)?.id || null
|
||||
: firstOnlineAgent?.id || activeAgents.value[0]?.id || null
|
||||
applyAgentDefaults(selectedAgent.value)
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
|
||||
Reference in New Issue
Block a user