diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index 7366693..dd30047 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -50,7 +50,6 @@ type updateMilestoneSettingsRequest struct { ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` - ServerFoldersAgentToken string `json:"serverFoldersAgentToken"` } type milestoneCredentials struct { @@ -110,7 +109,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re input.ServerFoldersAgentScheme, input.ServerFoldersAgentPort, ) - serverFoldersAgentToken := strings.TrimSpace(input.ServerFoldersAgentToken) if host == "" { writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") @@ -169,10 +167,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re NULL, $6, $7, - CASE - WHEN $8 = '' THEN NULL - ELSE pgp_sym_encrypt($8, $4) - END + NULL ) ON CONFLICT (id) DO UPDATE SET @@ -186,10 +181,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_updated_at = NULL, server_folders_agent_scheme = EXCLUDED.server_folders_agent_scheme, server_folders_agent_port = EXCLUDED.server_folders_agent_port, - server_folders_agent_token_encrypted = CASE - WHEN $8 = '' THEN milestone_settings.server_folders_agent_token_encrypted - ELSE EXCLUDED.server_folders_agent_token_encrypted - END, updated_at = now() `, host, @@ -199,7 +190,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re skipTLSVerify, serverFoldersAgentScheme, serverFoldersAgentPort, - serverFoldersAgentToken, ) } else { _, err = s.db.Exec( @@ -216,10 +206,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_updated_at = NULL, server_folders_agent_scheme = $4, server_folders_agent_port = $5, - server_folders_agent_token_encrypted = CASE - WHEN $6 = '' THEN server_folders_agent_token_encrypted - ELSE pgp_sym_encrypt($6, $7) - END, updated_at = now() WHERE id = 1 `, @@ -228,8 +214,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re skipTLSVerify, serverFoldersAgentScheme, serverFoldersAgentPort, - serverFoldersAgentToken, - encryptionKey, ) } @@ -238,6 +222,8 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re return } + rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context()) + if r.URL.Query().Get("sync") == "false" { settings, err := s.getMilestoneSettings(r.Context()) if err != nil { @@ -245,9 +231,15 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re return } - writeJSON(w, http.StatusOK, map[string]any{ + response := map[string]any{ "settings": settings, - }) + } + + if rotationWarning != "" { + response["warning"] = rotationWarning + } + + writeJSON(w, http.StatusOK, response) return } @@ -277,8 +269,16 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re "settings": settings, } + warnings := make([]string, 0, 2) + if rotationWarning != "" { + warnings = append(warnings, rotationWarning) + } if syncWarning != "" { - response["warning"] = syncWarning + warnings = append(warnings, syncWarning) + } + + if len(warnings) > 0 { + response["warning"] = strings.Join(warnings, " ") } else if syncResult.TokenReceived { response["sync"] = syncResult } @@ -286,6 +286,51 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re writeJSON(w, http.StatusOK, response) } +// rotateServerFoldersAgentTokenAfterSave erzeugt einen neuen Agent-Token, schiebt +// ihn an den Agent und persistiert ihn anschließend verschlüsselt. Schlägt einer +// der Schritte fehl, bleibt der bisherige Token unverändert und es wird eine +// Warnung zurückgegeben (das Speichern der übrigen Einstellungen bleibt gültig). +func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) string { + newToken, err := generateServerFoldersAgentToken() + if err != nil { + log.Printf("server-folders agent token generation failed: %v", err) + + return "Der Server-Folders-Agent-Token konnte nicht erzeugt werden." + } + + config, err := s.getServerFoldersAgentConfig(ctx) + if err != nil { + log.Printf("server-folders agent config load failed: %v", err) + + return "Der Server-Folders-Agent-Token konnte nicht ausgetauscht werden." + } + + if err := s.pushServerFoldersAgentToken(ctx, config, newToken); err != nil { + log.Printf("server-folders agent token rotation failed: %v", err) + + return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht." + } + + if _, err := s.db.Exec( + ctx, + ` + UPDATE milestone_settings + SET + server_folders_agent_token_encrypted = pgp_sym_encrypt($1, $2), + updated_at = now() + WHERE id = 1 + `, + newToken, + s.milestoneEncryptionKey(), + ); err != nil { + log.Printf("server-folders agent token persist failed: %v", err) + + return "Der neue Server-Folders-Agent-Token konnte nicht gespeichert werden." + } + + return "" +} + func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Request) { credentials, err := s.getMilestoneCredentials(r.Context()) if errors.Is(err, pgx.ErrNoRows) { diff --git a/backend/external/README.md b/backend/external/README.md index a166dba..490c90c 100644 --- a/backend/external/README.md +++ b/backend/external/README.md @@ -25,13 +25,33 @@ Der Proxy entfernt diese Parameter, bevor er an den Agent weiterleitet. Der Token wird als Header weitergegeben: ```http -X-Server-Folders-Token: CHANGE-ME +X-Server-Folders-Token: ``` +## Token-Handling (automatisch) + +Der Agent wird **ohne** Token gestartet. Das Backend erzeugt beim Speichern der +Milestone-Einstellungen automatisch einen zufälligen Token und überträgt ihn per +`PUT /agent-token` an den Agent. Der Agent persistiert den Token in einer Datei +(Standard: neben der `.exe`) und nutzt ihn danach für alle weiteren Anfragen – +auch nach einem Neustart. + +```http +PUT /agent-token +X-Server-Folders-Token: # nur nötig, wenn bereits ein Token gesetzt ist +Content-Type: application/json + +{ "token": "" } +``` + +Solange noch kein Token gesetzt ist, akzeptiert der Agent `PUT /agent-token` auch +ohne Header (Bootstrap). Betreibe den Agent daher in einem vertrauenswürdigen +Netzwerk und speichere die Einstellungen nach dem Start zeitnah. + ## Agent starten ```powershell -.\server-folders-agent.exe -port 8099 -roots "Milestone D=D:\Milestone;Milestone E=E:\Milestone" -token "CHANGE-ME" +.\server-folders-agent.exe -port 8099 -roots "Milestone D=D:\Milestone;Milestone E=E:\Milestone" ``` Alternativ weiter per ENV: @@ -39,7 +59,13 @@ Alternativ weiter per ENV: ```powershell $env:LISTEN_ADDR=":8099" $env:SERVER_FOLDER_ROOTS="Milestone D=D:\Milestone;Milestone E=E:\Milestone" -$env:SERVER_FOLDER_AGENT_TOKEN="CHANGE-ME" .\server-folders-agent.exe ``` + +Optionale Parameter: + +- `-token` / `SERVER_FOLDER_AGENT_TOKEN`: nur als Start-Seed, falls noch keine + Token-Datei existiert (in der Regel nicht nötig). +- `-token-file` / `SERVER_FOLDER_AGENT_TOKEN_FILE`: Pfad der Token-Datei + (Standard: `server-folders-agent.token` neben der `.exe`). diff --git a/backend/external/agent_build.bat b/backend/external/agent_build.bat index 53fedcd..18edb1f 100644 --- a/backend/external/agent_build.bat +++ b/backend/external/agent_build.bat @@ -26,8 +26,8 @@ if errorlevel 1 ( echo. echo Build erfolgreich: server-folders-agent.exe echo. -echo Beispiel: -echo server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "dein-token" +echo Beispiel ^(ohne Token - der Token wird vom Backend gesetzt^): +echo server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" echo. pause diff --git a/backend/external/agent_main.go b/backend/external/agent_main.go index d81b6e8..5291202 100644 --- a/backend/external/agent_main.go +++ b/backend/external/agent_main.go @@ -8,20 +8,31 @@ // POST /server-folders // PATCH /server-folders // DELETE /server-folders +// PUT /agent-token (Token zur Laufzeit setzen / rotieren) // -// 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" +// Start per Parameter (ohne Token): +// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" // // Alternativ per ENV: // LISTEN_ADDR=:8099 // SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage // SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive -// SERVER_FOLDER_AGENT_TOKEN=optional-geheimer-token // -// Wenn SERVER_FOLDER_AGENT_TOKEN oder -token gesetzt ist, muss der Client senden: -// X-Server-Folders-Token: -// oder: -// Authorization: Bearer +// Token-Handling: +// Der Agent wird ohne Token gestartet. Das Backend erzeugt beim Speichern der +// Milestone-Einstellungen automatisch einen Token und schiebt ihn per +// PUT /agent-token an den Agent. Der Token wird in einer Datei persistiert +// (Standard: neben der .exe, siehe -token-file / SERVER_FOLDER_AGENT_TOKEN_FILE) +// und überlebt damit einen Neustart. +// +// Solange noch kein Token gesetzt ist, ist PUT /agent-token offen (Bootstrap). +// Sobald ein Token gesetzt ist, muss der Client ihn senden: +// X-Server-Folders-Token: +// oder: +// Authorization: Bearer +// +// -token / SERVER_FOLDER_AGENT_TOKEN sind optional und dienen nur als +// Start-Seed, falls noch keine Token-Datei existiert. package main @@ -38,17 +49,20 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" ) const ( - defaultListenAddr = ":8099" + defaultListenAddr = ":8099" + defaultTokenFileName = "server-folders-agent.token" listenAddrEnv = "LISTEN_ADDR" serverStorageRootsEnv = "SERVER_STORAGE_ROOTS" serverArchiveRootsEnv = "SERVER_ARCHIVE_ROOTS" legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS" agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN" + agentTokenFileEnv = "SERVER_FOLDER_AGENT_TOKEN_FILE" ) type serverFolderRootType string @@ -63,6 +77,7 @@ type appConfig struct { StorageRoots string ArchiveRoots string Token string + TokenFile string } func (config appConfig) rootsForType(rootType serverFolderRootType) string { @@ -106,21 +121,26 @@ type renameServerFolderRequest struct { func main() { config := readConfigFromFlagsAndEnv() + store := newTokenStore(config.TokenFile, config.Token) + mux := http.NewServeMux() mux.HandleFunc("GET /health", handleHealth) - mux.HandleFunc("GET /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { handleListServerFolders(w, r, config) })) - mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("POST /server-folders", requireAgentToken(store, 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) { + mux.HandleFunc("PATCH /server-folders", requireAgentToken(store, 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) { + mux.HandleFunc("DELETE /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { handleDeleteServerFolder(w, r, config) })) + mux.HandleFunc("PUT /agent-token", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { + handleSetAgentToken(w, r, store) + })) server := &http.Server{ Addr: config.ListenAddr, @@ -131,7 +151,8 @@ func main() { log.Printf("server-folders-agent listening on %s", config.ListenAddr) log.Printf("storage roots: %q", config.StorageRoots) log.Printf("archive roots: %q", config.ArchiveRoots) - log.Printf("token enabled: %t", config.Token != "") + log.Printf("token file: %s", config.TokenFile) + log.Printf("token enabled: %t", store.get() != "") if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server failed: %v", err) @@ -144,7 +165,8 @@ 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", "", "Optional access token for X-Server-Folders-Token") + tokenFlag := flag.String("token", "", "Optional start seed token (used only if no token file exists yet)") + tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token (default: next to the executable)") flag.Parse() listenAddr := strings.TrimSpace(*addrFlag) @@ -188,14 +210,31 @@ func readConfigFromFlagsAndEnv() appConfig { token = strings.TrimSpace(os.Getenv(agentTokenEnv)) } + tokenFile := strings.TrimSpace(*tokenFileFlag) + if tokenFile == "" { + tokenFile = strings.TrimSpace(os.Getenv(agentTokenFileEnv)) + } + if tokenFile == "" { + tokenFile = defaultTokenFilePath() + } + return appConfig{ ListenAddr: listenAddr, StorageRoots: storageRoots, ArchiveRoots: archiveRoots, Token: token, + TokenFile: tokenFile, } } +func defaultTokenFilePath() string { + if exePath, err := os.Executable(); err == nil { + return filepath.Join(filepath.Dir(exePath), defaultTokenFileName) + } + + return defaultTokenFileName +} + func handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "ok": true, @@ -771,10 +810,107 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot) return false } -func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc { - requiredToken = strings.TrimSpace(requiredToken) +// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in einer +// Datei persistiert, damit er einen Neustart des Agents übersteht. +type tokenStore struct { + mu sync.RWMutex + token string + filePath string +} +func newTokenStore(filePath string, seed string) *tokenStore { + store := &tokenStore{filePath: strings.TrimSpace(filePath)} + + if persisted := store.loadFromFile(); persisted != "" { + store.token = persisted + return store + } + + if seed = strings.TrimSpace(seed); seed != "" { + store.token = seed + + if err := store.persist(seed); err != nil { + log.Printf("token seed konnte nicht persistiert werden: %v", err) + } + } + + return store +} + +func (t *tokenStore) get() string { + t.mu.RLock() + defer t.mu.RUnlock() + + return t.token +} + +func (t *tokenStore) set(token string) error { + token = strings.TrimSpace(token) + + t.mu.Lock() + defer t.mu.Unlock() + + if err := t.persist(token); err != nil { + return err + } + + t.token = token + + return nil +} + +func (t *tokenStore) loadFromFile() string { + if t.filePath == "" { + return "" + } + + data, err := os.ReadFile(t.filePath) + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) +} + +func (t *tokenStore) persist(token string) error { + if t.filePath == "" { + return nil + } + + return os.WriteFile(t.filePath, []byte(token), 0600) +} + +type setAgentTokenRequest struct { + Token string `json:"token"` +} + +func handleSetAgentToken(w http.ResponseWriter, r *http.Request, store *tokenStore) { + var input setAgentTokenRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + nextToken := strings.TrimSpace(input.Token) + if nextToken == "" { + writeError(w, http.StatusBadRequest, "Token fehlt") + return + } + + if err := store.set(nextToken); err != nil { + log.Printf("Token konnte nicht gespeichert werden: %v", err) + writeError(w, http.StatusInternalServerError, "Token konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func requireAgentToken(store *tokenStore, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + requiredToken := store.get() if requiredToken == "" { next(w, r) return diff --git a/backend/go.mod b/backend/go.mod index 5135773..e5f8878 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,6 +10,8 @@ require ( golang.org/x/crypto v0.51.0 ) +require github.com/coder/websocket v1.8.14 // indirect + require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/backend/go.sum b/backend/go.sum index 94fa715..97d51c8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/backend/milestone_cameras.go b/backend/milestone_cameras.go index f32dbae..57ec80f 100644 --- a/backend/milestone_cameras.go +++ b/backend/milestone_cameras.go @@ -403,20 +403,69 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS } 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"` + 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"` + HardwareDisplayName string `json:"hardwareDisplayName"` +} + +func (s *Server) getMilestoneHardwareDisplayNameMap( + ctx context.Context, +) (map[string]string, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + COALESCE(milestone_hardware_id, ''), + COALESCE(milestone_display_name, inventory_number, '') + FROM devices + WHERE COALESCE(milestone_hardware_id, '') <> '' + `, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string]string{} + + for rows.Next() { + var hardwareID string + var displayName string + + if err := rows.Scan(&hardwareID, &displayName); err != nil { + return nil, err + } + + hardwareID = strings.TrimSpace(hardwareID) + displayName = strings.TrimSpace(displayName) + + if hardwareID == "" || displayName == "" { + continue + } + + if _, exists := out[hardwareID]; !exists { + out[hardwareID] = displayName + } + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return out, nil } func getMilestoneDeviceList( ctx context.Context, config milestoneRuntimeConfig, collection string, + hardwareDisplayNames map[string]string, ) ([]milestoneCameraListItem, error) { requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/" + collection + "?disabled" @@ -507,16 +556,18 @@ func getMilestoneDeviceList( } hardwareID := milestoneNamedDeviceParentID(item) + hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID]) items = append(items, milestoneCameraListItem{ - ID: id, - Name: name, - DisplayName: displayName, - Type: deviceType, - Enabled: enabled, - RecordingEnabled: recordingEnabled, - RecordingStorageID: recordingStorageID, - HardwareID: hardwareID, + ID: id, + Name: name, + DisplayName: displayName, + Type: deviceType, + Enabled: enabled, + RecordingEnabled: recordingEnabled, + RecordingStorageID: recordingStorageID, + HardwareID: hardwareID, + HardwareDisplayName: hardwareDisplayName, }) } @@ -530,14 +581,20 @@ func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Reque return } - cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras") + hardwareDisplayNames, err := s.getMilestoneHardwareDisplayNameMap(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Hardwarenamen konnten nicht geladen werden") + return + } + + cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames) 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") + microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames) if err != nil { log.Printf("Milestone microphones list failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden") diff --git a/backend/server_folders_proxy.go b/backend/server_folders_proxy.go index 5f237d2..702c415 100644 --- a/backend/server_folders_proxy.go +++ b/backend/server_folders_proxy.go @@ -5,6 +5,8 @@ package main import ( "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "io" @@ -315,6 +317,10 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders } func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (string, error) { + return serverFoldersAgentURLForPath(config, "/server-folders", rawQuery) +} + +func serverFoldersAgentURLForPath(config serverFoldersAgentConfig, path string, rawQuery string) (string, error) { if strings.TrimSpace(config.MilestoneHost) == "" { return "", fmt.Errorf("Milestone Host fehlt") } @@ -332,13 +338,81 @@ func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (st agentURL := url.URL{ Scheme: config.Scheme, Host: parsedMilestoneURL.Hostname() + ":" + config.Port, - Path: "/server-folders", + Path: path, RawQuery: rawQuery, } return agentURL.String(), nil } +// generateServerFoldersAgentToken erzeugt einen kryptografisch zufälligen Token. +func generateServerFoldersAgentToken() (string, error) { + randomBytes := make([]byte, 32) + + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(randomBytes), nil +} + +// pushServerFoldersAgentToken setzt bzw. rotiert den Token am Agent. Die +// Authentifizierung erfolgt mit dem aktuell hinterlegten Token (config.Token). +// Solange am Agent noch kein Token gesetzt ist, akzeptiert dieser den Request +// auch ohne gültigen Authentifizierungs-Header (Bootstrap). +func (s *Server) pushServerFoldersAgentToken( + ctx context.Context, + config serverFoldersAgentConfig, + newToken string, +) error { + targetURL, err := serverFoldersAgentURLForPath(config, "/agent-token", "") + if err != nil { + return err + } + + body, err := json.Marshal(map[string]string{ + "token": newToken, + }) + if err != nil { + return err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPut, targetURL, bytes.NewReader(body)) + if err != nil { + return err + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + + if config.Token != "" { + request.Header.Set("X-Server-Folders-Token", config.Token) + } + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf( + "server-folders-agent token rotation failed: url=%s status=%d body=%s", + targetURL, + response.StatusCode, + string(responseBody), + ) + } + + return nil +} + func forwardedServerFoldersQuery(values url.Values) string { nextValues := url.Values{} diff --git a/backend/setup/main.go b/backend/setup/main.go index fe92f6a..088ded7 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -228,11 +228,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[], token_version INTEGER NOT NULL DEFAULT 1, + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + // Online-Status: last_seen_at wird im laufenden Betrieb bei Aktivität + // aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb + // der letzten Minuten). + `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ`, + ` CREATE TABLE IF NOT EXISTS user_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -611,8 +618,64 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + // ── Chat ──────────────────────────────────────────────────────────── + // Eine Konversation ist entweder ein Team-Gruppenchat (type='team', + // genau einer pro Team), ein Einzelchat zwischen zwei Personen + // (type='direct') oder ein freier Gruppenchat (type='group'). + ` + CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + type TEXT NOT NULL DEFAULT 'direct' + CHECK (type IN ('team', 'direct', 'group')), + name TEXT NOT NULL DEFAULT '', + + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + + -- Stabiler Schlüssel für Einzelchats (sortierte User-IDs), + -- damit pro Personenpaar nur eine Konversation existiert. + direct_key TEXT NOT NULL DEFAULT '', + + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + + last_message_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS conversation_members ( + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + last_read_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + PRIMARY KEY (conversation_id, user_id) + ) + `, + + ` + CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id UUID REFERENCES users(id) ON DELETE SET NULL, + + body TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ + ) + `, + `CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`, `CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`, + `CREATE INDEX IF NOT EXISTS idx_users_last_seen_at ON users(last_seen_at)`, `CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON user_sessions(user_id, revoked_at)`, @@ -684,6 +747,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_user_logs_user_created_at ON user_logs(user_id, created_at DESC, id DESC)`, `CREATE INDEX IF NOT EXISTS idx_camera_firmware_cache_checked_at ON camera_firmware_cache(checked_at)`, + + // Genau eine Konversation pro Team bzw. pro Einzelchat-Paar. + `CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_team_id_unique ON conversations(team_id) WHERE team_id IS NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key_unique ON conversations(direct_key) WHERE direct_key <> ''`, + `CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`, + `CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`, + + `CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id)`, + + `CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_at ON messages(conversation_id, created_at DESC, id DESC)`, + `CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`, } for _, query := range queries { diff --git a/frontend/src/components/AddressCombobox.tsx b/frontend/src/components/AddressCombobox.tsx index 9fb31d3..0b7a082 100644 --- a/frontend/src/components/AddressCombobox.tsx +++ b/frontend/src/components/AddressCombobox.tsx @@ -46,10 +46,6 @@ type AddressComboboxProps = { const inputClassName = 'block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' -function classNames(...classes: Array) { - return classes.filter(Boolean).join(' ') -} - function toNumber(value: unknown) { if (typeof value === 'number') { return value @@ -469,12 +465,6 @@ export default function AddressCombobox({ className="cursor-default px-3 py-2 text-gray-900 select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-white dark:data-focus:bg-indigo-500" > {suggestion.label} - - {suggestion.type && ( - - {suggestion.type} - - )} ))} diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 52b2944..1d6b891 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -15,6 +15,11 @@ export type AvatarProps = Omit, 'size'> & { shape?: AvatarShape status?: AvatarStatus statusPosition?: AvatarStatusPosition + /** + * Online-Status der Person. true = online (grün), false = offline (grau). + * Wird ignoriert, wenn `status` explizit gesetzt ist. + */ + online?: boolean showPlaceholderIcon?: boolean wrapperClassName?: string } @@ -99,6 +104,7 @@ export default function Avatar({ shape = 'circle', status, statusPosition = 'bottom', + online, showPlaceholderIcon = true, wrapperClassName, className, @@ -107,6 +113,12 @@ export default function Avatar({ const roundedClassName = shape === 'circle' ? 'rounded-full' : 'rounded-md' const avatarInitials = getInitials(name, initials) + // `status` hat Vorrang; ansonsten leitet sich der Punkt aus `online` ab. + const effectiveStatus: AvatarStatus | undefined = + status ?? (online === undefined ? undefined : online ? 'green' : 'gray') + const statusLabel = + online === undefined ? undefined : online ? 'Online' : 'Offline' + const avatar = src ? ( ) - if (!status) { + if (!effectiveStatus) { return avatar } @@ -163,11 +175,14 @@ export default function Avatar({ {avatar} ({ + id: root.path, + name: root.label || root.path, + path: root.path, + children: isPathInsideServerFolderRoot(response.path, root.path) + ? buildServerFolderBranch(root.path, response.path, folders) + : [], + })) +} + +function serverFolderPathKey(path: string) { + return normalizeServerFolderPath(path) +} + +function splitServerFolderRelativePath(rootPath: string, currentPath: string) { + const cleanRootPath = rootPath.trim().replace(/[\\/]+$/, '') + const cleanCurrentPath = currentPath.trim().replace(/[\\/]+$/, '') + + if ( + !cleanRootPath || + !cleanCurrentPath || + normalizeServerFolderPath(cleanRootPath) === + normalizeServerFolderPath(cleanCurrentPath) + ) { + return [] + } + + if (!isPathInsideServerFolderRoot(cleanCurrentPath, cleanRootPath)) { + return [] + } + + let relativePath = cleanCurrentPath + + if (relativePath.toLowerCase().startsWith(cleanRootPath.toLowerCase())) { + relativePath = relativePath.slice(cleanRootPath.length) + } + + return relativePath.replace(/^[\\/]+/, '').split(/[\\/]+/).filter(Boolean) +} + +function ensureServerFolderRootNodes( + currentNodes: TreeListNode[], + roots: ServerFolderRoot[], +): TreeListNode[] { + const currentByPath = new Map( + currentNodes.map((node) => [serverFolderPathKey(node.path), node]), + ) + + return roots.map((root) => { + const currentNode = currentByPath.get(serverFolderPathKey(root.path)) + + if (currentNode) { + return { + ...currentNode, + id: root.path, + name: root.label || currentNode.name || root.path, + path: root.path, + children: Array.isArray(currentNode.children) ? currentNode.children : [], + } + } + + return { + id: root.path, + name: root.label || root.path, + path: root.path, + children: [], + } + }) +} + +function replaceServerFolderChildrenAtSegments( + node: TreeListNode, + runningPath: string, + segments: string[], + children: TreeListNode[], +): TreeListNode { + if (segments.length === 0) { + return { + ...node, + children, + } + } + + const [segment, ...remainingSegments] = segments + const childPath = joinServerFolderPath(runningPath, segment) + const childKey = serverFolderPathKey(childPath) + const existingChildren = Array.isArray(node.children) ? node.children : [] + const existingChild = existingChildren.find( + (child) => serverFolderPathKey(child.path) === childKey, + ) + + const nextChild = replaceServerFolderChildrenAtSegments( + existingChild ?? { + id: childPath, + name: segment, + path: childPath, + children: [], + }, + childPath, + remainingSegments, + children, + ) + + const nextChildren = existingChild + ? existingChildren.map((child) => + serverFolderPathKey(child.path) === childKey ? nextChild : child, + ) + : [...existingChildren, nextChild] + + return { + ...node, + children: nextChildren, + } +} + +function mergeServerFolderTree( + currentNodes: TreeListNode[], + response: ServerFoldersResponse, +): TreeListNode[] { + const roots = Array.isArray(response.roots) ? response.roots : [] + const folders = Array.isArray(response.folders) ? response.folders : [] + const nextChildren = folders.map(serverFolderTreeNodeFromItem) + + if (roots.length === 0) { + return nextChildren + } + + if (!Array.isArray(currentNodes) || currentNodes.length === 0) { + return buildServerFolderTree(response) + } + + const targetPath = response.path + const matchingRoot = roots.find((root) => + isPathInsideServerFolderRoot(targetPath, root.path), + ) + + if (!matchingRoot) { + return buildServerFolderTree(response) + } + + return ensureServerFolderRootNodes(currentNodes, roots).map((rootNode) => { + if ( + serverFolderPathKey(rootNode.path) !== + serverFolderPathKey(matchingRoot.path) + ) { + return rootNode + } + + const segments = splitServerFolderRelativePath(matchingRoot.path, targetPath) + + return replaceServerFolderChildrenAtSegments( + rootNode, + matchingRoot.path, + segments, + nextChildren, + ) + }) +} + +function treeContainsPath(nodes: TreeListNode[], path: string): boolean { + const normalizedPath = normalizeServerFolderPath(path) + + if (!normalizedPath) { + return false + } + + return nodes.some((node) => { + if (normalizeServerFolderPath(node.path) === normalizedPath) { + return true + } + + return treeContainsPath( + Array.isArray(node.children) ? node.children : [], + path, + ) + }) +} + +// Reichert Ordner-Knoten mit dem Anzeigenamen an: trifft der Ordnername (= ID) +// auf einen bekannten Eintrag, wird dessen Anzeigename als Haupttext gesetzt und +// die ID grau als secondaryName beibehalten. +function decorateServerFolderNodesWithDisplayNames( + nodes: TreeListNode[], + displayNameById: Map, +): TreeListNode[] { + return nodes.map((node) => { + const baseName = serverFolderBaseName(node.path) + const resolvedDisplayName = displayNameById.get(baseName.trim().toLowerCase()) + + const decoratedNode = + resolvedDisplayName && + normalizeServerFolderPath(resolvedDisplayName) !== + normalizeServerFolderPath(baseName) + ? { ...node, name: resolvedDisplayName, secondaryName: baseName } + : node + + if (!Array.isArray(node.children)) { + return decoratedNode + } + + return { + ...decoratedNode, + children: decorateServerFolderNodesWithDisplayNames( + node.children, + displayNameById, + ), + } + }) +} + +async function readApiError(response: Response, fallback: string) { + const data = await response.json().catch(() => null) + + return typeof data?.error === 'string' ? data.error : fallback +} + +type ServerFolderPickerProps = { + rootType: ServerFolderRootType + label: string + description?: string + pathPlaceholder?: string + newFolderPlaceholder?: string + /** Aktuell gewählter Root-Pfad (ohne angehängten Namen). */ + rootPath: string + /** Name, der an den Root-Pfad angehängt wird (Vorschau + Zielpfad). */ + appendName: string + onRootPathChange: (rootPath: string) => void + previewLabel?: string + disabled?: boolean + /** Steuert, ob der Browser beim Einblenden automatisch lädt. */ + active?: boolean + displayNameById?: Map +} + +export default function ServerFolderPicker({ + rootType, + label, + description, + pathPlaceholder, + newFolderPlaceholder, + rootPath, + appendName, + onRootPathChange, + previewLabel, + disabled = false, + active = true, + displayNameById, +}: ServerFolderPickerProps) { + const [nodes, setNodes] = useState([]) + const [roots, setRoots] = useState([]) + const [currentPath, setCurrentPath] = useState('') + const [error, setError] = useState(null) + const [isCreating, setIsCreating] = useState(false) + + const rootPathRef = useRef(rootPath) + rootPathRef.current = rootPath + const onRootPathChangeRef = useRef(onRootPathChange) + onRootPathChangeRef.current = onRootPathChange + + async function loadFolders(path?: string, initial = false) { + try { + const query = new URLSearchParams() + query.set('rootType', rootType) + + const requestedPath = path?.trim() + if (requestedPath) { + query.set('path', requestedPath) + } + + const response = await fetch( + `${API_URL}/admin/server-folders?${query.toString()}`, + { credentials: 'include' }, + ) + + if (!response.ok) { + setError( + await readApiError(response, 'Server-Ordner konnten nicht geladen werden.'), + ) + return + } + + const data = (await response.json()) as ServerFoldersResponse + const nextPath = data.path || requestedPath || '' + + setNodes((current) => mergeServerFolderTree(current, data)) + setRoots(Array.isArray(data.roots) ? data.roots : []) + setCurrentPath(nextPath) + setError(null) + + if (initial && !rootPathRef.current.trim() && nextPath) { + onRootPathChangeRef.current(nextPath) + } + } catch { + setError('Server-Ordner konnten nicht geladen werden.') + } + } + + useEffect(() => { + if (!active) { + return + } + + void loadFolders(rootPathRef.current || undefined, true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, rootType]) + + function handleSelectedPathChange(selectedPath: string) { + const nextRoot = removeTrailingFolderName(selectedPath, appendName) + onRootPathChange(nextRoot) + + if (treeContainsPath(nodes, selectedPath)) { + void loadFolders(selectedPath) + } + } + + async function handleCreateFolder(_folderName: string, folderPath: string) { + const path = folderPath.trim() + if (!path) { + return + } + + setIsCreating(true) + setError(null) + + try { + const response = await fetch( + `${API_URL}/admin/server-folders?rootType=${rootType}`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }, + ) + + if (!response.ok) { + setError( + await readApiError(response, 'Server-Ordner konnte nicht erstellt werden.'), + ) + return + } + + onRootPathChange(removeTrailingFolderName(path, appendName)) + await loadFolders(path) + } catch { + setError('Server-Ordner konnte nicht erstellt werden.') + } finally { + setIsCreating(false) + } + } + + const trimmedAppendName = appendName.trim() + const selectedPath = trimmedAppendName + ? joinServerFolderPath(rootPath, appendName) + : rootPath + const treeRootPath = rootPath || currentPath || roots[0]?.path || '' + const displayNodes = displayNameById + ? decorateServerFolderNodesWithDisplayNames(nodes, displayNameById) + : nodes + + return ( +
+ {error && ( +
+ {error} +
+ )} + + +
+ ) +} diff --git a/frontend/src/components/TreeList.tsx b/frontend/src/components/TreeList.tsx index 5a07fdd..e21f7b0 100644 --- a/frontend/src/components/TreeList.tsx +++ b/frontend/src/components/TreeList.tsx @@ -454,11 +454,12 @@ export default function TreeList({ disabled={disabled} readOnly={readOnly || pathInputReadOnly} className={classNames( - "block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300", + "block w-full rounded-md px-3 py-1.5 text-sm outline-1 -outline-offset-1 outline-gray-300", "placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600", - "dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500", - (!canEdit || pathInputReadOnly) && - "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400", + "dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500", + !canEdit || pathInputReadOnly + ? "cursor-not-allowed bg-gray-100 text-gray-500 dark:bg-white/10 dark:text-gray-400" + : "bg-white text-gray-900 dark:bg-white/5 dark:text-white", )} /> diff --git a/frontend/src/pages/administration/milestone/Milestone.tsx b/frontend/src/pages/administration/milestone/Milestone.tsx index 98a3503..75aee02 100644 --- a/frontend/src/pages/administration/milestone/Milestone.tsx +++ b/frontend/src/pages/administration/milestone/Milestone.tsx @@ -412,9 +412,6 @@ export default function Milestone() { serverFoldersAgentPort: String( formData.get('serverFoldersAgentPort') ?? '8099', ), - serverFoldersAgentToken: String( - formData.get('serverFoldersAgentToken') ?? '', - ), }), }) @@ -701,34 +698,14 @@ export default function Milestone() {
-
diff --git a/frontend/src/pages/devices/DeviceFormFields.tsx b/frontend/src/pages/devices/DeviceFormFields.tsx index 674bdc5..ea2230c 100644 --- a/frontend/src/pages/devices/DeviceFormFields.tsx +++ b/frontend/src/pages/devices/DeviceFormFields.tsx @@ -7,6 +7,7 @@ import Switch from '../../components/Switch' import type { DeviceModalTabId } from './DeviceModalTabs' import Textarea from '../../components/Textarea' import ChildDeviceCard from './ChildDeviceCard' +import MilestoneChildDeviceCards from './MilestoneChildDeviceCards' import type { Device } from '../../components/types' import { MicrophoneIcon, @@ -77,124 +78,6 @@ export function SectionHeader({ ) } -function MilestoneChildDeviceCards({ - title, - emptyText, - items, - togglingIds, - togglingRecordingIds, - onToggle, - onToggleRecording, - onOpenDetails, - onToggleGroup, - icon: Icon, -}: { - title: string - emptyText: string - items: NonNullable - togglingIds: Set - togglingRecordingIds: Set - onToggle?: ( - childDevice: NonNullable[number], - enabled: boolean, - ) => void | Promise - onToggleRecording?: ( - childDevice: NonNullable[number], - enabled: boolean, - ) => void | Promise - onOpenDetails?: ( - childDevice: NonNullable[number], - ) => void - onToggleGroup?: ( - items: NonNullable, - enabled: boolean, - title: string, - ) => void | Promise - icon: typeof VideoCameraIcon -}) { - const enabledCount = items.filter((item) => item.enabled ?? true).length - const allEnabled = items.length > 0 && enabledCount === items.length - const isBulkBusy = items.some((item) => togglingIds.has(item.id)) - - async function handleToggleAll(nextEnabled: boolean) { - const itemsToUpdate = items.filter( - (item) => (item.enabled ?? true) !== nextEnabled, - ) - - if (itemsToUpdate.length === 0) { - return - } - - if (onToggleGroup) { - await onToggleGroup(itemsToUpdate, nextEnabled, title) - return - } - - if (!onToggle) { - return - } - - for (const item of itemsToUpdate) { - await onToggle(item, nextEnabled) - } - } - - return ( -
-
-
-
- -
- - {items.length} - - - void handleToggleAll(event.target.checked)} - label="" - className="scale-90" - /> -
-
- - {items.length === 0 ? ( -
-

- {emptyText} -

-
- ) : ( -
-
- {items.map((item) => ( - - ))} -
-
- )} -
- ) -} - function formatMacAddress(value: string) { const hexValue = value .replace(/[^0-9a-fA-F]/g, '') @@ -1079,12 +962,21 @@ export default function DeviceFormFields({ emptyText="Keine Kameras für diese Hardware gefunden." items={milestoneCameras} togglingIds={togglingMilestoneChildDeviceIds ?? new Set()} - togglingRecordingIds={togglingMilestoneCameraRecordingIds ?? new Set()} - onToggle={onToggleMilestoneChildDevice} - onToggleRecording={onToggleMilestoneChildDeviceRecording} - onOpenDetails={onOpenMilestoneCameraDetails} onToggleGroup={onToggleMilestoneChildDeviceGroup} + isItemEnabled={(item) => item.enabled ?? true} + getItemId={(item) => item.id} icon={VideoCameraIcon} + renderItem={(item) => ( + + )} /> item.enabled ?? true} + getItemId={(item) => item.id} icon={MicrophoneIcon} + renderItem={(item) => ( + + )} /> )} diff --git a/frontend/src/pages/devices/MilestoneChildDeviceCards.tsx b/frontend/src/pages/devices/MilestoneChildDeviceCards.tsx new file mode 100644 index 0000000..cd98daf --- /dev/null +++ b/frontend/src/pages/devices/MilestoneChildDeviceCards.tsx @@ -0,0 +1,124 @@ +// frontend\src\pages\devices\MilestoneChildDeviceCards.tsx + +import type { ComponentType, ReactNode, SVGProps } from 'react' +import Switch from '../../components/Switch' + +type Props = { + title: string + emptyText: string + items: T[] + count?: number + togglingIds?: Set + onToggleGroup?: ( + items: T[], + enabled: boolean, + title: string, + ) => void | Promise + isItemEnabled?: (item: T) => boolean + getItemId?: (item: T) => string + renderItem: (item: T) => ReactNode + icon: ComponentType> + rightMeta?: ReactNode + className?: string +} + +export default function MilestoneChildDeviceCards({ + title, + emptyText, + items, + count, + togglingIds, + onToggleGroup, + isItemEnabled, + getItemId, + renderItem, + icon: Icon, + rightMeta, + className, +}: Props) { + const resolvedCount = count ?? items.length + + const enabledCount = isItemEnabled + ? items.filter((item) => isItemEnabled(item)).length + : 0 + + const allEnabled = + !!isItemEnabled && items.length > 0 && enabledCount === items.length + + const isBulkBusy = + !!togglingIds && + !!getItemId && + items.some((item) => togglingIds.has(getItemId(item))) + + async function handleToggleAll(nextEnabled: boolean) { + if (!onToggleGroup || !isItemEnabled) { + return + } + + const itemsToUpdate = items.filter( + (item) => isItemEnabled(item) !== nextEnabled, + ) + + if (itemsToUpdate.length === 0) { + return + } + + await onToggleGroup(itemsToUpdate, nextEnabled, title) + } + + return ( +
+
+
+
+ +
+ {rightMeta} + + + {resolvedCount} + + + {onToggleGroup && isItemEnabled ? ( + void handleToggleAll(event.target.checked)} + label="" + className="scale-90" + /> + ) : null} +
+
+ + {items.length === 0 ? ( +
+

+ {emptyText} +

+
+ ) : ( +
+
+ {items.map((item) => renderItem(item))} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/operations/CreateOperationModal.tsx b/frontend/src/pages/operations/CreateOperationModal.tsx index 22e6a90..0011ee6 100644 --- a/frontend/src/pages/operations/CreateOperationModal.tsx +++ b/frontend/src/pages/operations/CreateOperationModal.tsx @@ -2,6 +2,7 @@ import { useEffect, + useMemo, useState, type ComponentType, type FormEvent, @@ -16,9 +17,11 @@ import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, + CircleStackIcon, ClipboardDocumentListIcon, DocumentTextIcon, MapPinIcon, + PlusIcon, UserGroupIcon, WrenchScrewdriverIcon, } from '@heroicons/react/20/solid' @@ -34,6 +37,11 @@ import Textarea from '../../components/Textarea' import Combobox, { type ComboboxItem } from '../../components/Combobox' import MultiCombobox from '../../components/MultiCombobox' import Avatar from '../../components/Avatar' +import LoadingSpinner from '../../components/LoadingSpinner' +import Tabs from '../../components/Tabs' +import ServerFolderPicker, { + joinServerFolderPath, +} from '../../components/ServerFolderPicker' import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -107,13 +115,123 @@ type SetupStepId = | 'responsibilities' | 'notes' | 'equipment' + | 'storage' | 'review' +type ModalMilestoneStorage = { + id: string + displayName: string + name: string + diskPath: string + mounted: boolean + isDefault: boolean +} + +type CreateStorageTabId = 'basis' | 'archive' +type SizeUnit = 'gb' | 'tb' +type RetentionUnit = 'hours' | 'days' + +type CreateStorageFormState = { + name: string + diskPath: string + basisDiskRootPath: string + maxSize: string + maxSizeUnit: SizeUnit + retentionValue: string + retentionUnit: RetentionUnit + archiveName: string + archiveDiskPath: string + archiveDiskRootPath: string + archiveMaxSize: string + archiveMaxSizeUnit: SizeUnit + archiveRetentionValue: string + archiveRetentionUnit: RetentionUnit + archiveUseAutomaticPath: boolean +} + +const emptyCreateStorageForm: CreateStorageFormState = { + name: '', + diskPath: '', + basisDiskRootPath: '', + maxSize: '30', + maxSizeUnit: 'gb', + retentionValue: '1', + retentionUnit: 'hours', + archiveName: '', + archiveDiskPath: '', + archiveDiskRootPath: '', + archiveMaxSize: '3.6', + archiveMaxSizeUnit: 'tb', + archiveRetentionValue: '365', + archiveRetentionUnit: 'days', + archiveUseAutomaticPath: true, +} + +function maxSizeToMegabytes(value: string, unit: SizeUnit) { + const n = Number(value || '0') + if (!Number.isFinite(n) || n < 0) return 0 + return Math.round(n * (unit === 'tb' ? 1024 * 1024 : 1024)) +} + +function retentionToMinutes(value: string, unit: RetentionUnit) { + const n = Number(value || '0') + if (!Number.isFinite(n) || n <= 0) return 0 + return Math.round(n * (unit === 'hours' ? 60 : 24 * 60)) +} + +function formatConvertedInputValue(value: number) { + if (!Number.isFinite(value)) return '' + const digits = Math.abs(value) > 0 && Math.abs(value) < 1 ? 4 : 2 + return String(Number(value.toFixed(digits))) +} + +function convertMaxSizeInputValue(value: string, from: SizeUnit, to: SizeUnit) { + if (from === to || value.trim() === '') return value + const n = Number(value) + if (!Number.isFinite(n)) return value + const gb = from === 'tb' ? n * 1024 : n + return formatConvertedInputValue(to === 'tb' ? gb / 1024 : gb) +} + +function convertRetentionInputValue(value: string, from: RetentionUnit, to: RetentionUnit) { + if (from === to || value.trim() === '') return value + const n = Number(value) + if (!Number.isFinite(n)) return value + const hours = from === 'days' ? n * 24 : n + return formatConvertedInputValue(to === 'days' ? hours / 24 : hours) +} + +function retentionUnitLabel(unit: RetentionUnit, value: string) { + const n = Number(value || '0') + const singular = Number.isFinite(n) && n === 1 + if (unit === 'hours') return singular ? 'Stunde' : 'Stunden' + return singular ? 'Tag' : 'Tage' +} + +function formatMaxSizePreview(value: string, unit: SizeUnit) { + const mb = maxSizeToMegabytes(value, unit) + if (mb <= 0) return '0 MB' + if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} TB` + if (mb >= 1024) return `${(mb / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} GB` + return `${mb.toLocaleString('de-DE')} MB` +} + +function sanitizeWindowsFolderNameInput(value: string) { + return value.replace(/[<>:"/\\|?*]/g, '') +} + +function hasInvalidWindowsFolderNameCharacters(value: string) { + return /[<>:"/\\|?*]/.test(value) +} + type SetupStepIcon = ComponentType> const inputClassName = 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' +const readOnlyInputClassName = + '!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10' + const initialFormValues: OperationFormValues = { operationNumber: '', operationName: '', @@ -174,6 +292,13 @@ const setupSteps: Array<{ icon: WrenchScrewdriverIcon, optional: true, }, + { + id: 'storage', + title: 'Speicher', + description: 'Milestone-Speicher auswählen oder anlegen.', + icon: CircleStackIcon, + optional: true, + }, { id: 'review', title: 'Prüfen', @@ -621,10 +746,32 @@ export default function CreateOperationModal({ const [selectedOperationTeam, setSelectedOperationTeam] = useState([]) const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false) + const [milestoneStorages, setMilestoneStorages] = useState([]) + const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState(null) + const [isLoadingStorages, setIsLoadingStorages] = useState(false) + const [showCreateStorageForm, setShowCreateStorageForm] = useState(false) + const [isCreatingStorage, setIsCreatingStorage] = useState(false) + const [createStorageTab, setCreateStorageTab] = useState('basis') + const [createStorageForm, setCreateStorageForm] = useState(emptyCreateStorageForm) + const [createStorageError, setCreateStorageError] = useState(null) + const activeStepIndex = getStepIndex(activeStep) const isFirstStep = activeStepIndex === 0 const isLastStep = activeStepIndex === setupSteps.length - 1 + const storageDisplayNameById = useMemo(() => { + const byId = new Map() + + for (const storage of milestoneStorages) { + const label = storage.displayName?.trim() || storage.name?.trim() || '' + if (label && storage.id?.trim()) { + byId.set(storage.id.trim().toLowerCase(), label) + } + } + + return byId + }, [milestoneStorages]) + useEffect(() => { if (!open) { return @@ -635,6 +782,12 @@ export default function CreateOperationModal({ setSelectedOperationLeader(null) setSelectedOperationTeam([]) setLocalError(null) + setMilestoneStorages([]) + setSelectedMilestoneStorage(null) + setShowCreateStorageForm(false) + setCreateStorageTab('basis') + setCreateStorageForm(emptyCreateStorageForm) + setCreateStorageError(null) }, [open]) useEffect(() => { @@ -651,6 +804,11 @@ export default function CreateOperationModal({ } }, [open]) + useEffect(() => { + if (!open || activeStep !== 'storage') return + void loadMilestoneStorages() + }, [open, activeStep]) + async function loadOperationAssignees(signal?: AbortSignal) { setIsLoadingOperationAssignees(true) @@ -693,6 +851,119 @@ export default function CreateOperationModal({ } } + async function loadMilestoneStorages(autoSelectName?: string) { + setIsLoadingStorages(true) + try { + const response = await fetch(`${API_URL}/admin/milestone/storages`, { credentials: 'include' }) + if (!response.ok) return + const data = (await response.json()) as { storages?: ModalMilestoneStorage[] } + const storages = data.storages ?? [] + setMilestoneStorages(storages) + if (autoSelectName) { + const match = storages.find((s) => (s.displayName || s.name) === autoSelectName || s.name === autoSelectName) + if (match) setSelectedMilestoneStorage(match) + } + } catch { + // silent + } finally { + setIsLoadingStorages(false) + } + } + + function validateCreateStorageBasisTab() { + if (createStorageForm.name.trim() === '') { + setCreateStorageError('Bitte einen Namen für den Basis-Speicher eintragen.') + return false + } + if (hasInvalidWindowsFolderNameCharacters(createStorageForm.name)) { + setCreateStorageError('Der Name darf diese Zeichen nicht enthalten: < > : " / \\ | ? *') + return false + } + if (createStorageForm.basisDiskRootPath.trim() === '') { + setCreateStorageError('Bitte einen Speicherpfad für den Basis-Speicher auswählen.') + return false + } + if (retentionToMinutes(createStorageForm.retentionValue, createStorageForm.retentionUnit) <= 0) { + setCreateStorageError('Bitte eine gültige Retention für den Basis-Speicher eintragen.') + return false + } + setCreateStorageError(null) + return true + } + + function validateCreateStorageArchiveTab() { + if ( + !createStorageForm.archiveUseAutomaticPath && + createStorageForm.archiveDiskRootPath.trim() === '' + ) { + setCreateStorageError('Bitte einen Archivpfad auswählen.') + return false + } + if (retentionToMinutes(createStorageForm.archiveRetentionValue, createStorageForm.archiveRetentionUnit) <= 0) { + setCreateStorageError('Bitte eine gültige Retention für den Archivspeicher eintragen.') + return false + } + setCreateStorageError(null) + return true + } + + function goToCreateStorageTab(tab: CreateStorageTabId) { + if (tab === 'archive' && !validateCreateStorageBasisTab()) return + setCreateStorageError(null) + setCreateStorageTab(tab) + } + + async function handleCreateStorageWithArchive() { + if (!validateCreateStorageBasisTab() || !validateCreateStorageArchiveTab()) return + + setIsCreatingStorage(true) + setCreateStorageError(null) + + try { + const name = createStorageForm.name.trim() + const archiveName = createStorageForm.archiveUseAutomaticPath + ? name + : (createStorageForm.archiveName.trim() || `${name}_archive`) + const archiveDiskPath = createStorageForm.archiveUseAutomaticPath + ? createStorageForm.diskPath.trim() + : createStorageForm.archiveDiskPath.trim() + + const response = await fetch(`${API_URL}/admin/milestone/storages`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + displayName: name, + name, + diskPath: createStorageForm.diskPath.trim(), + maxSize: maxSizeToMegabytes(createStorageForm.maxSize, createStorageForm.maxSizeUnit), + retainMinutes: retentionToMinutes(createStorageForm.retentionValue, createStorageForm.retentionUnit), + archiveDisplayName: archiveName, + archiveName, + archiveFolderName: archiveName, + archiveDiskPath, + archiveMaxSize: maxSizeToMegabytes(createStorageForm.archiveMaxSize, createStorageForm.archiveMaxSizeUnit), + archiveRetainMinutes: retentionToMinutes(createStorageForm.archiveRetentionValue, createStorageForm.archiveRetentionUnit), + }), + }) + + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { error?: string } | null + setCreateStorageError(data?.error ?? 'Speicher konnte nicht angelegt werden.') + return + } + + setShowCreateStorageForm(false) + setCreateStorageTab('basis') + setCreateStorageForm(emptyCreateStorageForm) + await loadMilestoneStorages(name) + } catch { + setCreateStorageError('Verbindung fehlgeschlagen.') + } finally { + setIsCreatingStorage(false) + } + } + function handleOperationLeaderChange(item: ComboboxItem | null) { setSelectedOperationLeader(item) updateField('operationLeader', item?.name ?? '') @@ -825,6 +1096,7 @@ export default function CreateOperationModal({ +
@@ -851,7 +1123,7 @@ export default function CreateOperationModal({
-
)} + {activeStep === 'storage' && ( +
+ + + {isLoadingStorages && ( +
+ +
+ )} + + {!isLoadingStorages && milestoneStorages.length === 0 && !showCreateStorageForm && ( +
+ +

+ Keine Speicher vorhanden +

+

+ Leg einen neuen Milestone-Speicher an. +

+
+ )} + + {!isLoadingStorages && milestoneStorages.length > 0 && ( +
+ {milestoneStorages.map((storage) => { + const isSelected = selectedMilestoneStorage?.id === storage.id + return ( + + ) + })} +
+ )} + + {!isLoadingStorages && ( + <> + {!showCreateStorageForm ? ( + + ) : ( +
+
+

+ Neuen Speicher anlegen +

+ +
+ + goToCreateStorageTab('basis') }, + { name: 'Archivspeicher', href: '#', current: createStorageTab === 'archive', count: 2, onClick: () => goToCreateStorageTab('archive') }, + ]} + variant="underline" + ariaLabel="Speicherbereiche" + className="border-b border-gray-200 px-4 dark:border-white/10" + /> + + {createStorageError && ( +
+ {createStorageError} +
+ )} + +
+ {createStorageTab === 'basis' && ( + <> +
+ + + setCreateStorageForm((f) => { + const name = sanitizeWindowsFolderNameInput(e.target.value) + return { + ...f, + name, + diskPath: name.trim() + ? joinServerFolderPath(f.basisDiskRootPath, name) + : f.basisDiskRootPath, + } + }) + } + placeholder="z. B. Einsatz-Speicher-2025" + className={inputClassName} + autoFocus + /> +
+ + setCreateStorageForm((f) => ({ + ...f, + basisDiskRootPath: root, + diskPath: f.name.trim() + ? joinServerFolderPath(root, f.name) + : root, + })) + } + /> +
+
+ +
+ setCreateStorageForm((f) => ({ ...f, maxSize: e.target.value }))} className={inputClassName} /> + +
+

{formatMaxSizePreview(createStorageForm.maxSize, createStorageForm.maxSizeUnit)}

+
+
+ +
+ setCreateStorageForm((f) => ({ ...f, retentionValue: e.target.value }))} className={inputClassName} /> + +
+
+
+ + )} + + {createStorageTab === 'archive' && ( + <> + +
+ + + setCreateStorageForm((f) => { + const archiveName = sanitizeWindowsFolderNameInput(e.target.value) + return { + ...f, + archiveName, + archiveDiskPath: f.archiveUseAutomaticPath + ? f.archiveDiskPath + : archiveName.trim() + ? joinServerFolderPath(f.archiveDiskRootPath, archiveName) + : f.archiveDiskRootPath, + } + }) + } + readOnly={createStorageForm.archiveUseAutomaticPath} + className={`${inputClassName} ${createStorageForm.archiveUseAutomaticPath ? readOnlyInputClassName : ''}`} + placeholder={`${createStorageForm.name || 'storage'}_archive`} + /> +
+ {!createStorageForm.archiveUseAutomaticPath && ( + + setCreateStorageForm((f) => ({ + ...f, + archiveDiskRootPath: root, + archiveDiskPath: f.archiveName.trim() + ? joinServerFolderPath(root, f.archiveName) + : root, + })) + } + /> + )} +
+
+ +
+ setCreateStorageForm((f) => ({ ...f, archiveMaxSize: e.target.value }))} className={inputClassName} /> + +
+

{formatMaxSizePreview(createStorageForm.archiveMaxSize, createStorageForm.archiveMaxSizeUnit)}

+
+
+ +
+ setCreateStorageForm((f) => ({ ...f, archiveRetentionValue: e.target.value }))} className={inputClassName} /> + +
+
+
+ + )} + +
+
+ {createStorageTab === 'archive' && ( + + )} +
+
+ + {createStorageTab === 'basis' ? ( + + ) : ( + + )} +
+
+
+
+ )} + + )} +
+ )} + {activeStep === 'review' && (
+
+
+

+ Speicher +

+
+ +
+ +
+
+

diff --git a/frontend/src/pages/operations/OperationModal.tsx b/frontend/src/pages/operations/OperationModal.tsx index 34c5ea1..9b738dc 100644 --- a/frontend/src/pages/operations/OperationModal.tsx +++ b/frontend/src/pages/operations/OperationModal.tsx @@ -34,6 +34,7 @@ import { formatOperationNumberInput } from '../../components/formatter' import NotificationDot from '../../components/NotificationDot' import Combobox, { type ComboboxItem } from '../../components/Combobox' import MultiCombobox from '../../components/MultiCombobox' +import Tabs from '../../components/Tabs' type OperationModalProps = { open: boolean @@ -248,6 +249,99 @@ const operationTabs: Array<{ }, ] +type CreateStorageTabId = 'basis' | 'archive' +type SizeUnit = 'gb' | 'tb' +type RetentionUnit = 'hours' | 'days' + +type CreateStorageFormState = { + name: string + diskPath: string + maxSize: string + maxSizeUnit: SizeUnit + retentionValue: string + retentionUnit: RetentionUnit + archiveName: string + archiveDiskPath: string + archiveMaxSize: string + archiveMaxSizeUnit: SizeUnit + archiveRetentionValue: string + archiveRetentionUnit: RetentionUnit + archiveUseAutomaticPath: boolean +} + +const emptyCreateStorageForm: CreateStorageFormState = { + name: '', + diskPath: '', + maxSize: '30', + maxSizeUnit: 'gb', + retentionValue: '1', + retentionUnit: 'hours', + archiveName: '', + archiveDiskPath: '', + archiveMaxSize: '3.6', + archiveMaxSizeUnit: 'tb', + archiveRetentionValue: '365', + archiveRetentionUnit: 'days', + archiveUseAutomaticPath: true, +} + +function maxSizeToMegabytes(value: string, unit: SizeUnit) { + const n = Number(value || '0') + if (!Number.isFinite(n) || n < 0) return 0 + return Math.round(n * (unit === 'tb' ? 1024 * 1024 : 1024)) +} + +function retentionToMinutes(value: string, unit: RetentionUnit) { + const n = Number(value || '0') + if (!Number.isFinite(n) || n <= 0) return 0 + return Math.round(n * (unit === 'hours' ? 60 : 24 * 60)) +} + +function formatConvertedInputValue(value: number) { + if (!Number.isFinite(value)) return '' + const digits = Math.abs(value) > 0 && Math.abs(value) < 1 ? 4 : 2 + return String(Number(value.toFixed(digits))) +} + +function convertMaxSizeInputValue(value: string, from: SizeUnit, to: SizeUnit) { + if (from === to || value.trim() === '') return value + const n = Number(value) + if (!Number.isFinite(n)) return value + const gb = from === 'tb' ? n * 1024 : n + return formatConvertedInputValue(to === 'tb' ? gb / 1024 : gb) +} + +function convertRetentionInputValue(value: string, from: RetentionUnit, to: RetentionUnit) { + if (from === to || value.trim() === '') return value + const n = Number(value) + if (!Number.isFinite(n)) return value + const hours = from === 'days' ? n * 24 : n + return formatConvertedInputValue(to === 'days' ? hours / 24 : hours) +} + +function retentionUnitLabel(unit: RetentionUnit, value: string) { + const n = Number(value || '0') + const singular = Number.isFinite(n) && n === 1 + if (unit === 'hours') return singular ? 'Stunde' : 'Stunden' + return singular ? 'Tag' : 'Tage' +} + +function formatMaxSizePreview(value: string, unit: SizeUnit) { + const mb = maxSizeToMegabytes(value, unit) + if (mb <= 0) return '0 MB' + if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} TB` + if (mb >= 1024) return `${(mb / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} GB` + return `${mb.toLocaleString('de-DE')} MB` +} + +function sanitizeWindowsFolderNameInput(value: string) { + return value.replace(/[<>:"/\\|?*]/g, '') +} + +function hasInvalidWindowsFolderNameCharacters(value: string) { + return /[<>:"/\\|?*]/.test(value) +} + function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } @@ -338,8 +432,8 @@ export default function OperationModal({ const [isLoadingStorages, setIsLoadingStorages] = useState(false) const [showCreateStorageForm, setShowCreateStorageForm] = useState(false) const [isCreatingStorage, setIsCreatingStorage] = useState(false) - const [createStorageName, setCreateStorageName] = useState('') - const [createStorageDiskPath, setCreateStorageDiskPath] = useState('') + const [createStorageTab, setCreateStorageTab] = useState('basis') + const [createStorageForm, setCreateStorageForm] = useState(emptyCreateStorageForm) const [createStorageError, setCreateStorageError] = useState(null) const routerOptions = useMemo( @@ -369,8 +463,8 @@ export default function OperationModal({ setMilestoneStorages([]) setSelectedMilestoneStorage(null) setShowCreateStorageForm(false) - setCreateStorageName('') - setCreateStorageDiskPath('') + setCreateStorageTab('basis') + setCreateStorageForm(emptyCreateStorageForm) setCreateStorageError(null) }, [ open, @@ -641,19 +735,61 @@ export default function OperationModal({ } } - async function handleCreateMilestoneStorage(e: FormEvent) { - e.preventDefault() - const name = createStorageName.trim() - const diskPath = createStorageDiskPath.trim() - if (!name || !diskPath) { - setCreateStorageError('Name und Pfad sind erforderlich.') - return + function validateCreateStorageBasisTab() { + if (createStorageForm.name.trim() === '') { + setCreateStorageError('Bitte einen Namen für den Basis-Speicher eintragen.') + return false } + if (hasInvalidWindowsFolderNameCharacters(createStorageForm.name)) { + setCreateStorageError('Der Name darf diese Zeichen nicht enthalten: < > : " / \\ | ? *') + return false + } + if (createStorageForm.diskPath.trim() === '') { + setCreateStorageError('Bitte einen Speicherpfad für den Basis-Speicher eintragen.') + return false + } + if (retentionToMinutes(createStorageForm.retentionValue, createStorageForm.retentionUnit) <= 0) { + setCreateStorageError('Bitte eine gültige Retention für den Basis-Speicher eintragen.') + return false + } + setCreateStorageError(null) + return true + } + + function validateCreateStorageArchiveTab() { + if (!createStorageForm.archiveUseAutomaticPath && createStorageForm.archiveDiskPath.trim() === '') { + setCreateStorageError('Bitte einen Archivpfad eintragen.') + return false + } + if (retentionToMinutes(createStorageForm.archiveRetentionValue, createStorageForm.archiveRetentionUnit) <= 0) { + setCreateStorageError('Bitte eine gültige Retention für den Archivspeicher eintragen.') + return false + } + setCreateStorageError(null) + return true + } + + function goToCreateStorageTab(tab: CreateStorageTabId) { + if (tab === 'archive' && !validateCreateStorageBasisTab()) return + setCreateStorageError(null) + setCreateStorageTab(tab) + } + + async function handleCreateStorageWithArchive() { + if (!validateCreateStorageBasisTab() || !validateCreateStorageArchiveTab()) return setIsCreatingStorage(true) setCreateStorageError(null) try { + const name = createStorageForm.name.trim() + const archiveName = createStorageForm.archiveUseAutomaticPath + ? name + : (createStorageForm.archiveName.trim() || `${name}_archive`) + const archiveDiskPath = createStorageForm.archiveUseAutomaticPath + ? createStorageForm.diskPath.trim() + : createStorageForm.archiveDiskPath.trim() + const response = await fetch(`${API_URL}/admin/milestone/storages`, { method: 'POST', credentials: 'include', @@ -661,9 +797,15 @@ export default function OperationModal({ body: JSON.stringify({ displayName: name, name, - diskPath, - maxSize: 0, - retainMinutes: 0, + diskPath: createStorageForm.diskPath.trim(), + maxSize: maxSizeToMegabytes(createStorageForm.maxSize, createStorageForm.maxSizeUnit), + retainMinutes: retentionToMinutes(createStorageForm.retentionValue, createStorageForm.retentionUnit), + archiveDisplayName: archiveName, + archiveName, + archiveFolderName: archiveName, + archiveDiskPath, + archiveMaxSize: maxSizeToMegabytes(createStorageForm.archiveMaxSize, createStorageForm.archiveMaxSizeUnit), + archiveRetainMinutes: retentionToMinutes(createStorageForm.archiveRetentionValue, createStorageForm.archiveRetentionUnit), }), }) @@ -673,11 +815,10 @@ export default function OperationModal({ return } - const createdName = name - setCreateStorageName('') - setCreateStorageDiskPath('') setShowCreateStorageForm(false) - await loadMilestoneStorages(createdName) + setCreateStorageTab('basis') + setCreateStorageForm(emptyCreateStorageForm) + await loadMilestoneStorages(name) } catch { setCreateStorageError('Verbindung fehlgeschlagen.') } finally { @@ -1284,77 +1425,323 @@ export default function OperationModal({ {!showCreateStorageForm ? ( ) : ( -
-
-

+
+
+

Neuen Speicher anlegen

+ goToCreateStorageTab('basis'), + }, + { + name: 'Archivspeicher', + href: '#', + current: createStorageTab === 'archive', + count: 2, + onClick: () => goToCreateStorageTab('archive'), + }, + ]} + variant="underline" + ariaLabel="Speicherbereiche" + className="border-b border-gray-200 px-4 dark:border-white/10" + /> + {createStorageError && ( -
+
{createStorageError}
)} -
-
- - setCreateStorageName(e.target.value)} - placeholder="z. B. Einsatz-Speicher-2025" - className="mt-1 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10" - /> -
+
+ {createStorageTab === 'basis' && ( + <> +
+ + setCreateStorageForm((f) => ({ ...f, name: sanitizeWindowsFolderNameInput(e.target.value) }))} + placeholder="z. B. Einsatz-Speicher-2025" + className={inputClassName} + autoFocus + /> +
-
- - setCreateStorageDiskPath(e.target.value)} - placeholder={`z. B. D:\\Milestone\\${createStorageName || 'Einsatz'}`} - className="mt-1 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10" - /> -

- Ein Archivspeicher wird automatisch unter diesem Pfad angelegt. -

-
+
+ + setCreateStorageForm((f) => ({ ...f, diskPath: e.target.value }))} + placeholder={`z. B. D:\\Milestone\\${createStorageForm.name || 'Storage01'}`} + className={inputClassName} + /> +
-
- +
+
+ +
+ setCreateStorageForm((f) => ({ ...f, maxSize: e.target.value }))} + className={inputClassName} + /> + +
+

+ {formatMaxSizePreview(createStorageForm.maxSize, createStorageForm.maxSizeUnit)} +

+
+ +
+ +
+ setCreateStorageForm((f) => ({ ...f, retentionValue: e.target.value }))} + className={inputClassName} + /> + +
+
+
+ + )} + + {createStorageTab === 'archive' && ( + <> + + +
+ + setCreateStorageForm((f) => ({ ...f, archiveName: sanitizeWindowsFolderNameInput(e.target.value) }))} + readOnly={createStorageForm.archiveUseAutomaticPath} + className={`${inputClassName} ${createStorageForm.archiveUseAutomaticPath ? 'cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400' : ''}`} + placeholder={`${createStorageForm.name || 'storage'}_archive`} + /> +
+ + {!createStorageForm.archiveUseAutomaticPath && ( +
+ + setCreateStorageForm((f) => ({ ...f, archiveDiskPath: e.target.value }))} + placeholder="z. B. E:\\Milestone\\Archive01" + className={inputClassName} + /> +
+ )} + +
+
+ +
+ setCreateStorageForm((f) => ({ ...f, archiveMaxSize: e.target.value }))} + className={inputClassName} + /> + +
+

+ {formatMaxSizePreview(createStorageForm.archiveMaxSize, createStorageForm.archiveMaxSizeUnit)} +

+
+ +
+ +
+ setCreateStorageForm((f) => ({ ...f, archiveRetentionValue: e.target.value }))} + className={inputClassName} + /> + +
+
+
+ + )} + +
+
+ {createStorageTab === 'archive' && ( + + )} +
+
+ + {createStorageTab === 'basis' ? ( + + ) : ( + + )} +
diff --git a/frontend/src/pages/operations/milestone-cameras/MilestoneCamerasPage.tsx b/frontend/src/pages/operations/milestone-cameras/MilestoneCamerasPage.tsx deleted file mode 100644 index d994305..0000000 --- a/frontend/src/pages/operations/milestone-cameras/MilestoneCamerasPage.tsx +++ /dev/null @@ -1,310 +0,0 @@ -// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx - -import { useEffect, useState } from "react"; -import { - MicrophoneIcon, - VideoCameraIcon, -} from "@heroicons/react/24/outline"; -import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; -import LoadingSpinner from "../../../components/LoadingSpinner"; -import Tables, { type TableColumn } from "../../../components/Tables"; -import { Badge } from "../../../components/Badge"; -import EmptyState from "../../../components/EmptyState"; -import { useNotifications } from "../../../components/Notifications"; - -const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; - -type MilestoneCameraListItem = { - id: string; - name: string; - displayName: string; - type: "camera" | "microphone"; - enabled: boolean; - recordingEnabled: boolean; - recordingStorageId: string; - hardwareId: string; -}; - -type StorageSimple = { - id: string; - name: string; - displayName: string; -}; - -type Props = { - canWrite?: boolean; -}; - -export default function MilestoneCamerasPage({ canWrite }: Props) { - const { notify } = useNotifications(); - const [devices, setDevices] = useState([]); - const [storageMap, setStorageMap] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - - useEffect(() => { - void Promise.all([loadDevices(), loadStorages()]); - }, []); - - async function loadDevices() { - setIsLoading(true); - setError(null); - try { - const response = await fetch(`${API_URL}/admin/milestone/cameras`, { - credentials: "include", - }); - if (!response.ok) { - const data = (await response.json().catch(() => null)) as { - error?: string; - } | null; - setError(data?.error ?? "Geräte konnten nicht geladen werden"); - return; - } - const data = (await response.json()) as { - devices: MilestoneCameraListItem[]; - }; - setDevices(data.devices ?? []); - } catch { - setError("Verbindung zum Server fehlgeschlagen"); - } finally { - setIsLoading(false); - } - } - - async function loadStorages() { - try { - const response = await fetch(`${API_URL}/admin/milestone/storages`, { - credentials: "include", - }); - if (!response.ok) return; - const data = (await response.json()) as { - storages?: { id: string; name: string; displayName: string }[]; - }; - const map: Record = {}; - for (const s of data.storages ?? []) { - map[s.id] = { id: s.id, name: s.name, displayName: s.displayName }; - } - setStorageMap(map); - } catch { - // silent – storage names are cosmetic - } - } - - async function handleToggle( - device: MilestoneCameraListItem, - field: "enabled" | "recordingEnabled", - ) { - const newValue = !device[field]; - - setDevices((current) => - current.map((d) => - d.id === device.id ? { ...d, [field]: newValue } : d, - ), - ); - - const collection = device.type === "camera" ? "cameras" : "microphones"; - try { - const response = await fetch( - `${API_URL}/admin/milestone/devices/${collection}/${device.id}`, - { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ [field]: newValue }), - }, - ); - if (!response.ok) { - const data = (await response.json().catch(() => null)) as { - error?: string; - } | null; - setDevices((current) => - current.map((d) => - d.id === device.id ? { ...d, [field]: !newValue } : d, - ), - ); - notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden"); - } - } catch { - setDevices((current) => - current.map((d) => - d.id === device.id ? { ...d, [field]: !newValue } : d, - ), - ); - notify.error("Verbindung fehlgeschlagen"); - } - } - - const searchLower = search.trim().toLowerCase(); - const filtered = searchLower - ? devices.filter( - (d) => - d.name.toLowerCase().includes(searchLower) || - d.displayName.toLowerCase().includes(searchLower), - ) - : devices; - - const cameraCount = filtered.filter((d) => d.type === "camera").length; - const micCount = filtered.filter((d) => d.type === "microphone").length; - - const columns: TableColumn[] = [ - { - key: "name", - header: "Name", - isPrimary: true, - render: (row) => ( -
- {row.type === "camera" ? ( - - ) : ( - - )} -
-
- {row.name} -
- {row.displayName && row.displayName !== row.name && ( -
- {row.displayName} -
- )} -
-
- ), - }, - { - key: "enabled", - header: "Status", - hideOnMobile: "sm", - render: (row) => - canWrite ? ( - - ) : ( - - {row.enabled ? "Aktiv" : "Inaktiv"} - - ), - }, - { - key: "recordingEnabled", - header: "Aufnahme", - hideOnMobile: "md", - render: (row) => - canWrite ? ( - - ) : ( - - {row.recordingEnabled ? "Aktiv" : "Inaktiv"} - - ), - }, - { - key: "recordingStorageId", - header: "Speicher", - hideOnMobile: "lg", - render: (row) => { - const storage = storageMap[row.recordingStorageId]; - if (storage) { - return ( - - {storage.displayName || storage.name} - - ); - } - if (row.recordingStorageId) { - return ( - - {row.recordingStorageId} - - ); - } - return ; - }, - }, - ]; - - return ( -
-
-
-

- Milestone-Geräte -

- {!isLoading && !error && ( -

- {cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "} - {micCount} Mikrofon{micCount !== 1 ? "e" : ""} -

- )} -
-
- -
- - setSearch(e.target.value)} - className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500" - /> -
- - {isLoading && ( -
- -
- )} - - {!isLoading && error && ( -
- {error} -
- )} - - {!isLoading && !error && filtered.length === 0 && ( - - )} - - {!isLoading && !error && filtered.length > 0 && ( - row.id} - variant="border" - /> - )} -
- ); -} diff --git a/frontend/src/pages/operations/milestone-cameras/MilestoneDevicesPage.tsx b/frontend/src/pages/operations/milestone-cameras/MilestoneDevicesPage.tsx deleted file mode 100644 index d994305..0000000 --- a/frontend/src/pages/operations/milestone-cameras/MilestoneDevicesPage.tsx +++ /dev/null @@ -1,310 +0,0 @@ -// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx - -import { useEffect, useState } from "react"; -import { - MicrophoneIcon, - VideoCameraIcon, -} from "@heroicons/react/24/outline"; -import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; -import LoadingSpinner from "../../../components/LoadingSpinner"; -import Tables, { type TableColumn } from "../../../components/Tables"; -import { Badge } from "../../../components/Badge"; -import EmptyState from "../../../components/EmptyState"; -import { useNotifications } from "../../../components/Notifications"; - -const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; - -type MilestoneCameraListItem = { - id: string; - name: string; - displayName: string; - type: "camera" | "microphone"; - enabled: boolean; - recordingEnabled: boolean; - recordingStorageId: string; - hardwareId: string; -}; - -type StorageSimple = { - id: string; - name: string; - displayName: string; -}; - -type Props = { - canWrite?: boolean; -}; - -export default function MilestoneCamerasPage({ canWrite }: Props) { - const { notify } = useNotifications(); - const [devices, setDevices] = useState([]); - const [storageMap, setStorageMap] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - - useEffect(() => { - void Promise.all([loadDevices(), loadStorages()]); - }, []); - - async function loadDevices() { - setIsLoading(true); - setError(null); - try { - const response = await fetch(`${API_URL}/admin/milestone/cameras`, { - credentials: "include", - }); - if (!response.ok) { - const data = (await response.json().catch(() => null)) as { - error?: string; - } | null; - setError(data?.error ?? "Geräte konnten nicht geladen werden"); - return; - } - const data = (await response.json()) as { - devices: MilestoneCameraListItem[]; - }; - setDevices(data.devices ?? []); - } catch { - setError("Verbindung zum Server fehlgeschlagen"); - } finally { - setIsLoading(false); - } - } - - async function loadStorages() { - try { - const response = await fetch(`${API_URL}/admin/milestone/storages`, { - credentials: "include", - }); - if (!response.ok) return; - const data = (await response.json()) as { - storages?: { id: string; name: string; displayName: string }[]; - }; - const map: Record = {}; - for (const s of data.storages ?? []) { - map[s.id] = { id: s.id, name: s.name, displayName: s.displayName }; - } - setStorageMap(map); - } catch { - // silent – storage names are cosmetic - } - } - - async function handleToggle( - device: MilestoneCameraListItem, - field: "enabled" | "recordingEnabled", - ) { - const newValue = !device[field]; - - setDevices((current) => - current.map((d) => - d.id === device.id ? { ...d, [field]: newValue } : d, - ), - ); - - const collection = device.type === "camera" ? "cameras" : "microphones"; - try { - const response = await fetch( - `${API_URL}/admin/milestone/devices/${collection}/${device.id}`, - { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ [field]: newValue }), - }, - ); - if (!response.ok) { - const data = (await response.json().catch(() => null)) as { - error?: string; - } | null; - setDevices((current) => - current.map((d) => - d.id === device.id ? { ...d, [field]: !newValue } : d, - ), - ); - notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden"); - } - } catch { - setDevices((current) => - current.map((d) => - d.id === device.id ? { ...d, [field]: !newValue } : d, - ), - ); - notify.error("Verbindung fehlgeschlagen"); - } - } - - const searchLower = search.trim().toLowerCase(); - const filtered = searchLower - ? devices.filter( - (d) => - d.name.toLowerCase().includes(searchLower) || - d.displayName.toLowerCase().includes(searchLower), - ) - : devices; - - const cameraCount = filtered.filter((d) => d.type === "camera").length; - const micCount = filtered.filter((d) => d.type === "microphone").length; - - const columns: TableColumn[] = [ - { - key: "name", - header: "Name", - isPrimary: true, - render: (row) => ( -
- {row.type === "camera" ? ( - - ) : ( - - )} -
-
- {row.name} -
- {row.displayName && row.displayName !== row.name && ( -
- {row.displayName} -
- )} -
-
- ), - }, - { - key: "enabled", - header: "Status", - hideOnMobile: "sm", - render: (row) => - canWrite ? ( - - ) : ( - - {row.enabled ? "Aktiv" : "Inaktiv"} - - ), - }, - { - key: "recordingEnabled", - header: "Aufnahme", - hideOnMobile: "md", - render: (row) => - canWrite ? ( - - ) : ( - - {row.recordingEnabled ? "Aktiv" : "Inaktiv"} - - ), - }, - { - key: "recordingStorageId", - header: "Speicher", - hideOnMobile: "lg", - render: (row) => { - const storage = storageMap[row.recordingStorageId]; - if (storage) { - return ( - - {storage.displayName || storage.name} - - ); - } - if (row.recordingStorageId) { - return ( - - {row.recordingStorageId} - - ); - } - return ; - }, - }, - ]; - - return ( -
-
-
-

- Milestone-Geräte -

- {!isLoading && !error && ( -

- {cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "} - {micCount} Mikrofon{micCount !== 1 ? "e" : ""} -

- )} -
-
- -
- - setSearch(e.target.value)} - className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500" - /> -
- - {isLoading && ( -
- -
- )} - - {!isLoading && error && ( -
- {error} -
- )} - - {!isLoading && !error && filtered.length === 0 && ( - - )} - - {!isLoading && !error && filtered.length > 0 && ( - row.id} - variant="border" - /> - )} -
- ); -} diff --git a/frontend/src/pages/operations/milestone-devices/MilestoneDevicesPage.tsx b/frontend/src/pages/operations/milestone-devices/MilestoneDevicesPage.tsx index 5ab18b3..71c674d 100644 --- a/frontend/src/pages/operations/milestone-devices/MilestoneDevicesPage.tsx +++ b/frontend/src/pages/operations/milestone-devices/MilestoneDevicesPage.tsx @@ -1,19 +1,26 @@ // frontend\src\pages\operations\milestone-devices\MilestoneDevicesPage.tsx -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, type ComponentProps } from "react"; import { + ChevronRightIcon, + CircleStackIcon, MicrophoneIcon, VideoCameraIcon, } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import LoadingSpinner from "../../../components/LoadingSpinner"; -import Tables, { type TableColumn } from "../../../components/Tables"; -import { Badge } from "../../../components/Badge"; import EmptyState from "../../../components/EmptyState"; +import ChildDeviceCard from "../../devices/ChildDeviceCard"; import { useNotifications } from "../../../components/Notifications"; const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; +const NO_STORAGE_KEY = "__none__"; + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(" "); +} + type MilestoneCameraListItem = { id: string; name: string; @@ -23,6 +30,7 @@ type MilestoneCameraListItem = { recordingEnabled: boolean; recordingStorageId: string; hardwareId: string; + hardwareDisplayName?: string; }; type StorageSimple = { @@ -31,12 +39,18 @@ type StorageSimple = { displayName: string; }; +type DeviceGroup = { + id: string; + name: string; + rows: MilestoneCameraListItem[]; +}; + type Props = { canWrite?: boolean; }; export default function MilestoneDevicesPage({ canWrite }: Props) { - const { notify } = useNotifications(); + const { showToast } = useNotifications(); const [devices, setDevices] = useState([]); const [storageMap, setStorageMap] = useState>({}); const [isLoading, setIsLoading] = useState(true); @@ -94,8 +108,10 @@ export default function MilestoneDevicesPage({ canWrite }: Props) { async function handleToggle( device: MilestoneCameraListItem, field: "enabled" | "recordingEnabled", + nextValue?: boolean, ) { - const newValue = !device[field]; + const newValue = + typeof nextValue === "boolean" ? nextValue : !device[field]; setDevices((current) => current.map((d) => @@ -123,7 +139,11 @@ export default function MilestoneDevicesPage({ canWrite }: Props) { d.id === device.id ? { ...d, [field]: !newValue } : d, ), ); - notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden"); + showToast({ + variant: "error", + title: "Gerät konnte nicht aktualisiert werden", + message: data?.error ?? undefined, + }); } } catch { setDevices((current) => @@ -131,180 +151,396 @@ export default function MilestoneDevicesPage({ canWrite }: Props) { d.id === device.id ? { ...d, [field]: !newValue } : d, ), ); - notify.error("Verbindung fehlgeschlagen"); + showToast({ + variant: "error", + title: "Verbindung fehlgeschlagen", + }); } } const searchLower = search.trim().toLowerCase(); - const filtered = searchLower - ? devices.filter( - (d) => - d.name.toLowerCase().includes(searchLower) || - d.displayName.toLowerCase().includes(searchLower), - ) - : devices; + const filtered = useMemo( + () => + searchLower + ? devices.filter( + (d) => + d.name.toLowerCase().includes(searchLower) || + d.displayName.toLowerCase().includes(searchLower), + ) + : devices, + [devices, searchLower], + ); const cameraCount = filtered.filter((d) => d.type === "camera").length; const micCount = filtered.filter((d) => d.type === "microphone").length; - const columns: TableColumn[] = [ - { - key: "name", - header: "Name", - isPrimary: true, - render: (row) => ( -
- {row.type === "camera" ? ( - - ) : ( - - )} + const groups = useMemo(() => { + const byStorage = new Map(); + + for (const device of filtered) { + const key = device.recordingStorageId || NO_STORAGE_KEY; + const list = byStorage.get(key) ?? []; + list.push(device); + byStorage.set(key, list); + } + + const sortRows = (rows: MilestoneCameraListItem[]) => + [...rows].sort((a, b) => { + if (a.type !== b.type) { + return a.type === "camera" ? -1 : 1; + } + return a.name.localeCompare(b.name, "de"); + }); + + const named: DeviceGroup[] = []; + + for (const [key, rows] of byStorage) { + if (key === NO_STORAGE_KEY) { + continue; + } + + const storage = storageMap[key]; + named.push({ + id: key, + name: storage ? storage.displayName || storage.name : key, + rows: sortRows(rows), + }); + } + + named.sort((a, b) => a.name.localeCompare(b.name, "de")); + + const withoutStorage = byStorage.get(NO_STORAGE_KEY); + if (withoutStorage && withoutStorage.length > 0) { + named.push({ + id: NO_STORAGE_KEY, + name: "Ohne Speicher", + rows: sortRows(withoutStorage), + }); + } + + return named; + }, [filtered, storageMap]); + + return ( +
+
+
-
- {row.name} +

+ Milestone-Geräte +

+ {!isLoading && !error && ( +

+ {cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "} + {micCount} Mikrofon{micCount !== 1 ? "e" : ""} in {groups.length}{" "} + Speicher{groups.length !== 1 ? "n" : ""} +

+ )} +
+ +
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500" + /> +
+
+
+ +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ {error} +
+ )} + + {!isLoading && !error && filtered.length === 0 && ( + + )} + + {!isLoading && + !error && + filtered.length > 0 && + groups.map((group) => ( + + ))} +
+
+ ); +} + +type StorageGroupProps = { + group: DeviceGroup; + canWrite?: boolean; + onToggle: ( + device: MilestoneCameraListItem, + field: "enabled" | "recordingEnabled", + nextValue?: boolean, + ) => void | Promise; +}; + +type ChildDeviceItem = ComponentProps["item"]; + +function toChildDeviceItem(device: MilestoneCameraListItem): ChildDeviceItem { + return { + id: device.id, + milestoneDeviceId: device.id, + deviceType: device.type, + enabled: device.enabled, + recordingEnabled: device.recordingEnabled, + displayName: device.displayName, + name: device.name, + shortName: device.hardwareId, + } as ChildDeviceItem; +} + +type HardwareGroup = { + key: string + title: string + subtitle?: string + rows: MilestoneCameraListItem[] +} + +function groupRowsByHardware(rows: MilestoneCameraListItem[]): HardwareGroup[] { + const map = new Map() + + for (const row of rows) { + const key = row.hardwareId?.trim() || "__no_hardware__" + const list = map.get(key) ?? [] + list.push(row) + map.set(key, list) + } + + return [...map.entries()] + .map(([key, items]) => { + const hardwareDisplayName = + items.find((item) => item.hardwareDisplayName?.trim())?.hardwareDisplayName?.trim() || "" + + return { + key, + title: + key === "__no_hardware__" + ? "Ohne Hardware" + : hardwareDisplayName || key, + subtitle: + key === "__no_hardware__" || hardwareDisplayName === "" || hardwareDisplayName === key + ? undefined + : key, + rows: items, + } + }) + .sort((a, b) => a.title.localeCompare(b.title, "de")) +} + +function StorageGroup({ group, canWrite, onToggle }: StorageGroupProps) { + const cameraRows = group.rows.filter((d) => d.type === "camera") + const microphoneRows = group.rows.filter((d) => d.type === "microphone") + + const cameras = cameraRows.length + const microphones = microphoneRows.length + + const cameraGroups = groupRowsByHardware(cameraRows) + const microphoneGroups = groupRowsByHardware(microphoneRows) + + const [collapsed, setCollapsed] = useState(false) + + return ( +
+ + + {collapsed ? null : group.rows.length === 0 ? ( +

+ Keine Geräte in dieser Gruppe. +

+ ) : ( +
+
+
+
+ +

+ Kameras +

+
+ + + {cameraRows.length} +
- {row.displayName && row.displayName !== row.name && ( -
- {row.displayName} + + {cameraGroups.length === 0 ? ( +

+ Keine Kameras in dieser Gruppe. +

+ ) : ( +
+ {cameraGroups.map((hardwareGroup) => ( +
+
+

+ {hardwareGroup.title} +

+ + + {hardwareGroup.rows.length} + +
+ +
+ {hardwareGroup.rows.map((device) => { + const item = toChildDeviceItem(device) + + return ( + void onToggle(device, "enabled", enabled) + : undefined + } + onToggleRecording={ + canWrite + ? (_, enabled) => + void onToggle(device, "recordingEnabled", enabled) + : undefined + } + /> + ) + })} +
+
+ ))} +
+ )} +
+ +
+
+
+ +

+ Mikrofone +

+
+ + + {microphoneRows.length} + +
+ + {microphoneGroups.length === 0 ? ( +

+ Keine Mikrofone in dieser Gruppe. +

+ ) : ( +
+ {microphoneGroups.map((hardwareGroup) => ( +
+
+

+ {hardwareGroup.title} +

+ + + {hardwareGroup.rows.length} + +
+ +
+ {hardwareGroup.rows.map((device) => { + const item = toChildDeviceItem(device) + + return ( + void onToggle(device, "enabled", enabled) + : undefined + } + /> + ) + })} +
+
+ ))}
)}
- ), - }, - { - key: "enabled", - header: "Status", - hideOnMobile: "sm", - render: (row) => - canWrite ? ( - - ) : ( - - {row.enabled ? "Aktiv" : "Inaktiv"} - - ), - }, - { - key: "recordingEnabled", - header: "Aufnahme", - hideOnMobile: "md", - render: (row) => - canWrite ? ( - - ) : ( - - {row.recordingEnabled ? "Aktiv" : "Inaktiv"} - - ), - }, - { - key: "recordingStorageId", - header: "Speicher", - hideOnMobile: "lg", - render: (row) => { - const storage = storageMap[row.recordingStorageId]; - if (storage) { - return ( - - {storage.displayName || storage.name} - - ); - } - if (row.recordingStorageId) { - return ( - - {row.recordingStorageId} - - ); - } - return ; - }, - }, - ]; - - return ( -
-
-
-

- Milestone-Geräte -

- {!isLoading && !error && ( -

- {cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "} - {micCount} Mikrofon{micCount !== 1 ? "e" : ""} -

- )} -
-
- -
- - setSearch(e.target.value)} - className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500" - /> -
- - {isLoading && ( -
- -
)} - - {!isLoading && error && ( -
- {error} -
- )} - - {!isLoading && !error && filtered.length === 0 && ( - - )} - - {!isLoading && !error && filtered.length > 0 && ( - row.id} - variant="border" - /> - )} -
- ); +
+ ) } diff --git a/frontend/src/pages/operations/milestone-storage/MilestoneStoragePage.tsx b/frontend/src/pages/operations/milestone-storage/MilestoneStoragePage.tsx index 006b12d..5348a6c 100644 --- a/frontend/src/pages/operations/milestone-storage/MilestoneStoragePage.tsx +++ b/frontend/src/pages/operations/milestone-storage/MilestoneStoragePage.tsx @@ -244,11 +244,12 @@ function classNames(...classes: Array) { function inputClassName(disabled = false) { return classNames( - "block w-full min-w-[100px] rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300", + "block w-full min-w-[100px] rounded-md px-3 py-1.5 text-sm outline-1 -outline-offset-1 outline-gray-300", "placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600", - "dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500", - disabled && - "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400", + "dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500", + disabled + ? "cursor-not-allowed bg-gray-100 text-gray-500 dark:bg-white/10 dark:text-gray-400" + : "bg-white text-gray-900 dark:bg-white/5 dark:text-white", ); } @@ -704,6 +705,42 @@ function serverFolderTreeNodeFromItem(item: ServerFolderItem): TreeListNode { }; } +// Reichert Ordner-Knoten mit dem Anzeigenamen des zugehörigen Milestone-Speichers +// an. Auf der Platte heißen die Datenordner nach ihrer GUID/ID; trifft der +// Ordnername (= ID) auf einen bekannten Speicher/Archivspeicher, wird dessen +// Anzeigename als Haupttext gesetzt und die ursprüngliche ID grau als +// secondaryName beibehalten. Normal benannte Ordner bleiben unverändert. +function decorateServerFolderNodesWithDisplayNames( + nodes: TreeListNode[], + displayNameById: Map, +): TreeListNode[] { + return nodes.map((node) => { + const baseName = serverFolderBaseName(node.path); + const resolvedDisplayName = displayNameById.get( + baseName.trim().toLowerCase(), + ); + + const decoratedNode = + resolvedDisplayName && + normalizeServerFolderPath(resolvedDisplayName) !== + normalizeServerFolderPath(baseName) + ? { ...node, name: resolvedDisplayName, secondaryName: baseName } + : node; + + if (!Array.isArray(node.children)) { + return decoratedNode; + } + + return { + ...decoratedNode, + children: decorateServerFolderNodesWithDisplayNames( + node.children, + displayNameById, + ), + }; + }); +} + function buildServerFolderBranch( rootPath: string, currentPath: string, @@ -991,6 +1028,9 @@ export default function MilestoneStoragePage({ const { showToast, showErrorToast } = useNotifications(); const [storages, setStorages] = useState([]); + const [milestoneDeviceNames, setMilestoneDeviceNames] = useState< + { id: string; hardwareId: string; name: string; displayName: string }[] + >([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -1017,6 +1057,56 @@ export default function MilestoneStoragePage({ const storageServerFolders = serverFolderTrees.storage; const archiveServerFolders = serverFolderTrees.archive; + const storageFolderDisplayNamesById = useMemo(() => { + const byId = new Map(); + + const register = ( + id: string | undefined, + displayName: string | undefined, + name: string | undefined, + ) => { + const label = displayName?.trim() || name?.trim() || ""; + if (!label || !id?.trim()) { + return; + } + + byId.set(id.trim().toLowerCase(), label); + }; + + for (const storage of storages) { + register(storage.id, storage.displayName, storage.name); + + for (const archive of storage.archiveStorages ?? []) { + register(archive.id, archive.displayName, archive.name); + } + } + + for (const device of milestoneDeviceNames) { + register(device.id, device.displayName, device.name); + register(device.hardwareId, device.displayName, device.name); + } + + return byId; + }, [storages, milestoneDeviceNames]); + + const decoratedStorageServerFolderNodes = useMemo( + () => + decorateServerFolderNodesWithDisplayNames( + storageServerFolders.nodes, + storageFolderDisplayNamesById, + ), + [storageServerFolders.nodes, storageFolderDisplayNamesById], + ); + + const decoratedArchiveServerFolderNodes = useMemo( + () => + decorateServerFolderNodesWithDisplayNames( + archiveServerFolders.nodes, + storageFolderDisplayNamesById, + ), + [archiveServerFolders.nodes, storageFolderDisplayNamesById], + ); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editingStorage, setEditingStorage] = useState( null, @@ -1071,8 +1161,41 @@ export default function MilestoneStoragePage({ useEffect(() => { void loadStorages(); + void loadMilestoneDeviceNames(); }, []); + async function loadMilestoneDeviceNames() { + try { + const response = await fetch(`${API_URL}/admin/milestone/cameras`, { + credentials: "include", + }); + + if (!response.ok) { + return; + } + + const data = (await response.json()) as { + devices?: { + id?: string; + hardwareId?: string; + name?: string; + displayName?: string; + }[]; + }; + + setMilestoneDeviceNames( + (data.devices ?? []).map((device) => ({ + id: device.id ?? "", + hardwareId: device.hardwareId ?? "", + name: device.name ?? "", + displayName: device.displayName ?? "", + })), + ); + } catch { + // silent – device names are cosmetic + } + } + async function loadStorages() { setIsLoading(true); setError(null); @@ -2523,7 +2646,7 @@ export default function MilestoneStoragePage({ createStorageForm.name.trim() || undefined } previewLabel="wird angelegt" - nodes={storageServerFolders.nodes} + nodes={decoratedStorageServerFolderNodes} rootPath={ createStorageForm.basisDiskRootPath || storageServerFolders.currentPath || @@ -2676,7 +2799,7 @@ export default function MilestoneStoragePage({ - Archivpfad automatisch aus Archiv-Root bilden + Archivpfad automatisch aus Basis-Speicher bilden Beispiel: Archiv-Root + Archiv-Ordner. @@ -2736,7 +2859,7 @@ export default function MilestoneStoragePage({ undefined } previewLabel="wird angelegt" - nodes={archiveServerFolders.nodes} + nodes={decoratedArchiveServerFolderNodes} rootPath={ createStorageForm.archiveDiskRootPath || archiveServerFolders.currentPath || @@ -2772,7 +2895,7 @@ export default function MilestoneStoragePage({ label="Archivpfad" description="Wähle oder erfasse einen eigenen Zielordner für den Archivspeicher." selectedPath={createStorageForm.archiveDiskPath} - nodes={archiveServerFolders.nodes} + nodes={decoratedArchiveServerFolderNodes} rootPath={ archiveServerFolders.currentPath || createStorageForm.archiveDiskPath || @@ -2983,16 +3106,13 @@ export default function MilestoneStoragePage({ - setEditForm((current) => ({ - ...current, - diskPath: event.target.value, - })) - } - className={inputClassName()} - required + readOnly + className={inputClassName(true)} />
+

+ Der Pfad kann nachträglich nicht geändert werden. +

@@ -3137,6 +3257,23 @@ export default function MilestoneStoragePage({
+
+ +
+ +
+

+ Der Pfad kann nachträglich nicht geändert werden. +

+
+