feat(scan): add guided Linux agent setup in administration
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 29s

This commit is contained in:
2026-09-17 18:56:30 +02:00
parent 1329f164d8
commit 68c0a224e5
15 changed files with 869 additions and 89 deletions

File diff suppressed because one or more lines are too long

View 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>

View File

@@ -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({

View File

@@ -255,8 +255,10 @@ export const useAdmin = () => {
return await $api("/api/admin/system-status")
}
const getInstanceAgents = async (): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents") as any
const getInstanceAgents = async (quiet = false): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents", quiet ? {
retry: 0, timeout: 15000, onRequestError: () => {}, onResponseError: () => {},
} : {}) as any
return { agents: response?.agents || [] }
}
@@ -274,12 +276,17 @@ export const useAdmin = () => {
})
}
const rotateInstanceAgentToken = async (id: string): Promise<{ token: string }> => {
return await $api(`/api/instance-agents/${id}/token`, { method: "POST" })
}
return {
getOverview,
getSystemStatus,
getInstanceAgents,
createInstanceAgent,
updateInstanceAgent,
rotateInstanceAgentToken,
createUser,
createUserForProfile,
updateUser,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { InstanceAgent } from "~/composables/useAdmin"
import AgentSetupWizard from "~/components/AgentSetupWizard.vue"
const auth = useAuthStore()
const router = useRouter()
@@ -8,15 +9,16 @@ const admin = useAdmin()
const loading = ref(true)
const saving = ref(false)
const creating = ref(false)
const agents = ref<InstanceAgent[]>([])
const selectedAgentId = ref<string | null>(null)
const generatedToken = ref("")
const newAgent = reactive({
name: "",
description: "",
})
const setupOpen = ref(false)
const setupTarget = ref<InstanceAgent | null>(null)
const setupKey = ref(0)
const openSetup = (agent: InstanceAgent | null = null) => {
setupTarget.value = agent
setupKey.value++
setupOpen.value = true
}
const editState = reactive({
name: "",
@@ -103,38 +105,9 @@ const loadAgents = async () => {
}
}
const createAgent = async () => {
if (!newAgent.name.trim()) return
creating.value = true
generatedToken.value = ""
try {
const response = await admin.createInstanceAgent({
name: newAgent.name.trim(),
description: newAgent.description.trim() || null,
})
generatedToken.value = response.token
newAgent.name = ""
newAgent.description = ""
selectedAgentId.value = response.agent.id
await loadAgents()
toast.add({
title: "Agent angelegt",
description: "Der Token wird nur jetzt vollständig angezeigt.",
color: "success",
})
} catch (err: any) {
toast.add({
title: "Agent konnte nicht angelegt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error",
})
} finally {
creating.value = false
}
const setupUpdated = async (id: string) => {
selectedAgentId.value = id
await loadAgents()
}
const saveAgent = async () => {
@@ -192,6 +165,7 @@ onMounted(async () => {
<template>
<UDashboardNavbar title="Administration: Scanner">
<template #right>
<UButton v-if="!setupOpen" icon="i-heroicons-plus" @click="openSetup()">Agent einrichten</UButton>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
@@ -205,47 +179,17 @@ onMounted(async () => {
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<AgentSetupWizard v-if="setupOpen" :key="setupKey" :initial-agent="setupTarget" @updated="setupUpdated" @close="setupOpen = false" />
<div v-show="!setupOpen" class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-plus-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Agent anlegen</h2>
</div>
</template>
<form class="space-y-3" @submit.prevent="createAgent">
<UFormField label="Name">
<UInput v-model="newAgent.name" placeholder="MacBook Büro" />
</UFormField>
<UFormField label="Beschreibung">
<UTextarea v-model="newAgent.description" :rows="2" placeholder="Arbeitsplatz oder Standort" />
</UFormField>
<UButton
type="submit"
icon="i-heroicons-plus"
:loading="creating"
:disabled="!newAgent.name.trim()"
>
Agent anlegen
</UButton>
</form>
<div class="space-y-4">
<h2 class="text-base font-semibold text-highlighted">Neuen Scanner-Rechner verbinden</h2>
<p class="text-sm text-muted">Der Assistent erstellt die passende Linux-Installation und begleitet dich bis zum ersten Scan.</p>
<UButton icon="i-heroicons-sparkles" @click="openSetup()">Einrichtung starten</UButton>
</div>
</UCard>
<UAlert
v-if="generatedToken"
color="warning"
variant="soft"
icon="i-heroicons-key"
title="Agent-Token"
description="Diesen Token jetzt in die .env des lokalen Agenten übernehmen. Später wird er nicht erneut angezeigt."
>
<template #description>
<code class="mt-2 block break-all rounded bg-muted px-2 py-1 text-xs">{{ generatedToken }}</code>
</template>
</UAlert>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center justify-between gap-3">
@@ -290,7 +234,8 @@ onMounted(async () => {
<h2 class="text-base font-semibold text-highlighted">{{ selectedAgent.name }}</h2>
<p class="text-xs text-muted">Token-Präfix {{ selectedAgent.tokenPrefix }} · zuletzt gesehen {{ formatDate(selectedAgent.lastSeenAt) }}</p>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<UButton color="neutral" variant="outline" size="sm" @click="openSetup(selectedAgent)">Einrichtung öffnen</UButton>
<UBadge :color="isOnline(selectedAgent) ? 'success' : 'neutral'" variant="soft">
{{ isOnline(selectedAgent) ? "Online" : "Offline" }}
</UBadge>

View File

@@ -0,0 +1,129 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
const token = 'fedeo_agent_' + 'a'.repeat(64)
const scanner = 'fujitsu:ScanSnap iX1600:test'
const baseAgent = { id: agentId, name: 'Linux Büro', active: true, scannerNames: [], scanDefaults: {}, capabilities: {} }
async function setup(t, initialAgent = null, overrides = {}) {
const helpers = await import('../utils/agentInstaller.mjs')
const requests = [], notices = [], events = [], downloads = [], modals = []
const timers = new Map()
const mounted = [], unmounted = []
let timerId = 0
let reportedAgent = { ...baseAgent }
const context = vm.createContext({
...helpers, ref: vue.ref, computed: vue.computed, watch: vue.watch,
defineProps: () => ({ initialAgent }),
defineEmits: () => (...args) => events.push(args),
onMounted: handler => mounted.push(handler), onBeforeUnmount: handler => unmounted.push(handler),
useAdmin: () => ({
createInstanceAgent: async body => { requests.push(['create', body]); return { agent: { ...baseAgent }, token } },
updateInstanceAgent: async (id, body) => { requests.push(['update', id, body]); return { agent: { ...baseAgent, ...body } } },
rotateInstanceAgentToken: async id => { requests.push(['rotate', id]); return { token } },
getInstanceAgents: async () => ({ agents: [reportedAgent] }),
...overrides,
}),
useToast: () => ({ add: notice => notices.push(notice) }),
useModal: () => ({ open: (...args) => modals.push(args) }),
useRuntimeConfig: () => ({ public: { apiBase: '/backend' } }),
window: { location: { origin: 'https://fedeo.example.com' } },
bundle: JSON.parse(fs.readFileSync(__dirname + '/../assets/device-agent/bundle.json', 'utf8')),
FileScanModal: 'FileScanModal',
Blob, URL: { createObjectURL: blob => { downloads.push(blob); return 'blob:test' }, revokeObjectURL() {} },
document: { createElement: () => ({ click() {}, remove() {} }), body: { appendChild() {} } },
setTimeout: callback => { timers.set(++timerId, callback); return timerId },
clearTimeout: id => timers.delete(id), setInterval: () => -1, clearInterval() {},
})
const source = fs.readFileSync(__dirname + '/../components/AgentSetupWizard.vue', 'utf8')
.split('<script setup lang="ts">')[1].split('</script>')[0].replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }), context)
mounted.forEach(handler => handler())
await new Promise(setImmediate)
const close = () => unmounted.forEach(handler => handler())
t.after(close)
return { run: code => vm.runInContext(code, context), requests, notices, events, downloads, modals, timers, close, report: value => { reportedAgent = value } }
}
test('new setup creates one agent and downloads software with the correct reverse-proxy URL', async t => {
const ui = await setup(t)
await ui.run('prepareAgent()')
await new Promise(setImmediate)
assert.equal(ui.run('step.value'), 1)
ui.run('downloadInstaller()')
assert.equal(ui.requests.filter(item => item[0] === 'create').length, 1)
assert.equal(ui.downloads.length, 1)
assert.ok((await ui.downloads[0].text()).startsWith('#!/usr/bin/env bash'))
assert.equal(ui.run('endpointPreview.value'), 'https://fedeo.example.com/backend/instance-agent/heartbeat')
assert.equal(ui.run('downloaded.value'), true)
ui.run('backendUrl.value="https://other.example.com/backend"')
await new Promise(setImmediate)
assert.equal(ui.run('downloaded.value'), false)
})
test('existing agent requires explicit acknowledgement before token rotation', async t => {
const ui = await setup(t, { ...baseAgent })
await ui.run('prepareAgent()')
await ui.run('rotateToken()')
assert.equal(ui.requests.length, 0)
ui.run('rotateConfirmed.value=true')
await ui.run('rotateToken()')
assert.equal(ui.requests[0][0], 'rotate')
assert.equal(ui.run('tokenReady.value'), true)
assert.equal(ui.run('rotateConfirmed.value'), false)
})
test('stale or inactive agents cannot finish setup; fresh reports save the chosen duplex scanner', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date(Date.now() - 300000).toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
ui.run('step.value=2; checkingSince.value=Date.now()-1000')
await ui.run('checkConnection()')
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), false)
assert.equal(ui.requests.length, 0)
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
await ui.run('checkConnection()')
ui.run('profile.value="ix1600"; step.value=3')
await ui.run('finishSetup()')
assert.equal(ui.run('saved.value'), true)
assert.equal(ui.requests[0][2].preferredScannerName, scanner)
assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex')
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
ui.run('openTestScan()')
assert.equal(ui.modals[0][1].scanData.agentId, agentId)
})
test('online agent without a detected scanner remains incomplete', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), capabilities: { scan: true } })
await ui.run('checkConnection()')
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), true)
assert.equal(ui.run('readyToSave.value'), false)
assert.equal(ui.requests.length, 0)
})
test('connection errors stop automatic requests and are visible for retry', async t => {
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
ui.run('step.value=2')
await ui.run('checkConnection()')
assert.equal(ui.run('connectionError.value'), 'offline')
assert.equal(ui.timers.size, 0)
})
test('closing setup clears credentials and does not schedule polling after an in-flight response', async t => {
let resolve
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: () => new Promise(done => { resolve = done }) })
ui.run('step.value=2; token.value="' + token + '"')
const pending = ui.run('checkConnection()')
ui.close()
resolve({ agents: [{ ...baseAgent, lastSeenAt: new Date().toISOString() }] })
await pending
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
})

View File

@@ -4,21 +4,21 @@ const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
async function setup(getJob) {
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
const notices = []
const requests = []
let unmount
const context = vm.createContext({
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
onBeforeUnmount: handler => { unmount = handler },
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document' } }),
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document', ...scanData } }),
defineEmits: () => () => {},
useModal: () => ({ close() {} }),
useToast: () => ({ add: notice => notices.push(notice) }),
setTimeout: callback => queueMicrotask(callback),
useNuxtApp: () => ({ $api: async (url, options) => {
requests.push({ url, options })
if (url === '/api/instance-agents') return { agents: [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }] }
if (url === '/api/instance-agents') return { agents }
if (url === '/api/scan-jobs') return { job: { id: 'job' } }
return getJob()
} }),
@@ -70,3 +70,16 @@ test('iX1600 preset resets image settings and normalizes the output filename to
assert.equal(ui.run('scanForm.postprocess'), false)
assert.equal(ui.run('scanForm.resolution'), 300)
})
test('setup test scan uses the requested agent even when another agent is first', async () => {
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true }, scannerNames: ['scanner-' + id] }))
const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents)
await ui.run('startScan()')
assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured')
})
test('an unavailable setup agent never silently scans with another device', async () => {
const ui = await setup(() => ({}), { agentId: 'missing' })
await ui.run('startScan()')
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
})

View File

@@ -0,0 +1,48 @@
export function agentBackendUrl(value, origin) {
if (/[\r\n\t]/.test(String(value || ''))) throw new Error('Die Serveradresse darf keine Steuerzeichen enthalten.')
const url = new URL(String(value || '/').trim(), origin)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
throw new Error('Bitte eine HTTP(S)-Adresse ohne Zugangsdaten, Suchparameter oder Fragment eingeben.')
}
return url.toString().replace(/\/+$/, '')
}
function base64(value) {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export function renderAgentInstaller(bundle, options) {
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(options.agentId)) {
throw new Error('Ungültige Agent-ID')
}
if (!/^fedeo_agent_[a-f0-9]{64}$/.test(options.token)) throw new Error('Bitte den vollständigen Agent-Token eingeben.')
const url = agentBackendUrl(options.backendUrl)
if (/\s|["'`\\]/.test(url)) throw new Error('Die Serveradresse enthält ungültige Zeichen.')
const configuration = [
`FEDEO_URL=${url}`, `FEDEO_AGENT_TOKEN=${options.token}`,
'FEDEO_POLL_SECONDS=5', 'FEDEO_SCAN_FORMAT=pdf', 'FEDEO_SCAN_RESOLUTION=300',
'FEDEO_SCAN_MODE=Color', 'FEDEO_SCAN_TIMEOUT_SECONDS=900',
'FEDEO_SCAN_POSTPROCESS=false', 'FEDEO_SCAN_POSTPROCESS_PROFILE=document', '',
].join('\n')
const replacements = {
AGENT_ID: options.agentId, AUTOSTART: String(Boolean(options.autostart)),
PAYLOAD_BASE64: base64(JSON.stringify(bundle.files)), CONFIG_BASE64: base64(configuration),
}
return bundle.installerTemplate.replace(/@@([A-Z0-9_]+)@@/g, (_, key) => {
if (!(key in replacements)) throw new Error('Unbekannter Platzhalter im Installationspaket')
return replacements[key]
})
}
export const ix1600ScanDefaults = {
format: 'pdf', resolution: 300, mode: 'Color', source: 'ADF Duplex',
paperSize: 'A4', postprocess: false, postprocessProfile: 'document',
}
export function agentSetupReady(agent, since = 0, now = Date.now()) {
const lastSeen = Date.parse(agent?.lastSeenAt || '')
return Boolean(agent?.active && Number.isFinite(lastSeen) && lastSeen >= since && now - lastSeen < 120000)
}