1592 lines
51 KiB
TypeScript
1592 lines
51 KiB
TypeScript
// 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>
|
||
)
|
||
}
|