From 1abfc72e3ab8baa8389891fd78d1533e5d101395 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:02:19 +0200 Subject: [PATCH] updated --- backend/external/agent_main.go | 321 +- backend/milestone_api.go | 9 - backend/milestone_cameras.go | 206 ++ backend/milestone_storage.go | 1963 ++++++++++++- backend/operations.go | 53 +- backend/routes.go | 46 +- backend/server_folders_proxy.go | 180 +- backend/setup/main.go | 3 + frontend/src/App.tsx | 12 + frontend/src/components/Sidebar.tsx | 478 +-- frontend/src/components/TreeList.tsx | 184 +- frontend/src/components/types.ts | 1 + .../src/pages/operations/OperationModal.tsx | 619 +++- .../MilestoneCamerasPage.tsx | 310 ++ .../MilestoneDevicesPage.tsx | 310 ++ .../MilestoneDevicesPage.tsx | 310 ++ .../MilestoneStoragePage.tsx | 2615 +++++++++++------ 17 files changed, 6313 insertions(+), 1307 deletions(-) create mode 100644 frontend/src/pages/operations/milestone-cameras/MilestoneCamerasPage.tsx create mode 100644 frontend/src/pages/operations/milestone-cameras/MilestoneDevicesPage.tsx create mode 100644 frontend/src/pages/operations/milestone-devices/MilestoneDevicesPage.tsx diff --git a/backend/external/agent_main.go b/backend/external/agent_main.go index eab7261..d81b6e8 100644 --- a/backend/external/agent_main.go +++ b/backend/external/agent_main.go @@ -4,14 +4,14 @@ // // Endpunkte: // GET /health -// GET /server-folders?rootType=storage&path=D:\Milestone -// POST /server-folders +// GET /server-folders?rootType=storage&path=D:\Milestone +// POST /server-folders +// PATCH /server-folders +// DELETE /server-folders // // Start per Parameter: // server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME" // -// Pflichtwerte: -storage-root, -archive-root und -token. Alternativ können die passenden ENV-Variablen gesetzt werden. -// // Alternativ per ENV: // LISTEN_ADDR=:8099 // SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage @@ -38,9 +38,7 @@ import ( "path/filepath" "sort" "strings" - "syscall" "time" - "unsafe" ) const ( @@ -82,7 +80,6 @@ type serverFolderRoot struct { type serverFolderNode struct { Name string `json:"name"` - DisplayName string `json:"displayName,omitempty"` Path string `json:"path"` HasChildren bool `json:"hasChildren"` } @@ -101,6 +98,11 @@ type createServerFolderRequest struct { Name string `json:"name"` } +type renameServerFolderRequest struct { + Path string `json:"path"` + Name string `json:"name"` +} + func main() { config := readConfigFromFlagsAndEnv() @@ -113,6 +115,12 @@ func main() { mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { handleCreateServerFolder(w, r, config) })) + mux.HandleFunc("PATCH /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { + handleRenameServerFolder(w, r, config) + })) + mux.HandleFunc("DELETE /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { + handleDeleteServerFolder(w, r, config) + })) server := &http.Server{ Addr: config.ListenAddr, @@ -136,7 +144,7 @@ func readConfigFromFlagsAndEnv() appConfig { legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone") storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage") archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive") - tokenFlag := flag.String("token", "", "Required access token for X-Server-Folders-Token") + tokenFlag := flag.String("token", "", "Optional access token for X-Server-Folders-Token") flag.Parse() listenAddr := strings.TrimSpace(*addrFlag) @@ -163,56 +171,29 @@ func readConfigFromFlagsAndEnv() appConfig { if storageRoots == "" { storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv)) } + if storageRoots == "" { + storageRoots = legacyRoots + } archiveRoots := strings.TrimSpace(*archiveRootsFlag) if archiveRoots == "" { archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv)) } + if archiveRoots == "" { + archiveRoots = legacyRoots + } token := strings.TrimSpace(*tokenFlag) if token == "" { token = strings.TrimSpace(os.Getenv(agentTokenEnv)) } - config := appConfig{ + return appConfig{ ListenAddr: listenAddr, StorageRoots: storageRoots, ArchiveRoots: archiveRoots, Token: token, } - - validateRequiredConfig(config, legacyRoots) - - return config -} - -func validateRequiredConfig(config appConfig, legacyRoots string) { - missing := make([]string, 0, 3) - - if strings.TrimSpace(config.StorageRoots) == "" { - missing = append(missing, "-storage-root oder "+serverStorageRootsEnv) - } - - if strings.TrimSpace(config.ArchiveRoots) == "" { - missing = append(missing, "-archive-root oder "+serverArchiveRootsEnv) - } - - if strings.TrimSpace(config.Token) == "" { - missing = append(missing, "-token oder "+agentTokenEnv) - } - - if len(missing) == 0 { - return - } - - if strings.TrimSpace(legacyRoots) != "" { - log.Printf("Hinweis: -roots/%s wird nicht mehr als Ersatz fuer -storage-root und -archive-root akzeptiert.", legacyFolderRootsEnv) - } - - log.Fatalf( - `Pflichtparameter fehlen: %s. Beispiel: server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"`, - strings.Join(missing, ", "), - ) } func handleHealth(w http.ResponseWriter, r *http.Request) { @@ -301,11 +282,8 @@ func handleListServerFolders(w http.ResponseWriter, r *http.Request, config appC continue } - fileSystemName := entry.Name() - folders = append(folders, serverFolderNode{ - Name: fileSystemName, - DisplayName: windowsDisplayNameForPath(childPath, fileSystemName), + Name: entry.Name(), Path: childPath, HasChildren: folderHasVisibleSubdirectories(childPath, roots), }) @@ -392,13 +370,211 @@ func handleCreateServerFolder(w http.ResponseWriter, r *http.Request, config app "ok": true, "folder": serverFolderNode{ Name: filepath.Base(targetPath), - DisplayName: windowsDisplayNameForPath(targetPath, filepath.Base(targetPath)), Path: targetPath, HasChildren: false, }, }) } +func handleRenameServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) { + rootType := serverFolderRootTypeFromRequest(r) + + roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + var input renameServerFolderRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + sourcePath, err := resolveExistingPath(input.Path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden") + return + } + + writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden") + return + } + + if !isPathAllowed(sourcePath, roots) { + writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben") + return + } + + if isConfiguredRootPath(sourcePath, roots) { + writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht umbenannt werden") + return + } + + info, err := os.Stat(sourcePath) + if err != nil { + writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden") + return + } + + if !info.IsDir() { + writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner") + return + } + + nextName := strings.TrimSpace(input.Name) + if !isSafeSingleFolderName(nextName) { + writeError(w, http.StatusBadRequest, "Neuer Ordnername ist ungültig") + return + } + + parentPath := filepath.Dir(sourcePath) + targetPath, err := cleanAbsolutePath(filepath.Join(parentPath, nextName)) + if err != nil { + writeError(w, http.StatusBadRequest, "Neuer Zielpfad ist ungültig") + return + } + + if !isPathAllowed(targetPath, roots) { + writeError(w, http.StatusForbidden, "Neuer Zielpfad ist nicht freigegeben") + return + } + + if strings.EqualFold(sourcePath, targetPath) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "folder": serverFolderNode{ + Name: filepath.Base(sourcePath), + Path: sourcePath, + HasChildren: folderHasVisibleSubdirectories(sourcePath, roots), + }, + }) + return + } + + if _, err := os.Stat(targetPath); err == nil { + writeError(w, http.StatusConflict, "Zielordner existiert bereits") + return + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusBadRequest, "Zielordner konnte nicht geprüft werden") + return + } + + if err := os.Rename(sourcePath, targetPath); err != nil { + writeError(w, http.StatusForbidden, "Ordner konnte nicht umbenannt werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "folder": serverFolderNode{ + Name: filepath.Base(targetPath), + Path: targetPath, + HasChildren: folderHasVisibleSubdirectories(targetPath, roots), + }, + }) +} + +func handleDeleteServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) { + rootType := serverFolderRootTypeFromRequest(r) + + roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + requestedPath := strings.TrimSpace(r.URL.Query().Get("path")) + + if requestedPath == "" && r.Body != nil { + var input struct { + Path string `json:"path"` + } + + if err := readJSON(r, &input); err == nil { + requestedPath = strings.TrimSpace(input.Path) + } + } + + if requestedPath == "" { + writeError(w, http.StatusBadRequest, "Pfad fehlt") + return + } + + targetPath, err := resolveExistingPath(requestedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden") + return + } + + writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden") + return + } + + if !isPathAllowed(targetPath, roots) { + writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben") + return + } + + if isConfiguredRootPath(targetPath, roots) { + writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht gelöscht werden") + return + } + + info, err := os.Stat(targetPath) + if err != nil { + writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden") + return + } + + if !info.IsDir() { + writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner") + return + } + + if err := os.Remove(targetPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": false, + "path": targetPath, + }) + return + } + + writeError(w, http.StatusConflict, "Ordner konnte nicht gelöscht werden. Er ist vermutlich nicht leer.") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": true, + "path": targetPath, + }) +} + +func isConfiguredRootPath(pathValue string, roots []serverFolderRoot) bool { + pathValue, err := cleanAbsolutePath(pathValue) + if err != nil { + return false + } + + for _, root := range roots { + rootPath, err := cleanAbsolutePath(root.Path) + if err != nil { + continue + } + + if strings.EqualFold(pathValue, rootPath) { + return true + } + } + + return false +} + func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType { value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType"))) if value == "" { @@ -595,57 +771,6 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot) return false } -const ( - shgfiDisplayName = 0x000000200 -) - -type shFileInfo struct { - HIcon uintptr - IIcon int32 - DwAttributes uint32 - SzDisplayName [260]uint16 - SzTypeName [80]uint16 -} - -var ( - shell32DLL = syscall.NewLazyDLL("shell32.dll") - procSHGetFileInfo = shell32DLL.NewProc("SHGetFileInfoW") -) - -func windowsDisplayNameForPath(pathValue string, fallback string) string { - pathValue = strings.TrimSpace(pathValue) - fallback = strings.TrimSpace(fallback) - - if pathValue == "" { - return fallback - } - - pathPointer, err := syscall.UTF16PtrFromString(pathValue) - if err != nil { - return fallback - } - - var info shFileInfo - result, _, _ := procSHGetFileInfo.Call( - uintptr(unsafe.Pointer(pathPointer)), - 0, - uintptr(unsafe.Pointer(&info)), - unsafe.Sizeof(info), - shgfiDisplayName, - ) - - if result == 0 { - return fallback - } - - displayName := strings.TrimSpace(syscall.UTF16ToString(info.SzDisplayName[:])) - if displayName == "" { - return fallback - } - - return displayName -} - func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc { requiredToken = strings.TrimSpace(requiredToken) diff --git a/backend/milestone_api.go b/backend/milestone_api.go index 33a9b2e..b270ac9 100644 --- a/backend/milestone_api.go +++ b/backend/milestone_api.go @@ -1017,15 +1017,6 @@ func getMilestoneNamedDevice( return envelope.Data, nil } -func milestoneStringValue(value any) string { - text, ok := value.(string) - if !ok { - return "" - } - - return strings.TrimSpace(text) -} - func milestoneBoolValue(value any) bool { switch typedValue := value.(type) { case bool: diff --git a/backend/milestone_cameras.go b/backend/milestone_cameras.go index e6f5786..f32dbae 100644 --- a/backend/milestone_cameras.go +++ b/backend/milestone_cameras.go @@ -4,7 +4,11 @@ package main import ( "context" + "encoding/json" "errors" + "fmt" + "io" + "log" "net/http" "strings" @@ -398,6 +402,208 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS return streams } +type milestoneCameraListItem struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + RecordingEnabled bool `json:"recordingEnabled"` + RecordingStorageID string `json:"recordingStorageId"` + HardwareID string `json:"hardwareId"` +} + +func getMilestoneDeviceList( + ctx context.Context, + config milestoneRuntimeConfig, + collection string, +) ([]milestoneCameraListItem, error) { + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/" + collection + "?disabled" + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("milestone %s list failed: status=%d", collection, response.StatusCode) + } + + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, err + } + + var rawItems []any + if array, ok := decoded["array"].([]any); ok { + rawItems = array + } else if data, ok := decoded["data"].(map[string]any); ok { + if array, ok := data["array"].([]any); ok { + rawItems = array + } + } + + deviceType := "camera" + if collection == "microphones" { + deviceType = "microphone" + } + + items := make([]milestoneCameraListItem, 0, len(rawItems)) + + for _, raw := range rawItems { + item, ok := raw.(map[string]any) + if !ok { + continue + } + + id := milestoneNamedDeviceID(item) + if id == "" { + continue + } + + name := milestoneStringValue(item["name"]) + displayName := milestoneStringValue(item["displayName"]) + if name == "" { + name = displayName + } + if displayName == "" { + displayName = name + } + if name == "" { + continue + } + + enabled := milestoneItemEnabledValue(item) + + recordingEnabled := false + if v, ok := item["recordingEnabled"].(bool); ok { + recordingEnabled = v + } + + recordingStorageID := "" + if rs, ok := item["recordingStorage"].(map[string]any); ok { + recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"])) + } + if recordingStorageID == "" { + if relations, ok := item["relations"].(map[string]any); ok { + if rs, ok := relations["recordingStorage"].(map[string]any); ok { + recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"])) + } + } + } + + hardwareID := milestoneNamedDeviceParentID(item) + + items = append(items, milestoneCameraListItem{ + ID: id, + Name: name, + DisplayName: displayName, + Type: deviceType, + Enabled: enabled, + RecordingEnabled: recordingEnabled, + RecordingStorageID: recordingStorageID, + HardwareID: hardwareID, + }) + } + + return items, nil +} + +func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Request) { + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras") + if err != nil { + log.Printf("Milestone cameras list failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden") + return + } + + microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones") + if err != nil { + log.Printf("Milestone microphones list failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden") + return + } + + all := make([]milestoneCameraListItem, 0, len(cameras)+len(microphones)) + all = append(all, cameras...) + all = append(all, microphones...) + + writeJSON(w, http.StatusOK, map[string]any{"devices": all}) +} + +type patchMilestoneDeviceRequest struct { + Enabled *bool `json:"enabled"` + RecordingEnabled *bool `json:"recordingEnabled"` +} + +func (s *Server) handlePatchMilestoneDevice(w http.ResponseWriter, r *http.Request) { + collection := strings.TrimSpace(r.PathValue("collection")) + deviceID := strings.TrimSpace(r.PathValue("id")) + + if collection != "cameras" && collection != "microphones" { + writeError(w, http.StatusBadRequest, "Ungültige Gerätekategorie") + return + } + + if deviceID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") + return + } + + var input patchMilestoneDeviceRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + payload := map[string]any{} + if input.Enabled != nil { + payload["enabled"] = *input.Enabled + } + if input.RecordingEnabled != nil { + payload["recordingEnabled"] = *input.RecordingEnabled + } + + if len(payload) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + if err := patchMilestoneNamedDevice(r.Context(), config, collection, deviceID, payload); err != nil { + log.Printf("Milestone device patch failed: collection=%s id=%s error=%v", collection, deviceID, err) + writeError(w, http.StatusBadGateway, "Gerät konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func applyMilestoneCameraStreamUpdates( cameraData map[string]any, streamUpdates []updateMilestoneCameraStreamRequest, diff --git a/backend/milestone_storage.go b/backend/milestone_storage.go index ac1c8c2..4bec9ff 100644 --- a/backend/milestone_storage.go +++ b/backend/milestone_storage.go @@ -10,7 +10,16 @@ import ( "log" "net/http" "net/url" + "strconv" "strings" + "time" +) + +const ( + defaultCreateStorageMaxSizeMB = 30 * 1024 + defaultCreateStorageRetainMinutes = 60 + defaultCreateArchiveMaxSizeMB = 3.6 * 1024 * 1024 + defaultCreateArchiveRetainMinutes = 365 * 24 * 60 ) type milestoneArchiveStorage struct { @@ -37,6 +46,7 @@ type milestoneStorage struct { MaxSize int `json:"maxSize,omitempty"` RetainMinutes int `json:"retainMinutes,omitempty"` Mounted bool `json:"mounted"` + IsDefault bool `json:"isDefault"` } type milestoneStorageResponse struct { @@ -72,6 +82,7 @@ type milestoneStorageWithArchives struct { MaxSize int `json:"maxSize,omitempty"` RetainMinutes int `json:"retainMinutes,omitempty"` Mounted bool `json:"mounted"` + IsDefault bool `json:"isDefault"` StorageInformation *milestoneStorageInformation `json:"storageInformation,omitempty"` ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"` } @@ -85,6 +96,13 @@ type createMilestoneArchiveStorageRequest struct { RetainMinutes int `json:"retainMinutes"` } +type updateMilestoneStorageRequest struct { + DisplayName *string `json:"displayName"` + Name *string `json:"name"` + MaxSize *int `json:"maxSize"` + RetainMinutes *int `json:"retainMinutes"` +} + type updateMilestoneArchiveStorageRequest struct { DisplayName *string `json:"displayName"` Name *string `json:"name"` @@ -112,13 +130,13 @@ type createMilestoneStorageWithArchiveRequest struct { RetainMinutes int `json:"retainMinutes"` Signing bool `json:"signing"` - ArchiveDisplayName string `json:"archiveDisplayName"` - ArchiveName string `json:"archiveName"` - ArchiveDescription string `json:"archiveDescription"` - ArchiveDiskPath string `json:"archiveDiskPath"` - ArchiveFolderName string `json:"archiveFolderName"` - ArchiveMaxSize int `json:"archiveMaxSize"` - ArchiveRetainMinutes int `json:"archiveRetainMinutes"` + ArchiveDisplayName string `json:"archiveDisplayName"` + ArchiveName string `json:"archiveName"` + ArchiveDescription string `json:"archiveDescription"` + ArchiveDiskPath string `json:"archiveDiskPath"` + ArchiveFolderName string `json:"archiveFolderName"` + ArchiveMaxSize float64 `json:"archiveMaxSize"` + ArchiveRetainMinutes int `json:"archiveRetainMinutes"` } type milestoneStorageInformationRelationRef struct { @@ -191,9 +209,8 @@ func getMilestoneStorageInformation( if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf( - "milestone storage information get failed: status=%d body=%s", + "milestone storage information get failed: status=%d", response.StatusCode, - string(body), ) } @@ -270,9 +287,8 @@ func getMilestoneArchiveStorages( if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf( - "milestone archive storages get failed: status=%d body=%s", + "milestone archive storages get failed: status=%d", response.StatusCode, - string(body), ) } @@ -288,6 +304,48 @@ func getMilestoneArchiveStorages( return archives, nil } +func getMilestoneArchiveStorage( + ctx context.Context, + config milestoneRuntimeConfig, + archiveStorageID string, +) (*milestoneArchiveStorage, error) { + archiveStorageID = strings.TrimSpace(archiveStorageID) + if archiveStorageID == "" { + return nil, nil + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/archiveStorages/" + url.PathEscape(archiveStorageID) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf( + "milestone archive storage get failed: status=%d body=%s", + response.StatusCode, + string(body), + ) + } + + return decodeMilestoneArchiveStorage(body) +} + func decodeMilestoneArchiveStorages(body []byte) ([]milestoneArchiveStorage, error) { var arrayResponse milestoneArchiveStoragesResponse if err := json.Unmarshal(body, &arrayResponse); err == nil && arrayResponse.Array != nil { @@ -368,9 +426,8 @@ func getMilestoneStorages( if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf( - "milestone storages get failed: status=%d body=%s", + "milestone storages get failed: status=%d", response.StatusCode, - string(body), ) } @@ -387,6 +444,78 @@ func getMilestoneStorages( return []milestoneStorage{}, nil } +func getMilestoneRecordingServerDefaultStorageID( + ctx context.Context, + config milestoneRuntimeConfig, +) string { + recordingServerID := strings.TrimSpace(config.ServerID) + if recordingServerID == "" { + return "" + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/recordingServers/" + url.PathEscape(recordingServerID) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return "" + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return "" + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 { + return "" + } + + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil || envelope.Data == nil { + return "" + } + + // Check direct fields (object reference or plain string ID) + for _, key := range []string{"storage", "defaultStorage", "defaultRecordingStorage"} { + switch v := envelope.Data[key].(type) { + case map[string]any: + if id := strings.TrimSpace(milestoneStringValue(v["id"])); id != "" { + return id + } + case string: + if id := strings.TrimSpace(v); id != "" { + return id + } + } + } + + // Check inside relations + if relations, ok := envelope.Data["relations"].(map[string]any); ok { + for _, key := range []string{"storage", "defaultStorage", "defaultRecordingStorage"} { + if ref, ok := relations[key].(map[string]any); ok { + if id := strings.TrimSpace(milestoneStringValue(ref["id"])); id != "" { + return id + } + } + } + } + + // Log the recording server response keys to help identify the correct field + keys := make([]string, 0, len(envelope.Data)) + for k := range envelope.Data { + keys = append(keys, k) + } + log.Printf("Milestone recording server response keys (default storage not found): %v", keys) + + return "" +} + func getMilestoneStoragesWithArchives( ctx context.Context, config milestoneRuntimeConfig, @@ -396,6 +525,24 @@ func getMilestoneStoragesWithArchives( return nil, err } + // Prefer isDefault flag returned directly in the storage list + defaultStorageID := "" + for _, s := range storages { + if s.IsDefault { + defaultStorageID = s.ID + break + } + } + // Fall back to recording server endpoint + if defaultStorageID == "" { + defaultStorageID = getMilestoneRecordingServerDefaultStorageID(ctx, config) + if defaultStorageID != "" { + log.Printf("Milestone default storage from recording server: id=%s", defaultStorageID) + } else { + log.Printf("Milestone default storage could not be determined (storages=%d)", len(storages)) + } + } + result := make([]milestoneStorageWithArchives, 0, len(storages)) for _, storage := range storages { @@ -434,6 +581,7 @@ func getMilestoneStoragesWithArchives( MaxSize: storage.MaxSize, RetainMinutes: storage.RetainMinutes, Mounted: storage.Mounted, + IsDefault: defaultStorageID != "" && storage.ID == defaultStorageID, StorageInformation: storageInformation, ArchiveStorages: archives, }) @@ -473,10 +621,15 @@ func (s *Server) handleCreateMilestoneStorageWithArchive(w http.ResponseWriter, name := strings.TrimSpace(input.Name) diskPath := strings.TrimSpace(input.DiskPath) - if displayName == "" { - writeError(w, http.StatusBadRequest, "Anzeigename fehlt") - return + if name == "" { + name = displayName } + if displayName == "" { + displayName = name + } + + diskPath = normalizeMilestoneCreateDiskPath(diskPath, name, displayName) + if name == "" { writeError(w, http.StatusBadRequest, "Name fehlt") return @@ -486,6 +639,16 @@ func (s *Server) handleCreateMilestoneStorageWithArchive(w http.ResponseWriter, return } + maxSize := input.MaxSize + if maxSize < 0 { + maxSize = defaultCreateStorageMaxSizeMB + } + + retainMinutes := input.RetainMinutes + if retainMinutes <= 0 { + retainMinutes = defaultCreateStorageRetainMinutes + } + config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") @@ -493,16 +656,13 @@ func (s *Server) handleCreateMilestoneStorageWithArchive(w http.ResponseWriter, } storagePayload := map[string]any{ - "displayName": displayName, - "name": name, - "description": strings.TrimSpace(input.Description), - "diskPath": diskPath, - "maxSize": input.MaxSize, - "signing": input.Signing, - } - - if input.RetainMinutes > 0 { - storagePayload["retainMinutes"] = input.RetainMinutes + "displayName": displayName, + "name": name, + "description": strings.TrimSpace(input.Description), + "diskPath": diskPath, + "maxSize": maxSize, + "retainMinutes": retainMinutes, + "signing": input.Signing, } storage, err := postMilestoneStorage(r.Context(), config, storagePayload) @@ -535,31 +695,50 @@ func (s *Server) handleCreateMilestoneStorageWithArchive(w http.ResponseWriter, if archiveDiskPath == "" { archiveDiskPath = joinMilestoneDiskPath(storage.DiskPath, archiveFolderName) } + archiveDiskPath = normalizeMilestoneCreateDiskPath( + archiveDiskPath, + archiveFolderName, + name, + displayName, + ) if archiveDiskPath == "" { writeError(w, http.StatusBadRequest, "Archivpfad fehlt") return } archiveDisplayName := strings.TrimSpace(input.ArchiveDisplayName) + archiveName := strings.TrimSpace(input.ArchiveName) + + if archiveName == "" { + archiveName = archiveDisplayName + } + if archiveDisplayName == "" { + archiveDisplayName = archiveName + } + if archiveName == "" { + archiveName = formatArchiveName(storage.Name, storage.ID) + } if archiveDisplayName == "" { archiveDisplayName = formatArchiveDisplayName(storage.DisplayName, storage.Name, storage.ID) } - archiveName := strings.TrimSpace(input.ArchiveName) - if archiveName == "" { - archiveName = formatArchiveName(storage.Name, storage.ID) + archiveMaxSize := input.ArchiveMaxSize + if archiveMaxSize < 0 { + archiveMaxSize = defaultCreateArchiveMaxSizeMB + } + + archiveRetainMinutes := input.ArchiveRetainMinutes + if archiveRetainMinutes <= 0 { + archiveRetainMinutes = defaultCreateArchiveRetainMinutes } archivePayload := map[string]any{ - "displayName": archiveDisplayName, - "name": archiveName, - "description": strings.TrimSpace(input.ArchiveDescription), - "diskPath": archiveDiskPath, - "maxSize": input.ArchiveMaxSize, - } - - if input.ArchiveRetainMinutes > 0 { - archivePayload["retainMinutes"] = input.ArchiveRetainMinutes + "displayName": archiveDisplayName, + "name": archiveName, + "description": strings.TrimSpace(input.ArchiveDescription), + "diskPath": archiveDiskPath, + "maxSize": archiveMaxSize, + "retainMinutes": archiveRetainMinutes, } archiveStorage, err := postMilestoneArchiveStorage(r.Context(), config, storage.ID, archivePayload) @@ -961,8 +1140,40 @@ func (s *Server) handleUpdateMilestoneArchiveStorage(w http.ResponseWriter, r *h return } + currentArchiveStorage, err := getMilestoneArchiveStorage(r.Context(), config, archiveStorageID) + if err != nil { + log.Printf("Milestone archive storage load before update failed: archiveStorageId=%s error=%v", archiveStorageID, err) + writeError(w, http.StatusBadGateway, "Archive-Storage konnte nicht geladen werden") + return + } + payload := map[string]any{} + var folderRename milestoneFolderRenameResult + if input.Name != nil && input.DiskPath == nil && currentArchiveStorage != nil { + nextName := strings.TrimSpace(*input.Name) + + if nextName != "" && !strings.EqualFold(strings.TrimSpace(currentArchiveStorage.Name), nextName) { + folderRename, err = s.renameMilestoneStorageFolderIfNeeded( + r.Context(), + "archive", + currentArchiveStorage.DiskPath, + currentArchiveStorage.Name, + currentArchiveStorage.DisplayName, + nextName, + ) + if err != nil { + log.Printf("Milestone archive folder rename failed: archiveStorageId=%s error=%v", archiveStorageID, err) + writeError(w, http.StatusBadGateway, "Archiv-Ordner konnte nicht umbenannt werden") + return + } + + if folderRename.Renamed { + payload["diskPath"] = folderRename.NewPath + } + } + } + if input.DisplayName != nil { payload["displayName"] = strings.TrimSpace(*input.DisplayName) } @@ -988,13 +1199,149 @@ func (s *Server) handleUpdateMilestoneArchiveStorage(w http.ResponseWriter, r *h } if err := patchMilestoneArchiveStorage(r.Context(), config, archiveStorageID, payload); err != nil { + s.rollbackMilestoneFolderRename(r.Context(), "archive", folderRename) + log.Printf("Milestone archive storage update failed: archiveStorageId=%s error=%v", archiveStorageID, err) writeError(w, http.StatusBadGateway, "Archive-Storage konnte nicht aktualisiert werden") return } writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, + "ok": true, + "renamedPath": folderRename.NewPath, + }) +} + +func patchMilestoneStorage( + ctx context.Context, + config milestoneRuntimeConfig, + storageID string, + payload map[string]any, +) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/storages/" + url.PathEscape(storageID) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPatch, + requestURL, + strings.NewReader(string(body)), + ) + if err != nil { + return err + } + + setMilestoneRequestHeaders(request, config, true) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf( + "milestone storage patch failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + return nil +} + +func (s *Server) handleUpdateMilestoneStorage(w http.ResponseWriter, r *http.Request) { + storageID := strings.TrimSpace(r.PathValue("id")) + if storageID == "" { + writeError(w, http.StatusBadRequest, "Storage-ID fehlt") + return + } + + var input updateMilestoneStorageRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + currentStorage, err := getMilestoneStorage(r.Context(), config, storageID) + if err != nil { + log.Printf("Milestone storage load before update failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Speicher konnte nicht geladen werden") + return + } + + payload := map[string]any{} + + var folderRename milestoneFolderRenameResult + if input.Name != nil && currentStorage != nil { + nextName := strings.TrimSpace(*input.Name) + + if nextName != "" && !strings.EqualFold(strings.TrimSpace(currentStorage.Name), nextName) { + folderRename, err = s.renameMilestoneStorageFolderIfNeeded( + r.Context(), + "storage", + currentStorage.DiskPath, + currentStorage.Name, + currentStorage.DisplayName, + nextName, + ) + if err != nil { + log.Printf("Milestone storage folder rename failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Speicher-Ordner konnte nicht umbenannt werden") + return + } + + if folderRename.Renamed { + payload["diskPath"] = folderRename.NewPath + } + } + } + + if input.DisplayName != nil { + payload["displayName"] = strings.TrimSpace(*input.DisplayName) + } + if input.Name != nil { + payload["name"] = strings.TrimSpace(*input.Name) + } + if input.MaxSize != nil { + payload["maxSize"] = *input.MaxSize + } + if input.RetainMinutes != nil { + payload["retainMinutes"] = *input.RetainMinutes + } + + if len(payload) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + + if err := patchMilestoneStorage(r.Context(), config, storageID, payload); err != nil { + s.rollbackMilestoneFolderRename(r.Context(), "storage", folderRename) + + log.Printf("Milestone storage update failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Speicher konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "renamedPath": folderRename.NewPath, }) } @@ -1031,9 +1378,8 @@ func getMilestoneStorage( if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf( - "milestone storage get failed: status=%d body=%s", + "milestone storage get failed: status=%d", response.StatusCode, - string(body), ) } @@ -1117,3 +1463,1538 @@ func setMilestoneRequestHeaders(request *http.Request, config milestoneRuntimeCo request.Header.Set("Content-Type", "application/json") } } + +type milestoneStorageDeviceBasicInfo struct { + ID string + ResourceType string + DisplayName string + Name string + RecordingStorageID string +} + +func milestoneStringValue(value any) string { + if value == nil { + return "" + } + + if stringValue, ok := value.(string); ok { + return stringValue + } + + return fmt.Sprint(value) +} + +func milestoneObjectID(value any) string { + if objectValue, ok := value.(map[string]any); ok { + return strings.TrimSpace(milestoneStringValue(objectValue["id"])) + } + + return strings.TrimSpace(milestoneStringValue(value)) +} + +func milestoneDeviceID(item map[string]any) string { + if id := strings.TrimSpace(milestoneStringValue(item["id"])); id != "" { + return id + } + + if relations, ok := item["relations"].(map[string]any); ok { + if self, ok := relations["self"].(map[string]any); ok { + if id := strings.TrimSpace(milestoneStringValue(self["id"])); id != "" { + return id + } + } + } + + return "" +} + +func milestoneDeviceRecordingStorageID(item map[string]any) string { + if id := milestoneObjectID(item["recordingStorage"]); id != "" { + return id + } + + if relations, ok := item["relations"].(map[string]any); ok { + if id := milestoneObjectID(relations["recordingStorage"]); id != "" { + return id + } + } + + return "" +} + +func milestoneDeviceHardwareID(item map[string]any) string { + if relations, ok := item["relations"].(map[string]any); ok { + if parent, ok := relations["parent"].(map[string]any); ok { + parentType := strings.TrimSpace(milestoneStringValue(parent["type"])) + if parentType == "" || strings.EqualFold(parentType, "hardware") { + return strings.TrimSpace(milestoneStringValue(parent["id"])) + } + } + + if id := milestoneObjectID(relations["hardware"]); id != "" { + return id + } + } + + return "" +} + +func milestoneDeviceDisplayName(item map[string]any) string { + if value := strings.TrimSpace(milestoneStringValue(item["displayName"])); value != "" { + return value + } + + if value := strings.TrimSpace(milestoneStringValue(item["name"])); value != "" { + return value + } + + return milestoneDeviceID(item) +} + +func decodeMilestoneObjectItems(body []byte) ([]map[string]any, error) { + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, err + } + + var rawItems []any + + if array, ok := decoded["array"].([]any); ok { + rawItems = array + } else if dataArray, ok := decoded["data"].([]any); ok { + rawItems = dataArray + } else if data, ok := decoded["data"].(map[string]any); ok { + if array, ok := data["array"].([]any); ok { + rawItems = array + } + } + + items := make([]map[string]any, 0, len(rawItems)) + for _, rawItem := range rawItems { + if item, ok := rawItem.(map[string]any); ok { + items = append(items, item) + } + } + + return items, nil +} + +func decodeMilestoneObject(body []byte) (map[string]any, error) { + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, err + } + + if data, ok := decoded["data"].(map[string]any); ok { + return data, nil + } + + return decoded, nil +} + +func getMilestoneDevicesByStorage( + ctx context.Context, + config milestoneRuntimeConfig, + resourceType string, + storageID string, +) ([]milestoneStorageDeviceBasicInfo, error) { + storageID = strings.TrimSpace(storageID) + if storageID == "" { + return []milestoneStorageDeviceBasicInfo{}, nil + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/" + strings.Trim(resourceType, "/") + "?disabled" + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf( + "milestone %s get failed: status=%d body=%s", + resourceType, + response.StatusCode, + string(body), + ) + } + + items, err := decodeMilestoneObjectItems(body) + if err != nil { + return nil, err + } + + devices := make([]milestoneStorageDeviceBasicInfo, 0) + for _, item := range items { + id := milestoneDeviceID(item) + if id == "" { + continue + } + + recordingStorageID := milestoneDeviceRecordingStorageID(item) + if !strings.EqualFold(recordingStorageID, storageID) { + continue + } + + devices = append(devices, milestoneStorageDeviceBasicInfo{ + ID: id, + ResourceType: resourceType, + DisplayName: milestoneDeviceDisplayName(item), + Name: strings.TrimSpace(milestoneStringValue(item["name"])), + RecordingStorageID: recordingStorageID, + }) + } + + return devices, nil +} + +type milestoneStorageDeleteProgressReporter func(event string, payload map[string]any) + +type milestoneTaskResult struct { + Progress string `json:"progress"` + ErrorCode string `json:"errorCode"` + ErrorText string `json:"errorText"` + State string `json:"state"` + Path string `json:"path"` + TaskType string `json:"taskType"` +} + +func postMilestoneChangeDeviceRecordingStorageTask( + ctx context.Context, + config milestoneRuntimeConfig, + resourceType string, + deviceID string, + targetStorage milestoneStorage, + report milestoneStorageDeleteProgressReporter, + progressStart int, + progressEnd int, +) error { + resourceType = strings.Trim(strings.TrimSpace(resourceType), "/") + deviceID = strings.TrimSpace(deviceID) + targetStorageID := strings.TrimSpace(targetStorage.ID) + + if resourceType == "" { + return fmt.Errorf("resource type is missing") + } + if deviceID == "" { + return fmt.Errorf("device id is missing") + } + if targetStorageID == "" { + return fmt.Errorf("target storage id is missing") + } + + endpoint := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/" + + resourceType + + "/" + + url.PathEscape(deviceID) + + "?task=ChangeDeviceRecordingStorage" + + payload := map[string]any{ + "itemSelection": map[string]any{ + "type": "storages", + "id": targetStorageID, + }, + "moveData": true, + } + + responseBody, err := postMilestoneTask(ctx, config, endpoint, payload) + if err != nil { + log.Printf( + "Milestone ChangeDeviceRecordingStorage task failed: resourceType=%s deviceId=%s targetStorageId=%s targetStorageName=%s endpoint=%s payload=%s error=%v", + resourceType, + deviceID, + targetStorageID, + formatStorageTitleForLog(targetStorage), + endpoint, + milestonePayloadForLog(payload), + err, + ) + + return fmt.Errorf( + "milestone ChangeDeviceRecordingStorage task failed for %s id=%s: endpoint=%s payload=%s error=%w", + resourceType, + deviceID, + endpoint, + milestonePayloadForLog(payload), + err, + ) + } + + log.Printf( + "Milestone ChangeDeviceRecordingStorage task started: resourceType=%s deviceId=%s targetStorageId=%s targetStorageName=%s endpoint=%s payload=%s response=%s", + resourceType, + deviceID, + targetStorageID, + formatStorageTitleForLog(targetStorage), + endpoint, + milestonePayloadForLog(payload), + string(responseBody), + ) + + if result, ok := milestoneTaskResultFromBody(responseBody); ok { + reportMilestoneTaskResult(report, progressStart, progressEnd, result) + if milestoneTaskResultIsError(result) { + return fmt.Errorf( + "milestone ChangeDeviceRecordingStorage task returned error state=%s errorCode=%s errorText=%s", + result.State, + result.ErrorCode, + result.ErrorText, + ) + } + if milestoneTaskResultIsComplete(result) { + return nil + } + } + + taskID := milestoneTaskIDFromBody(responseBody) + if taskID == "" { + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": progressEnd, + "message": "Milestone hat keinen Task-ID zurückgegeben. Der ChangeDeviceRecordingStorage-Aufruf wurde angenommen.", + }) + } + return nil + } + + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": progressStart, + "message": "ChangeDeviceRecordingStorage-Task gestartet.", + "taskId": taskID, + }) + } + + return pollMilestoneTaskProgress( + ctx, + config, + taskID, + report, + progressStart, + progressEnd, + ) +} + +func postMilestoneTask( + ctx context.Context, + config milestoneRuntimeConfig, + requestURL string, + payload map[string]any, +) ([]byte, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + requestURL, + strings.NewReader(string(body)), + ) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, true) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return responseBody, fmt.Errorf( + "status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + return responseBody, nil +} + +func milestonePayloadForLog(payload map[string]any) string { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Sprintf("%v", payload) + } + + return string(body) +} + +func milestoneTaskIDFromBody(body []byte) string { + var decoded any + if err := json.Unmarshal(body, &decoded); err != nil { + return "" + } + + return firstMilestoneStringByKeys(decoded, []string{ + "taskId", + "taskID", + "id", + }) +} + +func firstMilestoneStringByKeys(value any, keys []string) string { + object, ok := value.(map[string]any) + if !ok { + if values, ok := value.([]any); ok { + for _, item := range values { + if result := firstMilestoneStringByKeys(item, keys); result != "" { + return result + } + } + } + return "" + } + + for _, key := range keys { + if stringValue, ok := object[key].(string); ok && strings.TrimSpace(stringValue) != "" { + return strings.TrimSpace(stringValue) + } + } + + if relations, ok := object["relations"].(map[string]any); ok { + if self, ok := relations["self"].(map[string]any); ok { + if id := strings.TrimSpace(milestoneStringValue(self["id"])); id != "" { + return id + } + } + } + + for _, nested := range object { + if result := firstMilestoneStringByKeys(nested, keys); result != "" { + return result + } + } + + return "" +} + +func milestoneTaskResultFromBody(body []byte) (milestoneTaskResult, bool) { + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return milestoneTaskResult{}, false + } + + if result, ok := milestoneTaskResultFromValue(decoded["result"]); ok { + return result, true + } + + if data, ok := decoded["data"].(map[string]any); ok { + if result, ok := milestoneTaskResultFromValue(data["result"]); ok { + return result, true + } + } + + return milestoneTaskResult{}, false +} + +func milestoneTaskResultFromValue(value any) (milestoneTaskResult, bool) { + object, ok := value.(map[string]any) + if !ok { + return milestoneTaskResult{}, false + } + + result := milestoneTaskResult{ + Progress: strings.TrimSpace(milestoneStringValue(object["progress"])), + ErrorCode: strings.TrimSpace(milestoneStringValue(object["errorCode"])), + ErrorText: strings.TrimSpace(milestoneStringValue(object["errorText"])), + State: strings.TrimSpace(milestoneStringValue(object["state"])), + Path: strings.TrimSpace(milestoneStringValue(object["path"])), + TaskType: strings.TrimSpace(milestoneStringValue(object["taskType"])), + } + + return result, result.Progress != "" || + result.ErrorCode != "" || + result.ErrorText != "" || + result.State != "" || + result.Path != "" || + result.TaskType != "" +} + +func milestoneTaskResultProgress(result milestoneTaskResult, start int, end int) int { + if result.Progress == "" { + return end + } + + progress, err := strconv.Atoi(result.Progress) + if err != nil { + return end + } + + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + + if end < start { + return progress + } + + return start + ((end - start) * progress / 100) +} + +func milestoneTaskResultIsComplete(result milestoneTaskResult) bool { + state := strings.TrimSpace(strings.ToLower(result.State)) + + return state == "success" || + state == "completed" || + state == "complete" || + state == "finished" +} + +func milestoneTaskResultIsError(result milestoneTaskResult) bool { + state := strings.TrimSpace(strings.ToLower(result.State)) + + return state == "error" || + state == "failed" || + state == "failure" || + state == "faulted" +} + +func reportMilestoneTaskResult( + report milestoneStorageDeleteProgressReporter, + start int, + end int, + result milestoneTaskResult, +) { + if report == nil { + return + } + + message := "Milestone-Task läuft." + if result.State != "" { + message = "Milestone-Task Status: " + result.State + } + if result.ErrorText != "" { + message = result.ErrorText + } + + report("storage-delete-progress", map[string]any{ + "progress": milestoneTaskResultProgress(result, start, end), + "message": message, + "taskState": result.State, + "taskType": result.TaskType, + "taskPath": result.Path, + "errorCode": result.ErrorCode, + "errorText": result.ErrorText, + }) +} + +func getMilestoneTaskResult( + ctx context.Context, + config milestoneRuntimeConfig, + taskID string, +) (milestoneTaskResult, bool, error) { + taskID = strings.TrimSpace(taskID) + if taskID == "" { + return milestoneTaskResult{}, false, fmt.Errorf("task id is missing") + } + + requestURLs := []string{ + strings.TrimRight(config.Host, "/") + "/API/rest/v1/tasks/" + url.PathEscape(taskID), + } + + recordingServerID := strings.TrimSpace(config.ServerID) + if recordingServerID != "" { + requestURLs = append( + requestURLs, + strings.TrimRight(config.Host, "/")+ + "/API/rest/v1/recordingServers/"+ + url.PathEscape(recordingServerID)+ + "/tasks/"+ + url.PathEscape(taskID), + ) + } + + var lastErr error + + for _, requestURL := range requestURLs { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return milestoneTaskResult{}, false, err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + lastErr = err + continue + } + + body, readErr := io.ReadAll(response.Body) + response.Body.Close() + + if readErr != nil { + return milestoneTaskResult{}, false, readErr + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + lastErr = fmt.Errorf( + "milestone task get failed: url=%s status=%d body=%s", + requestURL, + response.StatusCode, + string(body), + ) + continue + } + + result, ok := milestoneTaskResultFromBody(body) + return result, ok, nil + } + + if lastErr != nil { + return milestoneTaskResult{}, false, lastErr + } + + return milestoneTaskResult{}, false, nil +} + +func pollMilestoneTaskProgress( + ctx context.Context, + config milestoneRuntimeConfig, + taskID string, + report milestoneStorageDeleteProgressReporter, + progressStart int, + progressEnd int, +) error { + deadline := time.NewTimer(5 * time.Minute) + defer deadline.Stop() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + result, ok, err := getMilestoneTaskResult(ctx, config, taskID) + if err != nil { + return err + } + + if ok { + reportMilestoneTaskResult(report, progressStart, progressEnd, result) + + if milestoneTaskResultIsError(result) { + return fmt.Errorf( + "milestone task %s failed: state=%s errorCode=%s errorText=%s", + taskID, + result.State, + result.ErrorCode, + result.ErrorText, + ) + } + + if milestoneTaskResultIsComplete(result) { + return nil + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("milestone task %s progress polling timed out", taskID) + case <-ticker.C: + } + } +} + +func writeMilestoneStorageSSE( + w http.ResponseWriter, + flusher http.Flusher, + event string, + payload map[string]any, +) { + if payload == nil { + payload = map[string]any{} + } + + body, err := json.Marshal(payload) + if err != nil { + body = []byte(`{"message":"SSE payload konnte nicht serialisiert werden"}`) + } + + fmt.Fprintf(w, "event: %s\n", event) + fmt.Fprintf(w, "data: %s\n\n", body) + flusher.Flush() +} + +type milestoneStorageDeviceMigrationResult struct { + Cameras int `json:"cameras"` + Microphones int `json:"microphones"` +} + +func migrateMilestoneStorageDevicesToDefaultStorage( + ctx context.Context, + config milestoneRuntimeConfig, + sourceStorageID string, + targetStorage milestoneStorage, + report milestoneStorageDeleteProgressReporter, +) (milestoneStorageDeviceMigrationResult, error) { + result := milestoneStorageDeviceMigrationResult{} + + targetStorageID := strings.TrimSpace(targetStorage.ID) + if targetStorageID == "" { + return result, fmt.Errorf("default storage id is missing") + } + + cameras, err := getMilestoneDevicesByStorage(ctx, config, "cameras", sourceStorageID) + if err != nil { + return result, err + } + + microphones, err := getMilestoneDevicesByStorage(ctx, config, "microphones", sourceStorageID) + if err != nil { + return result, err + } + + totalDevices := len(cameras) + len(microphones) + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": 5, + "message": fmt.Sprintf("%d Kamera(s) und %d Mikrofon(e) werden auf %s umgestellt.", len(cameras), len(microphones), formatStorageTitleForLog(targetStorage)), + "cameras": len(cameras), + "microphones": len(microphones), + "defaultStorageId": targetStorageID, + }) + } + + completedDevices := 0 + + nextProgressRange := func() (int, int) { + if totalDevices <= 0 { + return 80, 90 + } + + start := 10 + (completedDevices * 80 / totalDevices) + end := 10 + ((completedDevices + 1) * 80 / totalDevices) + + return start, end + } + + for _, camera := range cameras { + progressStart, progressEnd := nextProgressRange() + + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": progressStart, + "message": fmt.Sprintf("Kamera %q wird auf den Standardspeicher umgestellt.", camera.DisplayName), + "deviceType": "camera", + "deviceId": camera.ID, + "deviceName": camera.DisplayName, + }) + } + + if err := postMilestoneChangeDeviceRecordingStorageTask( + ctx, + config, + "cameras", + camera.ID, + targetStorage, + report, + progressStart, + progressEnd, + ); err != nil { + return result, fmt.Errorf( + "Kamera %q konnte nicht per ChangeDeviceRecordingStorage auf den Standardspeicher %q umgestellt werden: %w", + camera.DisplayName, + formatStorageTitleForLog(targetStorage), + err, + ) + } + + completedDevices++ + result.Cameras++ + + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": progressEnd, + "message": fmt.Sprintf("Kamera %q wurde umgestellt.", camera.DisplayName), + "deviceType": "camera", + "deviceId": camera.ID, + "deviceName": camera.DisplayName, + }) + } + } + + for _, microphone := range microphones { + progressStart, progressEnd := nextProgressRange() + + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": progressStart, + "message": fmt.Sprintf("Mikrofon %q wird auf den Standardspeicher umgestellt.", microphone.DisplayName), + "deviceType": "microphone", + "deviceId": microphone.ID, + "deviceName": microphone.DisplayName, + }) + } + + if err := postMilestoneChangeDeviceRecordingStorageTask( + ctx, + config, + "microphones", + microphone.ID, + targetStorage, + report, + progressStart, + progressEnd, + ); err != nil { + return result, fmt.Errorf( + "Mikrofon %q konnte nicht per ChangeDeviceRecordingStorage auf den Standardspeicher %q umgestellt werden: %w", + microphone.DisplayName, + formatStorageTitleForLog(targetStorage), + err, + ) + } + + completedDevices++ + result.Microphones++ + + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": progressEnd, + "message": fmt.Sprintf("Mikrofon %q wurde umgestellt.", microphone.DisplayName), + "deviceType": "microphone", + "deviceId": microphone.ID, + "deviceName": microphone.DisplayName, + }) + } + } + + return result, nil +} + +func formatStorageTitleForLog(storage milestoneStorage) string { + for _, value := range []string{storage.DisplayName, storage.Name, storage.ID} { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + + return "Standardspeicher" +} + +func findMilestoneStorageByID(storages []milestoneStorage, storageID string) *milestoneStorage { + for index := range storages { + if strings.EqualFold(storages[index].ID, storageID) { + return &storages[index] + } + } + + return nil +} + +func storageLooksLikeLocalDefault(storage milestoneStorage) bool { + for _, value := range []string{storage.DisplayName, storage.Name} { + if strings.EqualFold(strings.TrimSpace(value), "Local storage") { + return true + } + } + + return false +} + +func findMilestoneDefaultStorageForDelete( + ctx context.Context, + config milestoneRuntimeConfig, + storages []milestoneStorage, + sourceStorageID string, +) (*milestoneStorage, error) { + for index := range storages { + if storages[index].IsDefault { + if strings.EqualFold(storages[index].ID, sourceStorageID) { + return nil, fmt.Errorf("Der Standardspeicher kann nicht gelöscht werden") + } + + return &storages[index], nil + } + } + + defaultStorageID := getMilestoneRecordingServerDefaultStorageID(ctx, config) + if defaultStorageID != "" { + if strings.EqualFold(defaultStorageID, sourceStorageID) { + return nil, fmt.Errorf("Der Standardspeicher kann nicht gelöscht werden") + } + + if storage := findMilestoneStorageByID(storages, defaultStorageID); storage != nil { + return storage, nil + } + + return &milestoneStorage{ + ID: defaultStorageID, + DisplayName: "Standardspeicher", + Name: "Standardspeicher", + }, nil + } + + for index := range storages { + if !strings.EqualFold(storages[index].ID, sourceStorageID) && + storageLooksLikeLocalDefault(storages[index]) { + return &storages[index], nil + } + } + + return nil, fmt.Errorf("Standardspeicher \"Local storage\" wurde nicht gefunden") +} + +type milestoneFolderRenameResult struct { + OldPath string + NewPath string + Renamed bool +} + +func milestoneDiskPathBaseName(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + pathValue = strings.TrimRight(pathValue, `\/`) + + if pathValue == "" { + return "" + } + + separatorIndex := strings.LastIndexAny(pathValue, `\/`) + if separatorIndex < 0 { + return pathValue + } + + return pathValue[separatorIndex+1:] +} + +func milestoneDiskPathParent(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + pathValue = strings.TrimRight(pathValue, `\/`) + + if pathValue == "" { + return "" + } + + separatorIndex := strings.LastIndexAny(pathValue, `\/`) + if separatorIndex < 0 { + return "" + } + + return pathValue[:separatorIndex] +} + +func normalizeMilestoneCreateDiskPath(pathValue string, names ...string) string { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return "" + } + + if milestoneFolderNameMatches(pathValue, names...) { + parentPath := milestoneDiskPathParent(pathValue) + if parentPath != "" { + return parentPath + } + } + + return pathValue +} + +type milestoneFolderDeleteCandidate struct { + RootType string + Path string + Label string +} + +func appendMilestoneFolderDeleteCandidate( + candidates []milestoneFolderDeleteCandidate, + rootType string, + pathValue string, + label string, +) []milestoneFolderDeleteCandidate { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return candidates + } + + normalizedPath := strings.ToLower(strings.TrimRight(pathValue, `\/`)) + for _, candidate := range candidates { + if strings.ToLower(strings.TrimRight(candidate.Path, `\/`)) == normalizedPath { + return candidates + } + } + + return append(candidates, milestoneFolderDeleteCandidate{ + RootType: rootType, + Path: pathValue, + Label: label, + }) +} + +func milestoneStorageFolderDeleteCandidates(storage milestoneStorage) []milestoneFolderDeleteCandidate { + candidates := []milestoneFolderDeleteCandidate{} + diskPath := strings.TrimSpace(storage.DiskPath) + + if diskPath == "" { + return candidates + } + + storageID := strings.TrimSpace(storage.ID) + if storageID != "" && !strings.EqualFold(milestoneDiskPathBaseName(diskPath), storageID) { + candidates = appendMilestoneFolderDeleteCandidate( + candidates, + "storage", + joinMilestoneDiskPath(diskPath, storageID), + "Basis-Storage-Datenordner", + ) + } + + if strings.EqualFold(milestoneDiskPathBaseName(diskPath), storageID) || + milestoneFolderNameMatches(diskPath, storage.Name, storage.DisplayName) { + candidates = appendMilestoneFolderDeleteCandidate( + candidates, + "storage", + diskPath, + "Basis-Ordner", + ) + } + + return candidates +} + +func milestoneArchiveFolderDeleteCandidates(archive milestoneArchiveStorage) []milestoneFolderDeleteCandidate { + candidates := []milestoneFolderDeleteCandidate{} + diskPath := strings.TrimSpace(archive.DiskPath) + + if diskPath == "" { + return candidates + } + + archiveID := strings.TrimSpace(archive.ID) + if archiveID != "" && !strings.EqualFold(milestoneDiskPathBaseName(diskPath), archiveID) { + candidates = appendMilestoneFolderDeleteCandidate( + candidates, + "archive", + joinMilestoneDiskPath(diskPath, archiveID), + "Archiv-Storage-Datenordner", + ) + } + + if strings.EqualFold(milestoneDiskPathBaseName(diskPath), archiveID) || + milestoneFolderNameMatches(diskPath, archive.Name, archive.DisplayName) { + candidates = appendMilestoneFolderDeleteCandidate( + candidates, + "archive", + diskPath, + "Archiv-Ordner", + ) + } + + return candidates +} + +func milestoneFolderNameMatches(pathValue string, names ...string) bool { + baseName := milestoneDiskPathBaseName(pathValue) + if baseName == "" { + return false + } + + for _, name := range names { + if strings.EqualFold(strings.TrimSpace(name), baseName) { + return true + } + } + + return false +} + +func canUseMilestoneNameAsFolderName(value string) bool { + value = strings.TrimSpace(value) + + if value == "" || value == "." || value == ".." { + return false + } + + return !strings.ContainsAny(value, `<>:"/\|?*`) +} + +func (s *Server) renameMilestoneStorageFolderIfNeeded( + ctx context.Context, + rootType string, + currentDiskPath string, + currentName string, + currentDisplayName string, + nextName string, +) (milestoneFolderRenameResult, error) { + result := milestoneFolderRenameResult{ + OldPath: strings.TrimSpace(currentDiskPath), + } + + nextName = strings.TrimSpace(nextName) + if result.OldPath == "" || nextName == "" { + return result, nil + } + + if !canUseMilestoneNameAsFolderName(nextName) { + return result, fmt.Errorf("Neuer Ordnername %q ist kein gültiger Windows-Ordnername", nextName) + } + + if strings.EqualFold(milestoneDiskPathBaseName(result.OldPath), nextName) { + result.NewPath = result.OldPath + return result, nil + } + + if !milestoneFolderNameMatches(result.OldPath, currentName, currentDisplayName) { + return result, nil + } + + newPath, err := s.renameServerFolderPath(ctx, rootType, result.OldPath, nextName) + if err != nil { + return result, err + } + + result.NewPath = newPath + result.Renamed = true + + return result, nil +} + +func (s *Server) rollbackMilestoneFolderRename( + ctx context.Context, + rootType string, + renameResult milestoneFolderRenameResult, +) { + if !renameResult.Renamed || + strings.TrimSpace(renameResult.OldPath) == "" || + strings.TrimSpace(renameResult.NewPath) == "" { + return + } + + oldName := milestoneDiskPathBaseName(renameResult.OldPath) + if oldName == "" { + return + } + + if _, err := s.renameServerFolderPath(ctx, rootType, renameResult.NewPath, oldName); err != nil { + log.Printf( + "Milestone folder rename rollback failed: rootType=%s newPath=%s oldPath=%s error=%v", + rootType, + renameResult.NewPath, + renameResult.OldPath, + err, + ) + } +} + +func (s *Server) deleteMilestoneStorageDiskFolders( + ctx context.Context, + storage milestoneStorage, + archiveStorages []milestoneArchiveStorage, + report milestoneStorageDeleteProgressReporter, +) []string { + errors := []string{} + + candidates := []milestoneFolderDeleteCandidate{} + for _, archive := range archiveStorages { + candidates = append(candidates, milestoneArchiveFolderDeleteCandidates(archive)...) + } + candidates = append(candidates, milestoneStorageFolderDeleteCandidates(storage)...) + + for _, candidate := range candidates { + if strings.TrimSpace(candidate.Path) == "" { + continue + } + + message := fmt.Sprintf("%s %q wird gelöscht.", candidate.Label, candidate.Path) + if report != nil { + report("storage-delete-progress", map[string]any{ + "progress": 97, + "message": message, + "folder": candidate.Path, + "rootType": candidate.RootType, + }) + } + + if err := s.deleteServerFolderPath(ctx, candidate.RootType, candidate.Path); err != nil { + log.Printf( + "Milestone folder delete failed: rootType=%s label=%s path=%s error=%v", + candidate.RootType, + candidate.Label, + candidate.Path, + err, + ) + errors = append(errors, fmt.Sprintf("%s %s: %v", candidate.Label, candidate.Path, err)) + } + } + + return errors +} + +func deleteMilestoneStorageFromAPI( + ctx context.Context, + config milestoneRuntimeConfig, + storageID string, +) error { + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/storages/" + url.PathEscape(storageID) + + request, err := http.NewRequestWithContext(ctx, http.MethodDelete, requestURL, nil) + if err != nil { + return err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf( + "milestone storage delete failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + return nil +} + +func deleteMilestoneArchiveStorageFromAPI( + ctx context.Context, + config milestoneRuntimeConfig, + archiveStorageID string, +) error { + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/archiveStorages/" + url.PathEscape(archiveStorageID) + + request, err := http.NewRequestWithContext(ctx, http.MethodDelete, requestURL, nil) + if err != nil { + return err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf( + "milestone archive storage delete failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + return nil +} + +func (s *Server) handleDeleteMilestoneStorage(w http.ResponseWriter, r *http.Request) { + storageID := strings.TrimSpace(r.PathValue("id")) + if storageID == "" { + writeError(w, http.StatusBadRequest, "Storage-ID fehlt") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + storages, err := getMilestoneStorages(r.Context(), config) + if err != nil { + log.Printf("Milestone storages load failed for delete: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher konnten nicht geladen werden") + return + } + + storageToDelete := findMilestoneStorageByID(storages, storageID) + if storageToDelete == nil { + writeError(w, http.StatusNotFound, "Milestone-Speicher wurde nicht gefunden") + return + } + + archiveStoragesToDelete, err := getMilestoneArchiveStorages(r.Context(), config, storageID) + if err != nil { + log.Printf("Milestone archive storages load before storage delete failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Milestone-Archivspeicher konnten vor dem Löschen nicht geladen werden") + return + } + + defaultStorage, err := findMilestoneDefaultStorageForDelete( + r.Context(), + config, + storages, + storageID, + ) + if err != nil { + log.Printf("Milestone default storage lookup failed for delete: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + migration, err := migrateMilestoneStorageDevicesToDefaultStorage( + r.Context(), + config, + storageID, + *defaultStorage, + nil, + ) + if err != nil { + log.Printf( + "Milestone storage device migration failed: storageId=%s defaultStorageId=%s error=%v", + storageID, + defaultStorage.ID, + err, + ) + writeError(w, http.StatusBadGateway, "Kameras und Mikrofone konnten nicht auf den Standardspeicher umgestellt werden") + return + } + + if err := deleteMilestoneStorageFromAPI(r.Context(), config, storageID); err != nil { + log.Printf("Milestone storage delete failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher konnte nicht gelöscht werden") + return + } + + folderDeleteErrors := s.deleteMilestoneStorageDiskFolders( + r.Context(), + *storageToDelete, + archiveStoragesToDelete, + nil, + ) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deletedStorage": map[string]any{ + "id": storageToDelete.ID, + "displayName": storageToDelete.DisplayName, + "name": storageToDelete.Name, + }, + "defaultStorage": map[string]any{ + "id": defaultStorage.ID, + "displayName": defaultStorage.DisplayName, + "name": defaultStorage.Name, + }, + "migratedDevices": migration, + "folderDeleteErrors": folderDeleteErrors, + }) +} + +func (s *Server) handleDeleteMilestoneStorageStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "SSE wird von diesem Server nicht unterstützt") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + report := func(event string, payload map[string]any) { + writeMilestoneStorageSSE(w, flusher, event, payload) + } + + fail := func(status int, message string, err error) { + payload := map[string]any{ + "progress": 100, + "message": message, + "status": status, + } + if err != nil { + payload["error"] = err.Error() + } + report("storage-delete-error", payload) + } + + storageID := strings.TrimSpace(r.PathValue("id")) + if storageID == "" { + fail(http.StatusBadRequest, "Storage-ID fehlt", nil) + return + } + + report("storage-delete-progress", map[string]any{ + "progress": 0, + "message": "Löschvorgang wird vorbereitet.", + }) + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + fail(http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden", err) + return + } + + storages, err := getMilestoneStorages(r.Context(), config) + if err != nil { + log.Printf("Milestone storages load failed for delete stream: %v", err) + fail(http.StatusBadGateway, "Milestone-Speicher konnten nicht geladen werden", err) + return + } + + storageToDelete := findMilestoneStorageByID(storages, storageID) + if storageToDelete == nil { + fail(http.StatusNotFound, "Milestone-Speicher wurde nicht gefunden", nil) + return + } + + archiveStoragesToDelete, err := getMilestoneArchiveStorages(r.Context(), config, storageID) + if err != nil { + log.Printf("Milestone archive storages load before stream storage delete failed: storageId=%s error=%v", storageID, err) + fail(http.StatusBadGateway, "Milestone-Archivspeicher konnten vor dem Löschen nicht geladen werden", err) + return + } + + defaultStorage, err := findMilestoneDefaultStorageForDelete( + r.Context(), + config, + storages, + storageID, + ) + if err != nil { + log.Printf("Milestone default storage lookup failed for delete stream: storageId=%s error=%v", storageID, err) + fail(http.StatusBadRequest, err.Error(), err) + return + } + + report("storage-delete-progress", map[string]any{ + "progress": 3, + "message": fmt.Sprintf("Standardspeicher %q wurde gefunden.", formatStorageTitleForLog(*defaultStorage)), + "defaultStorage": map[string]any{ + "id": defaultStorage.ID, + "displayName": defaultStorage.DisplayName, + "name": defaultStorage.Name, + }, + }) + + migration, err := migrateMilestoneStorageDevicesToDefaultStorage( + r.Context(), + config, + storageID, + *defaultStorage, + report, + ) + if err != nil { + log.Printf( + "Milestone storage device migration failed during stream delete: storageId=%s defaultStorageId=%s error=%v", + storageID, + defaultStorage.ID, + err, + ) + fail(http.StatusBadGateway, "Kameras und Mikrofone konnten nicht auf den Standardspeicher umgestellt werden", err) + return + } + + report("storage-delete-progress", map[string]any{ + "progress": 95, + "message": "Basis-Speicher wird gelöscht.", + "migratedDevices": migration, + }) + + if err := deleteMilestoneStorageFromAPI(r.Context(), config, storageID); err != nil { + log.Printf("Milestone storage delete failed during stream delete: storageId=%s error=%v", storageID, err) + fail(http.StatusBadGateway, "Milestone-Speicher konnte nicht gelöscht werden", err) + return + } + + folderDeleteErrors := s.deleteMilestoneStorageDiskFolders( + r.Context(), + *storageToDelete, + archiveStoragesToDelete, + report, + ) + + completeMessage := "Basis-Speicher wurde gelöscht." + if len(folderDeleteErrors) > 0 { + completeMessage = "Basis-Speicher wurde gelöscht, aber nicht alle Ordner konnten entfernt werden." + } + + report("storage-delete-complete", map[string]any{ + "progress": 100, + "message": completeMessage, + "deletedStorage": map[string]any{ + "id": storageToDelete.ID, + "displayName": storageToDelete.DisplayName, + "name": storageToDelete.Name, + }, + "defaultStorage": map[string]any{ + "id": defaultStorage.ID, + "displayName": defaultStorage.DisplayName, + "name": defaultStorage.Name, + }, + "migratedDevices": migration, + "folderDeleteErrors": folderDeleteErrors, + }) +} + +func (s *Server) handleDeleteMilestoneArchiveStorage(w http.ResponseWriter, r *http.Request) { + archiveStorageID := strings.TrimSpace(r.PathValue("id")) + if archiveStorageID == "" { + writeError(w, http.StatusBadRequest, "Archive-Storage-ID fehlt") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + archiveStorageToDelete, err := getMilestoneArchiveStorage(r.Context(), config, archiveStorageID) + if err != nil { + log.Printf("Milestone archive storage load before delete failed: archiveStorageId=%s error=%v", archiveStorageID, err) + writeError(w, http.StatusBadGateway, "Milestone-Archivspeicher konnte nicht geladen werden") + return + } + + if err := deleteMilestoneArchiveStorageFromAPI(r.Context(), config, archiveStorageID); err != nil { + log.Printf("Milestone archive storage delete failed: archiveStorageId=%s error=%v", archiveStorageID, err) + writeError(w, http.StatusBadGateway, "Milestone-Archivspeicher konnte nicht gelöscht werden") + return + } + + folderDeleteErrors := []string{} + if archiveStorageToDelete != nil { + for _, candidate := range milestoneArchiveFolderDeleteCandidates(*archiveStorageToDelete) { + if err := s.deleteServerFolderPath(r.Context(), candidate.RootType, candidate.Path); err != nil { + log.Printf( + "Milestone archive folder delete failed after archive delete: archiveStorageId=%s path=%s error=%v", + archiveStorageID, + candidate.Path, + err, + ) + folderDeleteErrors = append(folderDeleteErrors, fmt.Sprintf("%s %s: %v", candidate.Label, candidate.Path, err)) + } + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "folderDeleteErrors": folderDeleteErrors, + }) +} diff --git a/backend/operations.go b/backend/operations.go index adb4e44..e84dfe5 100644 --- a/backend/operations.go +++ b/backend/operations.go @@ -26,15 +26,16 @@ type Operation struct { OperationLeader string `json:"operationLeader"` OperationTeam string `json:"operationTeam"` Legend string `json:"legend"` - Camera string `json:"camera"` - Router string `json:"router"` - Technology string `json:"technology"` - Switchbox string `json:"switchbox"` - HardDrive string `json:"hardDrive"` - Laptop string `json:"laptop"` - Remark string `json:"remark"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + Camera string `json:"camera"` + Router string `json:"router"` + Technology string `json:"technology"` + Switchbox string `json:"switchbox"` + HardDrive string `json:"hardDrive"` + Laptop string `json:"laptop"` + Remark string `json:"remark"` + MilestoneStorage string `json:"milestoneStorage"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type CreateOperationJournalEntryRequest struct { @@ -55,13 +56,14 @@ type CreateOperationRequest struct { OperationLeader string `json:"operationLeader"` OperationTeam string `json:"operationTeam"` Legend string `json:"legend"` - Camera string `json:"camera"` - Router string `json:"router"` - Technology string `json:"technology"` - Switchbox string `json:"switchbox"` - HardDrive string `json:"hardDrive"` - Laptop string `json:"laptop"` - Remark string `json:"remark"` + Camera string `json:"camera"` + Router string `json:"router"` + Technology string `json:"technology"` + Switchbox string `json:"switchbox"` + HardDrive string `json:"hardDrive"` + Laptop string `json:"laptop"` + Remark string `json:"remark"` + MilestoneStorage string `json:"milestoneStorage"` } type UpdateOperationRequest = CreateOperationRequest @@ -127,6 +129,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { hard_drive, laptop, remark, + milestone_storage, created_at, updated_at FROM operations @@ -163,6 +166,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { &operation.HardDrive, &operation.Laptop, &operation.Remark, + &operation.MilestoneStorage, &operation.CreatedAt, &operation.UpdatedAt, ); err != nil { @@ -232,9 +236,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { switchbox, hard_drive, laptop, - remark + remark, + milestone_storage ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, operation_number, @@ -253,6 +258,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { hard_drive, laptop, remark, + milestone_storage, created_at, updated_at `, @@ -272,6 +278,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { input.HardDrive, input.Laptop, input.Remark, + input.MilestoneStorage, ).Scan( &operation.ID, &operation.OperationNumber, @@ -290,6 +297,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { &operation.HardDrive, &operation.Laptop, &operation.Remark, + &operation.MilestoneStorage, &operation.CreatedAt, &operation.UpdatedAt, ) @@ -420,6 +428,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { hard_drive = $15, laptop = $16, remark = $17, + milestone_storage = $18, updated_at = now() WHERE id = $1 RETURNING @@ -440,6 +449,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { hard_drive, laptop, remark, + milestone_storage, created_at, updated_at `, @@ -460,6 +470,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { input.HardDrive, input.Laptop, input.Remark, + input.MilestoneStorage, ).Scan( &operation.ID, &operation.OperationNumber, @@ -478,6 +489,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { &operation.HardDrive, &operation.Laptop, &operation.Remark, + &operation.MilestoneStorage, &operation.CreatedAt, &operation.UpdatedAt, ) @@ -642,6 +654,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) hard_drive, laptop, remark, + milestone_storage, created_at, updated_at FROM operations @@ -667,6 +680,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) &operation.HardDrive, &operation.Laptop, &operation.Remark, + &operation.MilestoneStorage, &operation.CreatedAt, &operation.UpdatedAt, ) @@ -726,6 +740,7 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive) changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop) changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark) + changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", "", input.MilestoneStorage) return changes } @@ -749,6 +764,7 @@ func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOper changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive) changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop) changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark) + changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", oldOperation.MilestoneStorage, input.MilestoneStorage) return changes } @@ -792,6 +808,7 @@ func normalizeOperationInput(input *CreateOperationRequest) { input.HardDrive = strings.TrimSpace(input.HardDrive) input.Laptop = strings.TrimSpace(input.Laptop) input.Remark = strings.TrimSpace(input.Remark) + input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage) } func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) { diff --git a/backend/routes.go b/backend/routes.go index 1d6fada..c247b4d 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -123,6 +123,36 @@ func (s *Server) Routes() http.Handler { s.requireAuth(s.requireRight("administration:write", s.handleProxyCreateServerFolder)), ) + mux.HandleFunc( + "PATCH /admin/server-folders", + s.requireAuth(s.requireRight("administration:write", s.handleProxyRenameServerFolder)), + ) + mux.HandleFunc( + "DELETE /admin/server-folders", + s.requireAuth(s.requireRight("administration:write", s.handleProxyDeleteServerFolder)), + ) + + mux.HandleFunc( + "PATCH /admin/milestone/archive-storages/{id}", + s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)), + ) + mux.HandleFunc( + "DELETE /admin/milestone/archive-storages/{id}", + s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneArchiveStorage)), + ) + mux.HandleFunc( + "GET /admin/milestone/storage-information/{id}", + s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)), + ) + mux.HandleFunc( + "GET /admin/milestone/cameras", + s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneCameras)), + ) + mux.HandleFunc( + "PATCH /admin/milestone/devices/{collection}/{id}", + s.requireAuth(s.requireRight("administration:write", s.handlePatchMilestoneDevice)), + ) + mux.HandleFunc( "GET /admin/milestone/storages", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneStorages)), @@ -131,6 +161,14 @@ func (s *Server) Routes() http.Handler { "POST /admin/milestone/storages", s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneStorageWithArchive)), ) + mux.HandleFunc( + "PATCH /admin/milestone/storages/{id}", + s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneStorage)), + ) + mux.HandleFunc( + "DELETE /admin/milestone/storages/{id}", + s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneStorage)), + ) mux.HandleFunc( "POST /admin/milestone/storages/{id}/archive-storages", s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneArchiveStorage)), @@ -140,12 +178,8 @@ func (s *Server) Routes() http.Handler { s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneAutoArchiveStorage)), ) mux.HandleFunc( - "PATCH /admin/milestone/archive-storages/{id}", - s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)), - ) - mux.HandleFunc( - "GET /admin/milestone/storage-information/{id}", - s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)), + "GET /admin/milestone/storages/{id}/delete-stream", + s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneStorageStream)), ) return s.cors(mux) diff --git a/backend/server_folders_proxy.go b/backend/server_folders_proxy.go index 28b56bf..5f237d2 100644 --- a/backend/server_folders_proxy.go +++ b/backend/server_folders_proxy.go @@ -5,6 +5,7 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -28,6 +29,14 @@ func (s *Server) handleProxyCreateServerFolder(w http.ResponseWriter, r *http.Re s.proxyServerFoldersRequest(w, r, http.MethodPost) } +func (s *Server) handleProxyRenameServerFolder(w http.ResponseWriter, r *http.Request) { + s.proxyServerFoldersRequest(w, r, http.MethodPatch) +} + +func (s *Server) handleProxyDeleteServerFolder(w http.ResponseWriter, r *http.Request) { + s.proxyServerFoldersRequest(w, r, http.MethodDelete) +} + func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) { config, err := s.getServerFoldersAgentConfig(r.Context()) if err != nil { @@ -42,7 +51,7 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques } var body []byte - if method == http.MethodPost { + if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete { body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden") @@ -63,7 +72,7 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques request.Header.Set("Accept", "application/json") - if method == http.MethodPost { + if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete { request.Header.Set("Content-Type", "application/json") } @@ -98,6 +107,171 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques _, _ = w.Write(responseBody) } +func (s *Server) requestServerFoldersAgent( + ctx context.Context, + method string, + query url.Values, + payload any, +) ([]byte, int, error) { + config, err := s.getServerFoldersAgentConfig(ctx) + if err != nil { + return nil, 0, err + } + + targetURL, err := serverFoldersAgentURL(config, query.Encode()) + if err != nil { + return nil, 0, err + } + + var body []byte + if payload != nil { + body, err = json.Marshal(payload) + if err != nil { + return nil, 0, err + } + } + + request, err := http.NewRequestWithContext( + ctx, + method, + targetURL, + bytes.NewReader(body), + ) + if err != nil { + return nil, 0, err + } + + request.Header.Set("Accept", "application/json") + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + + if config.Token != "" { + request.Header.Set("X-Server-Folders-Token", config.Token) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + response, err := client.Do(request) + if err != nil { + return nil, 0, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + if err != nil { + return nil, response.StatusCode, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return responseBody, response.StatusCode, fmt.Errorf( + "server-folders-agent request failed: method=%s url=%s status=%d body=%s", + method, + targetURL, + response.StatusCode, + string(responseBody), + ) + } + + return responseBody, response.StatusCode, nil +} + +type serverFolderAgentFolderResponse struct { + OK bool `json:"ok"` + Folder struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` + } `json:"folder"` +} + +func (s *Server) deleteServerFolderPath( + ctx context.Context, + rootType string, + pathValue string, +) error { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return nil + } + + query := url.Values{} + query.Set("rootType", rootType) + query.Set("path", pathValue) + + responseBody, status, err := s.requestServerFoldersAgent( + ctx, + http.MethodDelete, + query, + nil, + ) + if err != nil { + if status == http.StatusNotFound { + return nil + } + + return fmt.Errorf( + "Ordner konnte nicht gelöscht werden: path=%s status=%d body=%s error=%w", + pathValue, + status, + string(responseBody), + err, + ) + } + + return nil +} + +func (s *Server) renameServerFolderPath( + ctx context.Context, + rootType string, + pathValue string, + nextName string, +) (string, error) { + pathValue = strings.TrimSpace(pathValue) + nextName = strings.TrimSpace(nextName) + + if pathValue == "" || nextName == "" { + return pathValue, nil + } + + query := url.Values{} + query.Set("rootType", rootType) + + responseBody, _, err := s.requestServerFoldersAgent( + ctx, + http.MethodPatch, + query, + map[string]any{ + "path": pathValue, + "name": nextName, + }, + ) + if err != nil { + return "", fmt.Errorf( + "Ordner konnte nicht umbenannt werden: path=%s name=%s body=%s error=%w", + pathValue, + nextName, + string(responseBody), + err, + ) + } + + var response serverFolderAgentFolderResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return "", err + } + + if strings.TrimSpace(response.Folder.Path) == "" { + return "", fmt.Errorf("Server-Folders-Agent hat keinen neuen Pfad zurückgegeben") + } + + return response.Folder.Path, nil +} + func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) { var config serverFoldersAgentConfig @@ -166,8 +340,6 @@ func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (st } func forwardedServerFoldersQuery(values url.Values) string { - // rootType=storage/archive wird bewusst weitergeleitet. - // Nur Verbindungs-/Auth-Parameter werden entfernt. nextValues := url.Values{} for key, value := range values { diff --git a/backend/setup/main.go b/backend/setup/main.go index 697e198..fe92f6a 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -439,12 +439,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { hard_drive TEXT NOT NULL DEFAULT '', laptop TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '', + milestone_storage TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`, + ` CREATE TABLE IF NOT EXISTS operation_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ee7a0a7..1c3255f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import type { Notification, User } from './components/types' import AdministrationPage from './pages/administration/AdministrationPage' import Feedback from './components/Feedback' import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage' +import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage' import { hasRight } from './components/permissions' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -445,6 +446,17 @@ function App() { } /> + + + + } + /> + > + icon: ComponentType> right?: string - isChild?: boolean + children?: NavigationItem[] } -const navigation: NavigationItem[] = [ +type NavigationSection = { + name?: string + items: NavigationItem[] +} + +const navigationSections: NavigationSection[] = [ { - name: 'Dashboard', - href: '/', - icon: HomeIcon, + items: [ + { + name: 'Dashboard', + href: '/', + icon: HomeIcon, + }, + ], }, { - name: 'Geräte', - href: '/geraete', - icon: ComputerDesktopIcon, - right: 'devices:read', + name: 'Betrieb', + items: [ + { + name: 'Geräte', + href: '/geraete', + icon: ComputerDesktopIcon, + right: 'devices:read', + }, + { + name: 'Einsätze', + href: '/einsaetze', + icon: VideoCameraIcon, + right: 'operations:read', + children: [ + { + name: 'Milestone-Speicher', + href: '/einsaetze/milestone-speicher', + icon: ServerStackIcon, + right: 'administration:read', + }, + { + name: 'Milestone-Geräte', + href: '/einsaetze/milestone-devices', + icon: CpuChipIcon, + right: 'administration:read', + }, + ], + }, + ], }, { - name: 'Einsätze', - href: '/einsaetze', - icon: VideoCameraIcon, - right: 'operations:read', - }, - { - name: 'Milestone-Speicher', - href: '/einsaetze/milestone-speicher', - icon: ServerStackIcon, - right: 'administration:read', - isChild: true, - }, - { - name: 'Kalender', - href: '/kalender', - icon: CalendarIcon, - right: 'calendar:read', - }, - { - name: 'Dokumente', - href: '/dokumente', - icon: DocumentDuplicateIcon, - right: 'documents:read', - }, - { - name: 'Berichte', - href: '/berichte', - icon: ChartPieIcon, - right: 'reports:read', + name: 'Organisation', + items: [ + { + name: 'Kalender', + href: '/kalender', + icon: CalendarIcon, + right: 'calendar:read', + }, + { + name: 'Dokumente', + href: '/dokumente', + icon: DocumentDuplicateIcon, + right: 'documents:read', + }, + { + name: 'Berichte', + href: '/berichte', + icon: ChartPieIcon, + right: 'reports:read', + }, + ], }, ] @@ -95,6 +123,49 @@ function getTeamInitial(team: Team) { return team.name.charAt(0).toUpperCase() } +function isPathActive(pathname: string, href: string) { + if (href === '/') { + return pathname === '/' + } + + return pathname === href || pathname.startsWith(`${href}/`) +} + +function itemIsActive(item: NavigationItem, pathname: string): boolean { + return ( + isPathActive(pathname, item.href) || + (item.children ?? []).some((child) => itemIsActive(child, pathname)) + ) +} + +function filterNavigationItems( + items: NavigationItem[], + user: User, +): NavigationItem[] { + return items + .map((item) => ({ + ...item, + children: item.children + ? filterNavigationItems(item.children, user) + : undefined, + })) + .filter((item) => { + const canSeeItem = !item.right || hasRight(user, item.right) + const hasVisibleChildren = Boolean(item.children?.length) + + return canSeeItem || hasVisibleChildren + }) +} + +function getVisibleNavigationSections(user: User) { + return navigationSections + .map((section) => ({ + ...section, + items: filterNavigationItems(section.items, user), + })) + .filter((section) => section.items.length > 0) +} + function SidebarContent({ user, setSidebarOpen, @@ -105,9 +176,10 @@ function SidebarContent({ onNavigate?: () => void }) { const teams = user.teams ?? [] - const location = useLocation() + const visibleNavigationSections = getVisibleNavigationSections(user) + const isAdministrationActive = location.pathname === '/administration' || location.pathname.startsWith('/administration/') @@ -116,150 +188,220 @@ function SidebarContent({ location.pathname === '/einstellungen' || location.pathname.startsWith('/einstellungen/') - const isFeedbackActive = - location.pathname === '/feedback' - - const visibleNavigation = navigation.filter((item) => - !item.right || hasRight(user, item.right), - ) + const isFeedbackActive = location.pathname === '/feedback' function handleNavigate() { onNavigate?.() setSidebarOpen(false) } + function renderNavigationItem(item: NavigationItem) { + const active = itemIsActive(item, location.pathname) + const children = item.children ?? [] + const showChildren = children.length > 0 + + return ( +
  • + + + + {showChildren && ( +
      + {children.map((child) => { + const childActive = itemIsActive(child, location.pathname) + + return ( +
    • + + +
    • + ) + })} +
    + )} +
  • + ) + } + return ( <> -
    +
    Your Company +
    +

    TEG

    +

    SE Düsseldorf

    +
  • -
    - Teams -
    +
    +

    + Teams +

    -
    +
  • + +
  • +

    + Hilfe & System +

    + +
      +
    • + + +
    • + + {hasRight(user, 'settings:read') && ( +
    • + + +
    • + )} + + {hasRight(user, 'administration:read') && ( +
    • + +
    • )}
  • - -
  • - - classNames( - isFeedbackActive - ? 'bg-white/5 text-white' - : 'text-gray-400 hover:bg-white/5 hover:text-white', - 'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold', - ) - } - > - - - {hasRight(user, 'settings:read') && ( - - classNames( - isSettingsActive - ? 'bg-white/5 text-white' - : 'text-gray-400 hover:bg-white/5 hover:text-white', - 'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold', - ) - } - > - - )} - - {hasRight(user, 'administration:read') && ( - - classNames( - isAdministrationActive - ? 'bg-white/5 text-white' - : 'text-gray-400 hover:bg-white/5 hover:text-white', - 'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold', - ) - } - > - - )} -
  • @@ -274,10 +416,14 @@ export default function Sidebar({ }: SidebarProps) { return ( <> - +
    @@ -287,14 +433,18 @@ export default function Sidebar({ >
    -
    -
    +
    -
    -
    +
    +
    ) -} \ No newline at end of file +} diff --git a/frontend/src/components/TreeList.tsx b/frontend/src/components/TreeList.tsx index 0fd0f20..5a07fdd 100644 --- a/frontend/src/components/TreeList.tsx +++ b/frontend/src/components/TreeList.tsx @@ -13,6 +13,9 @@ export type TreeListNode = { path: string; /** Optionaler echter Dateisystem-/Ordnername, wenn name ein Windows-Anzeigename ist. */ secondaryName?: string; + /** Markiert einen Ordner, der noch nicht existiert und nur als Vorschau angezeigt wird. */ + preview?: boolean; + previewLabel?: string; children?: TreeListNode[]; disabled?: boolean; }; @@ -30,6 +33,11 @@ type TreeListProps = { onNewFolderValueChange?: (value: string) => void; onCreateFolder?: (folderName: string, folderPath: string) => void; showPathInput?: boolean; + pathInputReadOnly?: boolean; + newFolderReadOnly?: boolean; + previewPath?: string; + previewName?: string; + previewLabel?: string; showCreateButton?: boolean; readOnly?: boolean; disabled?: boolean; @@ -80,6 +88,147 @@ function joinPath(basePath: string, childPath: string) { return `${base}${getPathSeparator(base)}${child}`; } +function normalizeTreePath(value: string) { + return value.trim().replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase(); +} + +function pathBaseName(path: string) { + const cleanPath = path.trim().replace(/[\\/]+$/, ""); + const parts = cleanPath.split(/[\\/]+/).filter(Boolean); + + return parts[parts.length - 1] || cleanPath || path; +} + +function pathParent(path: string) { + const cleanPath = path.trim().replace(/[\\/]+$/, ""); + + if (!cleanPath) { + return ""; + } + + const separatorIndex = Math.max( + cleanPath.lastIndexOf("\\"), + cleanPath.lastIndexOf("/"), + ); + + if (separatorIndex <= 0) { + return ""; + } + + return cleanPath.slice(0, separatorIndex); +} + +function treeContainsPath(nodes: TreeListNode[], path: string): boolean { + const normalizedPath = normalizeTreePath(path); + + return nodes.some((node) => { + if (normalizeTreePath(node.path) === normalizedPath) { + return true; + } + + return treeContainsPath(Array.isArray(node.children) ? node.children : [], path); + }); +} + +function markPreviewNode( + nodes: TreeListNode[], + previewPath: string, + previewLabel?: string, +): TreeListNode[] { + const normalizedPreviewPath = normalizeTreePath(previewPath); + + return nodes.map((node) => ({ + ...node, + preview: + node.preview || normalizeTreePath(node.path) === normalizedPreviewPath, + previewLabel: + normalizeTreePath(node.path) === normalizedPreviewPath + ? previewLabel + : node.previewLabel, + disabled: + node.disabled || normalizeTreePath(node.path) === normalizedPreviewPath, + children: Array.isArray(node.children) + ? markPreviewNode(node.children, previewPath, previewLabel) + : node.children, + })); +} + +function addPreviewNodeToTree( + nodes: TreeListNode[], + previewPath?: string, + previewName?: string, + previewLabel = "Vorschau", +): TreeListNode[] { + const cleanPreviewPath = previewPath?.trim().replace(/[\\/]+$/, "") ?? ""; + + if (!cleanPreviewPath) { + return nodes; + } + + if (treeContainsPath(nodes, cleanPreviewPath)) { + return nodes; + } + + const parent = pathParent(cleanPreviewPath); + const normalizedParent = normalizeTreePath(parent); + const previewNode: TreeListNode = { + id: `preview:${cleanPreviewPath}`, + name: previewName?.trim() || pathBaseName(cleanPreviewPath), + path: cleanPreviewPath, + preview: true, + previewLabel, + disabled: true, + children: [], + }; + + if (!parent) { + return [...nodes, previewNode]; + } + + let inserted = false; + + function insertInto(currentNodes: TreeListNode[]): TreeListNode[] { + return currentNodes.map((node) => { + if (normalizeTreePath(node.path) === normalizedParent) { + inserted = true; + + return { + ...node, + children: [ + ...(Array.isArray(node.children) ? node.children : []), + previewNode, + ], + }; + } + + if (!Array.isArray(node.children) || node.children.length === 0) { + return node; + } + + return { + ...node, + children: insertInto(node.children), + }; + }); + } + + const nextNodes = insertInto(nodes); + + if (inserted) { + return nextNodes; + } + + if (nodes.length === 0) { + return markPreviewNode( + buildFallbackNodes(cleanPreviewPath), + cleanPreviewPath, + previewLabel, + ); + } + + return nextNodes; +} + function buildFallbackNodes(path: string): TreeListNode[] { const parts = splitPath(path); @@ -139,6 +288,7 @@ function renderTreeNodes({ const active = node.path === selectedPath; const selectable = !disabled && !readOnly && !node.disabled; const children = Array.isArray(node.children) ? node.children : []; + const Icon = node.preview ? FolderPlusIcon : FolderIcon; return (
  • @@ -151,12 +301,14 @@ function renderTreeNodes({ active ? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300" : "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5", + node.preview && + "border border-dashed border-indigo-200 bg-indigo-50/70 text-indigo-700 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-200", !selectable && "cursor-default hover:bg-transparent dark:hover:bg-transparent", )} style={{ paddingLeft: `${0.5 + level * 1.25}rem` }} > -
  • + ))} +
    -
    - - )) + + {/* Right: Storage details */} +
    + {selectedStorage === null ? ( +
    +

    + Kein Speicher ausgewählt. +

    +
    + ) : ( +
    + {/* Storage header */} +
    +
    +
    +

    + {formatStorageTitle(selectedStorage)} +

    + {selectedStorage.isDefault && ( + + Standardspeicher + + )} +
    +
    + {selectedStorage.id} + {selectedStorage.diskPath || "—"} +
    +
    + {canWrite && ( +
    + + {!selectedStorage.isDefault && ( + + )} +
    + )} +
    + + {/* Storage stats */} +
    +
    +

    + Belegt +

    +

    + {formatBytes(selectedStorage.storageInformation?.usedSpace)} +

    +
    +
    +

    + Gesperrt +

    +

    + {formatBytes(selectedStorage.storageInformation?.lockedUsedSpace)} +

    +
    +
    +

    + Verfügbar +

    +

    + {selectedStorage.storageInformation + ? formatAvailableLabel(selectedStorage.storageInformation.isAvailable) + : "—"} +

    +
    +
    +

    + Stand +

    +

    + {formatStorageDate(selectedStorage.storageInformation?.lastUpdated)} +

    +
    +
    + + {/* Archive storages table */} +
    +
    + + + + + + + + {canWrite && ( + + )} + + + + {(Array.isArray(selectedStorage.archiveStorages) + ? selectedStorage.archiveStorages.length + : 0) === 0 ? ( + + + + ) : ( + (Array.isArray(selectedStorage.archiveStorages) + ? selectedStorage.archiveStorages + : [] + ).map((archiveStorage) => ( + + + + + + {canWrite && ( + + )} + + )) + )} + +
    + Name + + Pfad + + Max. Größe + + Retention + + Aktion +
    + Für diesen Basis-Speicher sind noch keine Archivspeicher vorhanden. +
    + {archiveStorage.name || archiveStorage.displayName || "—"} + + {archiveStorage.diskPath || "—"} + + {formatMegabytes(archiveStorage.maxSize)} + + {formatRetention(archiveStorage.retainMinutes)} + +
    + + +
    +
    +
    +
    +
    + )} +
    + )}
    @@ -1719,7 +2470,7 @@ export default function MilestoneStoragePage({ @@ -1733,39 +2484,8 @@ export default function MilestoneStoragePage({
    {activeCreateStorageTab === "basis" && (
    -
    -

    - Neuer Basis-Speicher -

    -

    - Das ist nicht „Local default“, sondern ein neuer - Recording-Storage auf dem Recording Server. -

    -
    -
    -
    - -
    - - setCreateStorageForm((current) => ({ - ...current, - displayName: event.target.value, - })) - } - className={inputClassName()} - required - autoFocus - /> -
    -
    - -
    +
    @@ -1774,32 +2494,11 @@ export default function MilestoneStoragePage({ type="text" value={createStorageForm.name} onChange={(event) => - setCreateStorageForm((current) => ({ - ...current, - name: event.target.value, - })) + updateCreateStorageName(event.target.value) } className={inputClassName()} - placeholder="Optional, sonst Anzeigename" - /> -
    -
    - -
    - -
    -