// frontend\src\pages\administration\milestone\MilestoneAdministration.tsx import { useEffect, useRef, useState, type FormEvent } from 'react' import { KeyIcon, ServerStackIcon, VideoCameraIcon, } from '@heroicons/react/20/solid' import Button from '../../../components/Button' import LoadingSpinner from '../../../components/LoadingSpinner' import { useNotifications } from '../../../components/Notifications' import Switch from '../../../components/Switch' import { DialogTitle } from '@headlessui/react' import { XMarkIcon } from '@heroicons/react/24/outline' import Modal from '../../../components/Modal' import TaskList, { type TaskListStep } from '../../../components/TaskList' import TreeList, { type TreeListNode } from '../../../components/TreeList' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' type MilestoneSettings = { host: string username: string passwordConfigured: boolean skipTlsVerify: boolean tokenConfigured: boolean tokenType: string tokenExpiresAt?: string tokenUpdatedAt?: string serverId: string serverName: string serverVersion: string serverUpdatedAt?: string serverFoldersAgentScheme: string serverFoldersAgentPort: string serverFoldersAgentTokenConfigured: boolean serverFoldersStorageRootLabel: string serverFoldersStorageRootPath: string serverFoldersArchiveRootLabel: string serverFoldersArchiveRootPath: string backupNasHost: string backupNasShare: string backupNasUsername: string backupNasPasswordConfigured: boolean backupLogRootPath: string updatedAt?: string } type BackupFolderRoot = { label: string path: string } type BackupFolderItem = { name: string displayName?: string path: string hasChildren?: boolean } type BackupFoldersResponse = { path: string roots?: BackupFolderRoot[] folders?: BackupFolderItem[] warning?: string } type MilestoneSyncEvent = { type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error' message?: string tokenType?: string tokenExpiresAt?: string recordingServerId?: string recordingServerName?: string recordingServerVersion?: string hardwareCount?: number settings?: MilestoneSettings } const inputClassName = 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' async function readApiError(response: Response, fallback: string) { const data = await response.json().catch(() => null) return data?.error ?? fallback } function formatUpdatedAt(value?: string) { if (!value) { return null } try { return new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short', }).format(new Date(value)) } catch { return value } } function createMilestoneSyncSteps(): TaskListStep[] { return [ { id: 'token', name: 'Token abrufen', description: 'Authentifizierung am Milestone-Server läuft.', status: 'current', }, { id: 'recordingServer', name: 'Recording-Server abfragen', description: 'Server-ID, Name und Version werden geladen.', status: 'upcoming', }, { id: 'hardware', name: 'Milestone-Daten synchronisieren', description: 'Geräte, Untergeräte und Treiberdaten werden geladen.', status: 'upcoming', }, ] } function updateMilestoneSyncStepsForEvent( steps: TaskListStep[], event: MilestoneSyncEvent, ): TaskListStep[] { if (event.type === 'token') { const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt) return steps.map((step) => { if (step.id === 'token') { return { ...step, status: 'complete', description: tokenExpiresAt ? `Token wurde abgerufen. Gültig bis ${tokenExpiresAt}.` : 'Token wurde erfolgreich abgerufen.', } } if (step.id === 'recordingServer') { return { ...step, status: 'current', } } return step }) } if (event.type === 'recordingServer') { const recordingServerDescription = [ event.recordingServerName || 'Recording-Server wurde abgerufen.', event.recordingServerVersion ? `Version ${event.recordingServerVersion}` : '', event.recordingServerId ? `ID: ${event.recordingServerId}` : '', ] .filter(Boolean) .join(' · ') return steps.map((step) => { if (step.id === 'recordingServer') { return { ...step, status: 'complete', description: recordingServerDescription, } } if (step.id === 'hardware') { return { ...step, status: 'current', } } return step }) } if (event.type === 'hardware') { return steps.map((step) => { if (step.id === 'hardware') { return { ...step, status: 'complete', description: event.hardwareCount === 1 ? '1 Hardware-Gerät wurde synchronisiert.' : `${event.hardwareCount ?? 0} Hardware-Geräte wurden synchronisiert.`, } } return step }) } return steps } function markCurrentMilestoneSyncStepAsError( steps: TaskListStep[], message: string, ): TaskListStep[] { const currentStepIndex = steps.findIndex((step) => step.status === 'current') const upcomingStepIndex = steps.findIndex((step) => step.status === 'upcoming') const failedStepIndex = currentStepIndex >= 0 ? currentStepIndex : upcomingStepIndex >= 0 ? upcomingStepIndex : steps.length - 1 return steps.map((step, index) => index === failedStepIndex ? { ...step, status: 'error', description: message, } : step, ) } export default function MilestoneAdministration() { const { showToast, showErrorToast } = useNotifications() const [settings, setSettings] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [isFetchingToken, setIsFetchingToken] = useState(false) const [skipTlsVerify, setSkipTlsVerify] = useState(false) const [syncModalOpen, setSyncModalOpen] = useState(false) const [syncSteps, setSyncSteps] = useState(() => createMilestoneSyncSteps()) const [syncError, setSyncError] = useState(null) const [syncCompleted, setSyncCompleted] = useState(false) const [backupNasHost, setBackupNasHost] = useState('') const [backupNasShare, setBackupNasShare] = useState('') const [backupNasUsername, setBackupNasUsername] = useState('') const [backupNasPassword, setBackupNasPassword] = useState('') const [backupLogRootPath, setBackupLogRootPath] = useState('') useEffect(() => { void loadSettings() }, []) function getMilestoneDataCountMessage(count: number) { if (count === 1) { return '1 Gerät wurde von Milestone geladen und gespeichert.' } return `${count} Geräte wurden von Milestone geladen und gespeichert.` } function handleMilestoneSyncEvent(event: MilestoneSyncEvent) { setSyncSteps((currentSteps) => updateMilestoneSyncStepsForEvent(currentSteps, event), ) if (event.type === 'error') { const message = event.message || 'Milestone-Synchronisierung fehlgeschlagen' setSyncError(message) setSyncSteps((currentSteps) => markCurrentMilestoneSyncStepAsError(currentSteps, message), ) throw new Error(message) } if (event.type === 'token') { const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt) showToast({ variant: 'success', title: 'Token abgerufen', message: tokenExpiresAt ? `Token wurde erfolgreich abgerufen. Gültig bis ${tokenExpiresAt}.` : 'Token wurde erfolgreich abgerufen.', }) return } if (event.type === 'recordingServer') { showToast({ variant: 'success', title: 'Recording-Infos abgerufen', message: [ event.recordingServerName || 'Recording-Server', event.recordingServerVersion ? `Version ${event.recordingServerVersion}` : '', event.recordingServerId ? `ID: ${event.recordingServerId}` : '', ] .filter(Boolean) .join(' · '), }) return } if (event.type === 'hardware') { showToast({ variant: 'success', title: 'Milestone-Daten synchronisiert', message: getMilestoneDataCountMessage(event.hardwareCount ?? 0), }) return } if (event.type === 'done') { setSyncCompleted(true) if (event.settings) { setSettings(event.settings) setSkipTlsVerify(Boolean(event.settings.skipTlsVerify)) setBackupNasHost(event.settings.backupNasHost ?? '') setBackupNasShare(event.settings.backupNasShare ?? '') setBackupNasUsername(event.settings.backupNasUsername ?? '') setBackupNasPassword('') setBackupLogRootPath(event.settings.backupLogRootPath ?? '') } return } } async function runMilestoneSyncStream() { setSyncModalOpen(true) setSyncCompleted(false) setSyncError(null) setSyncSteps(createMilestoneSyncSteps()) const response = await fetch(`${API_URL}/admin/milestone/token/stream`, { method: 'POST', credentials: 'include', }) if (!response.ok || !response.body) { const message = await readApiError( response, 'Milestone-Token konnte nicht abgerufen werden', ) setSyncError(message) setSyncSteps((currentSteps) => markCurrentMilestoneSyncStepAsError(currentSteps, message), ) throw new Error(message) } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { value, done } = await reader.read() if (done) { break } buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { const trimmedLine = line.trim() if (!trimmedLine) { continue } const event = JSON.parse(trimmedLine) as MilestoneSyncEvent handleMilestoneSyncEvent(event) } } const remainingLine = buffer.trim() if (remainingLine) { const event = JSON.parse(remainingLine) as MilestoneSyncEvent handleMilestoneSyncEvent(event) } } async function loadSettings() { setIsLoading(true) try { const response = await fetch(`${API_URL}/admin/milestone`, { credentials: 'include', }) if (!response.ok) { throw new Error( await readApiError( response, 'Milestone-Einstellungen konnten nicht geladen werden', ), ) } const data = await response.json() setSettings(data.settings) setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) setBackupNasHost(String(data.settings?.backupNasHost ?? '')) setBackupNasShare(String(data.settings?.backupNasShare ?? '')) setBackupNasUsername(String(data.settings?.backupNasUsername ?? '')) setBackupNasPassword('') setBackupLogRootPath(String(data.settings?.backupLogRootPath ?? '')) } catch (error) { showErrorToast({ title: 'Einstellungen konnten nicht geladen werden', error, fallback: 'Milestone-Einstellungen konnten nicht geladen werden', }) } finally { setIsLoading(false) } } async function handleSubmit(event: FormEvent) { event.preventDefault() const form = event.currentTarget const formData = new FormData(form) setIsSaving(true) try { const response = await fetch(`${API_URL}/admin/milestone?sync=false`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ host: String(formData.get('host') ?? ''), username: String(formData.get('username') ?? ''), password: String(formData.get('password') ?? ''), skipTlsVerify, serverFoldersAgentScheme: String( formData.get('serverFoldersAgentScheme') ?? 'http', ), serverFoldersAgentPort: String( formData.get('serverFoldersAgentPort') ?? '8099', ), serverFoldersStorageRootLabel: String( formData.get('serverFoldersStorageRootLabel') ?? 'Storage D', ), serverFoldersStorageRootPath: String( formData.get('serverFoldersStorageRootPath') ?? 'D:/Milestone-Speicher', ), serverFoldersArchiveRootLabel: String( formData.get('serverFoldersArchiveRootLabel') ?? 'Archive E', ), serverFoldersArchiveRootPath: String( formData.get('serverFoldersArchiveRootPath') ?? 'E:/', ), backupNasHost, backupNasShare, backupNasUsername, backupNasPassword, backupLogRootPath, }), }) if (!response.ok) { throw new Error( await readApiError( response, 'Milestone-Einstellungen konnten nicht gespeichert werden', ), ) } const data = (await response.json()) as { settings: MilestoneSettings } setSettings(data.settings) setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) setBackupNasHost(data.settings.backupNasHost ?? '') setBackupNasShare(data.settings.backupNasShare ?? '') setBackupNasUsername(data.settings.backupNasUsername ?? '') setBackupNasPassword('') setBackupLogRootPath(data.settings.backupLogRootPath ?? '') const passwordInput = form.elements.namedItem('password') if (passwordInput instanceof HTMLInputElement) { passwordInput.value = '' } showToast({ variant: 'success', title: 'Einstellungen gespeichert', message: 'Die Milestone-Einstellungen wurden gespeichert.', }) } catch (error) { showErrorToast({ title: 'Speichern fehlgeschlagen', error, fallback: 'Milestone-Einstellungen konnten nicht gespeichert werden', }) } finally { setIsSaving(false) } } async function handleFetchToken() { setIsFetchingToken(true) try { await runMilestoneSyncStream() } catch (error) { showErrorToast({ title: 'Abruf fehlgeschlagen', error, fallback: 'Milestone-Token konnte nicht abgerufen werden', }) } finally { setIsFetchingToken(false) } } if (isLoading) { return (
) } const updatedAt = formatUpdatedAt(settings?.updatedAt) return ( <> { const syncIsRunning = !syncCompleted && !syncError && (isSaving || isFetchingToken) if (!open && syncIsRunning) { return } setSyncModalOpen(open) }} zIndexClassName="z-[9999]" panelClassName="max-w-xl" >
Milestone-Synchronisierung

Token, Recording-Server und Milestone-Daten werden nacheinander abgefragt.

Milestone Videoserver

Hinterlege die Zugangsdaten für den Milestone Videoserver.

setSkipTlsVerify(event.target.checked)} label="TLS-Zertifikatsprüfung deaktivieren" description={ <> Nur für selbstsignierte Zertifikate oder interne Testsysteme verwenden. } variant="simple" />

TreeView-Agent

Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.

Der Agent-Token wird beim Speichern automatisch erzeugt, an den Agent übertragen und verschlüsselt gespeichert. Der Agent wird daher ohne Token gestartet – eine manuelle Eingabe ist nicht mehr nötig.{' '} {settings?.serverFoldersAgentTokenConfigured ? 'Aktuell ist ein Token gesetzt.' : 'Aktuell ist noch kein Token gesetzt.'}

Backup-Protokolle

NAS-Zugang und Ordner mit den Robocopy-.log-Dateien.

{ setBackupNasHost(event.target.value) setBackupNasShare('') setBackupLogRootPath('') }} placeholder="z. B. 192.168.178.20 oder NAS01" className={inputClassName} />
setBackupNasUsername(event.target.value)} placeholder="z. B. backup-user oder DOMÄNE\\backup-user" autoComplete="off" className={inputClassName} />
setBackupNasPassword(event.target.value)} autoComplete="new-password" placeholder={ settings?.backupNasPasswordConfigured ? 'Gespeichertes Kennwort verwenden' : '' } className={inputClassName} />

Speichern aktualisiert nur die Einstellungen. Der Milestone-Abruf läuft separat.

) } function normalizeBackupPath(value: string) { return value.trim().replace(/\//g, '\\').replace(/[\\]+$/, '').toLowerCase() } function normalizeBackupDisplayPath(value: string, nasHost?: string) { const cleanValue = value.trim().replace(/\//g, '\\').replace(/[\\]+$/, '') if (!cleanValue) { return '' } if (cleanValue.startsWith('\\\\')) { return `\\\\${cleanValue.replace(/^\\+/, '')}` } const cleanHost = nasHost?.trim().replace(/\//g, '\\').replace(/[\\]+$/, '') if ( cleanHost && cleanValue.toLowerCase().startsWith(`${cleanHost.toLowerCase()}\\`) ) { return `\\\\${cleanValue}` } return cleanValue } function isBackupPathInsideRoot(path: string, rootPath: string) { const normalizedPath = normalizeBackupPath(path) const normalizedRoot = normalizeBackupPath(rootPath) return ( normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}\\`) ) } function backupFolderNameFromPath(path: string) { const cleanPath = path.trim().replace(/[\\/]+$/, '') const parts = cleanPath.split(/[\\/]+/).filter(Boolean) return parts[parts.length - 1] || cleanPath || path } function backupFolderNodeFromItem(item: BackupFolderItem): TreeListNode { const name = item.displayName?.trim() || item.name || backupFolderNameFromPath(item.path) return { id: item.path, name, path: item.path, children: item.hasChildren ? [] : undefined, } } function joinBackupPath(basePath: string, childName: string) { const base = basePath.trim().replace(/[\\/]+$/, '') const child = childName.trim().replace(/^[\\/]+/, '') if (!base) { return child } if (!child) { return base } return `${base}\\${child}` } function buildBackupFolderBranch( rootPath: string, currentPath: string, folders: BackupFolderItem[], ): TreeListNode[] { const cleanRootPath = rootPath.trim().replace(/[\\/]+$/, '') const cleanCurrentPath = currentPath.trim().replace(/[\\/]+$/, '') const folderNodes = folders.map(backupFolderNodeFromItem) if ( !cleanRootPath || !cleanCurrentPath || normalizeBackupPath(cleanRootPath) === normalizeBackupPath(cleanCurrentPath) ) { return folderNodes } if (!isBackupPathInsideRoot(cleanCurrentPath, cleanRootPath)) { return [] } let relativePath = cleanCurrentPath if (normalizeBackupPath(relativePath).startsWith(normalizeBackupPath(cleanRootPath))) { relativePath = relativePath.slice(cleanRootPath.length) } const segments = relativePath.replace(/^[\\/]+/, '').split(/[\\/]+/).filter(Boolean) if (segments.length === 0) { return folderNodes } let runningPath = cleanRootPath let rootBranchNode: TreeListNode | null = null let currentNode: TreeListNode | null = null for (const segment of segments) { runningPath = joinBackupPath(runningPath, segment) const nextNode: TreeListNode = { id: runningPath, name: segment, path: runningPath, children: [], } if (!rootBranchNode) { rootBranchNode = nextNode } if (currentNode) { currentNode.children = [nextNode] } currentNode = nextNode } if (currentNode) { currentNode.children = folderNodes } return rootBranchNode ? [rootBranchNode] : folderNodes } function buildBackupFolderTree(response: BackupFoldersResponse): TreeListNode[] { const roots = Array.isArray(response.roots) ? response.roots : [] const folders = Array.isArray(response.folders) ? response.folders : [] if (roots.length === 0) { return folders.map(backupFolderNodeFromItem) } return roots.map((root) => ({ id: root.path, name: root.label || root.path, path: root.path, children: isBackupPathInsideRoot(response.path, root.path) ? buildBackupFolderBranch(root.path, response.path, folders) : [], })) } function getBackupShareFromPath(path: string) { const cleanPath = normalizeBackupDisplayPath(path) if (!cleanPath.startsWith('\\\\')) { return '' } const parts = cleanPath.slice(2).split('\\').filter(Boolean) return parts[1] ?? '' } function BackupFolderPicker({ value, nasHost, nasShare, nasUsername, nasPassword, storedPasswordAvailable, onChange, onShareChange, disabled, }: { value: string nasHost: string nasShare: string nasUsername: string nasPassword: string storedPasswordAvailable: boolean onChange: (value: string) => void onShareChange: (value: string) => void disabled?: boolean }) { const [nodes, setNodes] = useState([]) const [rootPath, setRootPath] = useState('') const [currentPath, setCurrentPath] = useState('') const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(false) const loadAbortControllerRef = useRef(null) const canLoadBackupFolders = Boolean(nasHost.trim()) && Boolean(nasUsername.trim()) && (Boolean(nasPassword) || storedPasswordAvailable) useEffect(() => { return () => { loadAbortControllerRef.current?.abort() } }, []) async function loadFolders(path?: string, shareOverride?: string) { const cleanHost = nasHost.trim() const cleanUsername = nasUsername.trim() const requestedPath = normalizeBackupDisplayPath(path ?? '', cleanHost) const pathShare = getBackupShareFromPath(requestedPath) const requestedShare = shareOverride ?? (pathShare || nasShare.trim()) if (!cleanHost || !cleanUsername || (!nasPassword && !storedPasswordAvailable)) { setNodes([]) setRootPath('') setCurrentPath('') setError(null) return } setIsLoading(true) setError(null) loadAbortControllerRef.current?.abort() const abortController = new AbortController() loadAbortControllerRef.current = abortController const timeoutId = window.setTimeout(() => { abortController.abort() }, 20000) try { const response = await fetch( `${API_URL}/admin/milestone/backup-folders/browse`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', signal: abortController.signal, body: JSON.stringify({ nasHost: cleanHost, nasShare: requestedShare, nasUsername: cleanUsername, nasPassword, path: requestedPath, }), }, ) if (!response.ok) { setError( await readApiError( response, 'Backup-Ordner konnten nicht geladen werden.', ), ) return } const data = (await response.json()) as BackupFoldersResponse const nextRootPath = data.roots?.[0]?.path ?? '' const nextPath = data.path || requestedPath || nextRootPath const nextShare = getBackupShareFromPath(nextPath || nextRootPath) setNodes(buildBackupFolderTree(data)) setRootPath(nextRootPath) setCurrentPath(nextPath) setError(data.warning ?? null) if (nextShare) { onShareChange(nextShare) } if (nextPath && value.trim() !== nextPath) { onChange(nextPath) } } catch (error) { if (abortController.signal.aborted) { if (loadAbortControllerRef.current === abortController) { setError( 'NAS hat nicht rechtzeitig geantwortet. Prüfe Hostname, Benutzername, Domäne und Netzwerkverbindung.', ) } return } setError( error instanceof Error ? error.message : 'Backup-Ordner konnten nicht geladen werden.', ) } finally { window.clearTimeout(timeoutId) if (loadAbortControllerRef.current === abortController) { loadAbortControllerRef.current = null setIsLoading(false) } } } useEffect(() => { if (!canLoadBackupFolders) { setNodes([]) setRootPath('') setCurrentPath('') setError(null) return } const timeoutId = window.setTimeout(() => { const selectedPath = normalizeBackupDisplayPath(value, nasHost) const selectedShare = selectedPath ? getBackupShareFromPath(selectedPath) : '' void loadFolders(selectedPath || undefined, selectedShare || '') }, 500) return () => { window.clearTimeout(timeoutId) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [nasHost, nasUsername, nasPassword, storedPasswordAvailable]) function handleSelectedPathChange(path: string) { const nextPath = normalizeBackupDisplayPath(path, nasHost) const nextShare = getBackupShareFromPath(nextPath) if (nextShare) { onShareChange(nextShare) } onChange(nextPath) void loadFolders(nextPath, nextShare) } function handleLoadShares() { onShareChange('') onChange('') setRootPath('') setCurrentPath('') void loadFolders('', '') } return (
{error && (
{error}
)}

Lade die Freigaben des NAS und wähle darunter den Ordner mit den Robocopy-.log-Dateien aus.

) }