teg/frontend/src/pages/administration/milestone/MilestoneAdministration.tsx
2026-07-03 16:56:20 +02:00

1592 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<MilestoneSettings | null>(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<TaskListStep[]>(() => createMilestoneSyncSteps())
const [syncError, setSyncError] = useState<string | null>(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<HTMLFormElement>) {
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 (
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner
size="md"
label="Milestone-Einstellungen werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
const updatedAt = formatUpdatedAt(settings?.updatedAt)
return (
<>
<Modal
open={syncModalOpen}
setOpen={(open) => {
const syncIsRunning =
!syncCompleted && !syncError && (isSaving || isFetchingToken)
if (!open && syncIsRunning) {
return
}
setSyncModalOpen(open)
}}
zIndexClassName="z-[9999]"
panelClassName="max-w-xl"
>
<div className="flex items-start justify-between border-b border-gray-200 px-6 py-4 dark:border-white/10">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Milestone-Synchronisierung
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Token, Recording-Server und Milestone-Daten werden nacheinander abgefragt.
</p>
</div>
<button
type="button"
disabled={!syncCompleted && !syncError && (isSaving || isFetchingToken)}
onClick={() => setSyncModalOpen(false)}
className="rounded-md text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:text-gray-300"
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
<div className="px-6 py-5">
<TaskList
steps={syncSteps}
ariaLabel="Milestone-Synchronisierung"
/>
</div>
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="gray"
disabled={!syncCompleted && !syncError && (isSaving || isFetchingToken)}
onClick={() => setSyncModalOpen(false)}
>
{syncCompleted || syncError ? 'Schließen' : 'Bitte warten...'}
</Button>
</div>
</Modal>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
<form
onSubmit={handleSubmit}
className="space-y-6"
>
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<VideoCameraIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Milestone Videoserver
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hinterlege die Zugangsdaten für den Milestone Videoserver.
</p>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-6">
<div className="lg:col-span-2">
<label
htmlFor="milestone-host"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse / Hostname *
</label>
<div className="mt-2">
<input
id="milestone-host"
name="host"
type="text"
required
defaultValue={settings?.host ?? ''}
placeholder="z. B. 192.168.178.50"
className={inputClassName}
/>
</div>
</div>
<div className="lg:col-span-2">
<label
htmlFor="milestone-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername *
</label>
<div className="mt-2">
<input
id="milestone-username"
name="username"
type="text"
required
defaultValue={settings?.username ?? ''}
autoComplete="off"
className={inputClassName}
/>
</div>
</div>
<div className="lg:col-span-2">
<label
htmlFor="milestone-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Kennwort {settings?.passwordConfigured ? '(leer lassen = unverändert)' : '*'}
</label>
<div className="mt-2">
<input
id="milestone-password"
name="password"
type="password"
required={!settings?.passwordConfigured}
autoComplete="new-password"
placeholder={
settings?.passwordConfigured
? 'Kennwort ist bereits gespeichert'
: ''
}
className={inputClassName}
/>
</div>
</div>
<div className="lg:col-span-6">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<Switch
checked={skipTlsVerify}
onChange={(event) => setSkipTlsVerify(event.target.checked)}
label="TLS-Zertifikatsprüfung deaktivieren"
description={
<>
Nur für selbstsignierte Zertifikate oder interne Testsysteme verwenden.
</>
}
variant="simple"
/>
</div>
</div>
</div>
</section>
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div>
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<ServerStackIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
TreeView-Agent
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.
</p>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label
htmlFor="server-folders-agent-scheme"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Schema
</label>
<div className="mt-2">
<select
id="server-folders-agent-scheme"
name="serverFoldersAgentScheme"
defaultValue={settings?.serverFoldersAgentScheme || 'http'}
className={inputClassName}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div>
<label
htmlFor="server-folders-agent-port"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Port
</label>
<div className="mt-2">
<input
id="server-folders-agent-port"
name="serverFoldersAgentPort"
type="text"
inputMode="numeric"
defaultValue={settings?.serverFoldersAgentPort || '8099'}
placeholder="8099"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="server-folders-storage-root-label"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Storage-Label
</label>
<div className="mt-2">
<input
id="server-folders-storage-root-label"
name="serverFoldersStorageRootLabel"
type="text"
defaultValue={settings?.serverFoldersStorageRootLabel || 'Storage D'}
placeholder="Storage D"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="server-folders-storage-root-path"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Storage-Pfad
</label>
<div className="mt-2">
<input
id="server-folders-storage-root-path"
name="serverFoldersStorageRootPath"
type="text"
defaultValue={settings?.serverFoldersStorageRootPath || 'D:/Milestone-Speicher'}
placeholder="D:/Milestone-Speicher"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="server-folders-archive-root-label"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Archiv-Label
</label>
<div className="mt-2">
<input
id="server-folders-archive-root-label"
name="serverFoldersArchiveRootLabel"
type="text"
defaultValue={settings?.serverFoldersArchiveRootLabel || 'Archive E'}
placeholder="Archive E"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="server-folders-archive-root-path"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Archiv-Pfad
</label>
<div className="mt-2">
<input
id="server-folders-archive-root-path"
name="serverFoldersArchiveRootPath"
type="text"
defaultValue={settings?.serverFoldersArchiveRootPath || 'E:/'}
placeholder="E:/"
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
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.'}
</p>
</div>
</div>
</div>
</section>
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div>
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<ServerStackIcon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Backup-Protokolle
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
NAS-Zugang und Ordner mit den Robocopy-.log-Dateien.
</p>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label
htmlFor="backup-nas-host"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
NAS-IP / Hostname
</label>
<div className="mt-2">
<input
id="backup-nas-host"
type="text"
value={backupNasHost}
onChange={(event) => {
setBackupNasHost(event.target.value)
setBackupNasShare('')
setBackupLogRootPath('')
}}
placeholder="z. B. 192.168.178.20 oder NAS01"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="backup-nas-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername
</label>
<div className="mt-2">
<input
id="backup-nas-username"
type="text"
value={backupNasUsername}
onChange={(event) => setBackupNasUsername(event.target.value)}
placeholder="z. B. backup-user oder DOMÄNE\\backup-user"
autoComplete="off"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="backup-nas-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Kennwort {settings?.backupNasPasswordConfigured ? '(leer lassen = unverändert)' : ''}
</label>
<div className="mt-2">
<input
id="backup-nas-password"
type="password"
value={backupNasPassword}
onChange={(event) => setBackupNasPassword(event.target.value)}
autoComplete="new-password"
placeholder={
settings?.backupNasPasswordConfigured
? 'Gespeichertes Kennwort verwenden'
: ''
}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-white/10">
<div>
<BackupFolderPicker
value={backupLogRootPath}
nasHost={backupNasHost}
nasShare={backupNasShare}
nasUsername={backupNasUsername}
nasPassword={backupNasPassword}
storedPasswordAvailable={Boolean(
settings?.backupNasPasswordConfigured &&
backupNasHost.trim() === (settings.backupNasHost ?? '').trim() &&
backupNasUsername.trim() === (settings.backupNasUsername ?? '').trim(),
)}
onChange={setBackupLogRootPath}
onShareChange={setBackupNasShare}
disabled={isSaving}
/>
</div>
</div>
</div>
</section>
<div className="flex flex-col gap-3 rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 sm:flex-row sm:items-center sm:justify-between dark:bg-gray-900 dark:ring-white/10">
<p className="text-sm text-gray-500 dark:text-gray-400">
Speichern aktualisiert nur die Einstellungen. Der Milestone-Abruf
läuft separat.
</p>
<div className="flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isFetchingToken}
disabled={!settings?.passwordConfigured || isSaving}
onClick={() => void handleFetchToken()}
>
Milestone synchronisieren
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Speichern
</Button>
</div>
</div>
</form>
<aside className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 xl:sticky xl:top-6 xl:self-start dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
<ServerStackIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Status
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Aktuell gespeicherte Milestone-Konfiguration.
</p>
</div>
</div>
<dl className="mt-5 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Server
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.host || 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Recording-Server-ID
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.serverId || 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Benutzername
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.username || 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Kennwort
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.passwordConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TLS-Zertifikatsprüfung
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings?.skipTlsVerify ? 'Deaktiviert' : 'Aktiv'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Auth-Token
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings?.tokenConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TreeView-Agent
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{(settings?.serverFoldersAgentScheme || 'http') +
'://' +
(settings?.host || 'Milestone-Server') +
':' +
(settings?.serverFoldersAgentPort || '8099')}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TreeView-Agent Token
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.serverFoldersAgentTokenConfigured
? 'Gespeichert'
: 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Storage-Root
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{(settings?.serverFoldersStorageRootLabel || 'Storage D') +
': ' +
(settings?.serverFoldersStorageRootPath || 'D:/Milestone-Speicher')}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Archiv-Root
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{(settings?.serverFoldersArchiveRootLabel || 'Archive E') +
': ' +
(settings?.serverFoldersArchiveRootPath || 'E:/')}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Backup-NAS
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.backupNasHost && settings?.backupNasShare
? `\\\\${settings.backupNasHost}\\${settings.backupNasShare}`
: 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Backup-Kennwort
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.backupNasPasswordConfigured
? 'Gespeichert'
: 'Nicht gespeichert'}
</dd>
</div>
{settings?.tokenExpiresAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Token gültig bis
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{formatUpdatedAt(settings.tokenExpiresAt)}
</dd>
</div>
)}
{settings?.serverName && (
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Recording-Server
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings.serverName}
</dd>
</div>
)}
{settings?.serverVersion && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Version
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings.serverVersion}
</dd>
</div>
)}
{updatedAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Zuletzt geändert
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{updatedAt}
</dd>
</div>
)}
</dl>
</aside>
</div>
</>
)
}
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<TreeListNode[]>([])
const [rootPath, setRootPath] = useState('')
const [currentPath, setCurrentPath] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const loadAbortControllerRef = useRef<AbortController | null>(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 (
<div>
{error && (
<div className="mb-3 rounded-md bg-amber-50 p-3 text-sm text-amber-800 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20">
{error}
</div>
)}
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Lade die Freigaben des NAS und wähle darunter den Ordner mit den
Robocopy-.log-Dateien aus.
</p>
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
isLoading={isLoading}
disabled={disabled || isLoading || !canLoadBackupFolders}
onClick={handleLoadShares}
>
Neu laden
</Button>
</div>
<TreeList
label="Freigabe und Ordner auswählen"
description={
canLoadBackupFolders
? 'Freigabe auswählen, danach den passenden Ordner darunter anklicken.'
: 'Trage NAS-IP, Benutzername und Kennwort ein.'
}
selectedPath={value || currentPath}
nodes={nodes}
rootPath={rootPath || currentPath || value}
onSelectedPathChange={handleSelectedPathChange}
disabled={disabled || isLoading || !canLoadBackupFolders}
showFallbackNodes
emptyMessage="Noch keine Freigaben geladen."
showNewFolderInput={false}
pathPlaceholder="\\\\NAS\\Freigabe\\Ordner"
/>
</div>
)
}