added server folder agent

This commit is contained in:
Linrador 2026-06-09 14:26:35 +02:00
parent 5af9896514
commit dd387a884c
29 changed files with 8835 additions and 2076 deletions

File diff suppressed because it is too large Load Diff

View File

@ -73,6 +73,7 @@ type MilestoneHardwareChildDevice struct {
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
Enabled bool `json:"enabled"`
RecordingEnabled bool `json:"recordingEnabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@ -258,6 +259,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
milestone_hardware_child_devices.display_name,
milestone_hardware_child_devices.short_name,
milestone_hardware_child_devices.enabled,
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
milestone_hardware_child_devices.created_at,
milestone_hardware_child_devices.updated_at
FROM milestone_hardware_child_devices
@ -290,6 +292,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&childDevice.DisplayName,
&childDevice.ShortName,
&childDevice.Enabled,
&childDevice.RecordingEnabled,
&childDevice.CreatedAt,
&childDevice.UpdatedAt,
); err != nil {

45
backend/external/README.md vendored Normal file
View File

@ -0,0 +1,45 @@
# Server Folders mit Parametern
## Backend-Proxy
Der bestehende Frontend-Endpunkt bleibt:
```http
GET /admin/server-folders?path=D:\Milestone
POST /admin/server-folders
```
Zusätzlich kann der Proxy diese Parameter auswerten:
```http
GET /admin/server-folders?path=D:\Milestone&schema=http&port=8099&token=CHANGE-ME
```
Aliasnamen:
- `schema`, `scheme`, `agentSchema`, `agentScheme`
- `port`, `agentPort`
- `token`, `agentToken`
Der Proxy entfernt diese Parameter, bevor er an den Agent weiterleitet.
Der Token wird als Header weitergegeben:
```http
X-Server-Folders-Token: CHANGE-ME
```
## Agent starten
```powershell
.\server-folders-agent.exe -port 8099 -roots "Milestone D=D:\Milestone;Milestone E=E:\Milestone" -token "CHANGE-ME"
```
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
```

35
backend/external/agent_build.bat vendored Normal file
View File

@ -0,0 +1,35 @@
@echo off
setlocal
cd /d "%~dp0"
echo Entferne alten Build...
if exist server-folders-agent.exe del server-folders-agent.exe
echo.
echo Baue server-folders-agent fuer Windows 11 x64...
echo.
set GOOS=windows
set GOARCH=amd64
set CGO_ENABLED=0
go build -o server-folders-agent.exe .
if errorlevel 1 (
echo.
echo Build fehlgeschlagen.
pause
exit /b 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.
pause
endlocal

705
backend/external/agent_main.go vendored Normal file
View File

@ -0,0 +1,705 @@
// backend\external\agent_main.go
//
// Eigenständiger Ordner-Agent für den Milestone-/Recording-Server.
//
// Endpunkte:
// GET /health
// GET /server-folders?rootType=storage&path=D:\Milestone
// POST /server-folders
//
// Start per Parameter:
// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"
//
// Pflichtwerte: -storage-root, -archive-root und -token. Alternativ können die passenden ENV-Variablen gesetzt werden.
//
// Alternativ per ENV:
// LISTEN_ADDR=:8099
// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage
// 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: <token>
// oder:
// Authorization: Bearer <token>
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"unsafe"
)
const (
defaultListenAddr = ":8099"
listenAddrEnv = "LISTEN_ADDR"
serverStorageRootsEnv = "SERVER_STORAGE_ROOTS"
serverArchiveRootsEnv = "SERVER_ARCHIVE_ROOTS"
legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS"
agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN"
)
type serverFolderRootType string
const (
serverFolderRootTypeStorage serverFolderRootType = "storage"
serverFolderRootTypeArchive serverFolderRootType = "archive"
)
type appConfig struct {
ListenAddr string
StorageRoots string
ArchiveRoots string
Token string
}
func (config appConfig) rootsForType(rootType serverFolderRootType) string {
if rootType == serverFolderRootTypeArchive {
return strings.TrimSpace(config.ArchiveRoots)
}
return strings.TrimSpace(config.StorageRoots)
}
type serverFolderRoot struct {
Label string `json:"label"`
Path string `json:"path"`
}
type serverFolderNode struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Path string `json:"path"`
HasChildren bool `json:"hasChildren"`
}
type listServerFoldersResponse struct {
RootType string `json:"rootType"`
Path string `json:"path"`
ParentPath string `json:"parentPath,omitempty"`
Roots []serverFolderRoot `json:"roots"`
Folders []serverFolderNode `json:"folders"`
}
type createServerFolderRequest struct {
Path string `json:"path"`
ParentPath string `json:"parentPath"`
Name string `json:"name"`
}
func main() {
config := readConfigFromFlagsAndEnv()
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
mux.HandleFunc("GET /server-folders", requireAgentToken(config.Token, 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) {
handleCreateServerFolder(w, r, config)
}))
server := &http.Server{
Addr: config.ListenAddr,
Handler: logRequests(mux),
ReadHeaderTimeout: 10 * time.Second,
}
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 != "")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server failed: %v", err)
}
}
func readConfigFromFlagsAndEnv() appConfig {
addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099")
portFlag := flag.String("port", "", "Listen port, e.g. 8099")
legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone")
storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage")
archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive")
tokenFlag := flag.String("token", "", "Required access token for X-Server-Folders-Token")
flag.Parse()
listenAddr := strings.TrimSpace(*addrFlag)
if listenAddr == "" {
listenAddr = strings.TrimSpace(os.Getenv(listenAddrEnv))
}
port := strings.TrimSpace(*portFlag)
if listenAddr == "" && port != "" {
port = strings.TrimPrefix(port, ":")
listenAddr = ":" + port
}
if listenAddr == "" {
listenAddr = defaultListenAddr
}
legacyRoots := strings.TrimSpace(*legacyRootsFlag)
if legacyRoots == "" {
legacyRoots = strings.TrimSpace(os.Getenv(legacyFolderRootsEnv))
}
storageRoots := strings.TrimSpace(*storageRootsFlag)
if storageRoots == "" {
storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv))
}
archiveRoots := strings.TrimSpace(*archiveRootsFlag)
if archiveRoots == "" {
archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv))
}
token := strings.TrimSpace(*tokenFlag)
if token == "" {
token = strings.TrimSpace(os.Getenv(agentTokenEnv))
}
config := appConfig{
ListenAddr: listenAddr,
StorageRoots: storageRoots,
ArchiveRoots: archiveRoots,
Token: token,
}
validateRequiredConfig(config, legacyRoots)
return config
}
func validateRequiredConfig(config appConfig, legacyRoots string) {
missing := make([]string, 0, 3)
if strings.TrimSpace(config.StorageRoots) == "" {
missing = append(missing, "-storage-root oder "+serverStorageRootsEnv)
}
if strings.TrimSpace(config.ArchiveRoots) == "" {
missing = append(missing, "-archive-root oder "+serverArchiveRootsEnv)
}
if strings.TrimSpace(config.Token) == "" {
missing = append(missing, "-token oder "+agentTokenEnv)
}
if len(missing) == 0 {
return
}
if strings.TrimSpace(legacyRoots) != "" {
log.Printf("Hinweis: -roots/%s wird nicht mehr als Ersatz fuer -storage-root und -archive-root akzeptiert.", legacyFolderRootsEnv)
}
log.Fatalf(
`Pflichtparameter fehlen: %s. Beispiel: server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"`,
strings.Join(missing, ", "),
)
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func handleListServerFolders(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
requestedPath := strings.TrimSpace(r.URL.Query().Get("path"))
if requestedPath == "" {
requestedPath = roots[0].Path
}
targetPath, err := cleanAbsolutePath(requestedPath)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Pfad")
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
return
}
resolvedTargetPath, err := resolveExistingPath(targetPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(resolvedTargetPath, roots) {
writeError(w, http.StatusForbidden, "Pfad verlässt den freigegebenen Bereich")
return
}
info, err := os.Stat(resolvedTargetPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
return
}
if !info.IsDir() {
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
return
}
entries, err := os.ReadDir(resolvedTargetPath)
if err != nil {
writeError(w, http.StatusForbidden, "Ordnerinhalt konnte nicht gelesen werden")
return
}
folders := make([]serverFolderNode, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
childPath := filepath.Join(resolvedTargetPath, entry.Name())
childPath, err = cleanAbsolutePath(childPath)
if err != nil {
continue
}
if !isPathAllowed(childPath, roots) {
continue
}
fileSystemName := entry.Name()
folders = append(folders, serverFolderNode{
Name: fileSystemName,
DisplayName: windowsDisplayNameForPath(childPath, fileSystemName),
Path: childPath,
HasChildren: folderHasVisibleSubdirectories(childPath, roots),
})
}
sort.SliceStable(folders, func(i int, j int) bool {
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
})
writeJSON(w, http.StatusOK, listServerFoldersResponse{
RootType: string(rootType),
Path: resolvedTargetPath,
ParentPath: allowedParentPath(resolvedTargetPath, roots),
Roots: roots,
Folders: folders,
})
}
func handleCreateServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var input createServerFolderRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
targetPath, err := resolveCreateFolderTarget(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Zielpfad ist nicht freigegeben")
return
}
parentPath := filepath.Dir(targetPath)
resolvedParentPath, err := resolveExistingPath(parentPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Übergeordneter Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Übergeordneter Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(resolvedParentPath, roots) {
writeError(w, http.StatusForbidden, "Übergeordneter Ordner ist nicht freigegeben")
return
}
parentInfo, err := os.Stat(resolvedParentPath)
if err != nil {
writeError(w, http.StatusBadRequest, "Übergeordneter Ordner konnte nicht gelesen werden")
return
}
if !parentInfo.IsDir() {
writeError(w, http.StatusBadRequest, "Übergeordneter Pfad ist kein Ordner")
return
}
if err := os.Mkdir(targetPath, 0750); err != nil {
if errors.Is(err, fs.ErrExist) {
writeError(w, http.StatusConflict, "Ordner existiert bereits")
return
}
writeError(w, http.StatusForbidden, "Ordner konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": serverFolderNode{
Name: filepath.Base(targetPath),
DisplayName: windowsDisplayNameForPath(targetPath, filepath.Base(targetPath)),
Path: targetPath,
HasChildren: false,
},
})
}
func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType {
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType")))
if value == "" {
value = strings.TrimSpace(strings.ToLower(r.URL.Query().Get("type")))
}
switch value {
case "archive", "archive-root", "archive_root":
return serverFolderRootTypeArchive
default:
return serverFolderRootTypeStorage
}
}
func getConfiguredServerFolderRoots(rawValue string, rootType serverFolderRootType) ([]serverFolderRoot, error) {
rawValue = strings.TrimSpace(rawValue)
if rawValue == "" {
return nil, fmt.Errorf("%s-root fehlt", rootType)
}
parts := strings.Split(rawValue, ";")
roots := make([]serverFolderRoot, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
label := ""
pathValue := part
if before, after, found := strings.Cut(part, "="); found {
label = strings.TrimSpace(before)
pathValue = strings.TrimSpace(after)
}
rootPath, err := cleanAbsolutePath(pathValue)
if err != nil {
return nil, fmt.Errorf("ungültiger Root-Pfad: %s", pathValue)
}
if label == "" {
label = rootPath
}
roots = append(roots, serverFolderRoot{
Label: label,
Path: rootPath,
})
}
if len(roots) == 0 {
return nil, fmt.Errorf("keine gültigen Root-Pfade konfiguriert")
}
return roots, nil
}
func cleanAbsolutePath(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("Pfad fehlt")
}
cleaned := filepath.Clean(value)
absolute, err := filepath.Abs(cleaned)
if err != nil {
return "", err
}
return absolute, nil
}
func resolveExistingPath(value string) (string, error) {
cleaned, err := cleanAbsolutePath(value)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
return "", err
}
return cleanAbsolutePath(resolved)
}
func resolveCreateFolderTarget(input createServerFolderRequest) (string, error) {
if strings.TrimSpace(input.Path) != "" {
return cleanAbsolutePath(input.Path)
}
parentPath := strings.TrimSpace(input.ParentPath)
folderName := strings.TrimSpace(input.Name)
if parentPath == "" {
return "", fmt.Errorf("parentPath fehlt")
}
if !isSafeSingleFolderName(folderName) {
return "", fmt.Errorf("Ordnername ist ungültig")
}
return cleanAbsolutePath(filepath.Join(parentPath, folderName))
}
func isSafeSingleFolderName(value string) bool {
value = strings.TrimSpace(value)
if value == "" || value == "." || value == ".." {
return false
}
if strings.ContainsAny(value, `/\`) {
return false
}
if strings.ContainsAny(value, `<>:"|?*`) {
return false
}
return true
}
func isPathAllowed(pathValue string, roots []serverFolderRoot) bool {
pathValue, err := cleanAbsolutePath(pathValue)
if err != nil {
return false
}
for _, root := range roots {
rootPath, err := cleanAbsolutePath(root.Path)
if err != nil {
continue
}
if pathWithinRoot(pathValue, rootPath) {
return true
}
}
return false
}
func pathWithinRoot(pathValue string, rootPath string) bool {
rel, err := filepath.Rel(rootPath, pathValue)
if err != nil {
return false
}
return rel == "." ||
(rel != ".." &&
!strings.HasPrefix(rel, ".."+string(filepath.Separator)) &&
!filepath.IsAbs(rel))
}
func allowedParentPath(pathValue string, roots []serverFolderRoot) string {
parentPath := filepath.Dir(pathValue)
if parentPath == pathValue {
return ""
}
if !isPathAllowed(parentPath, roots) {
return ""
}
return parentPath
}
func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot) bool {
entries, err := os.ReadDir(pathValue)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
childPath, err := cleanAbsolutePath(filepath.Join(pathValue, entry.Name()))
if err != nil {
continue
}
if isPathAllowed(childPath, roots) {
return true
}
}
return false
}
const (
shgfiDisplayName = 0x000000200
)
type shFileInfo struct {
HIcon uintptr
IIcon int32
DwAttributes uint32
SzDisplayName [260]uint16
SzTypeName [80]uint16
}
var (
shell32DLL = syscall.NewLazyDLL("shell32.dll")
procSHGetFileInfo = shell32DLL.NewProc("SHGetFileInfoW")
)
func windowsDisplayNameForPath(pathValue string, fallback string) string {
pathValue = strings.TrimSpace(pathValue)
fallback = strings.TrimSpace(fallback)
if pathValue == "" {
return fallback
}
pathPointer, err := syscall.UTF16PtrFromString(pathValue)
if err != nil {
return fallback
}
var info shFileInfo
result, _, _ := procSHGetFileInfo.Call(
uintptr(unsafe.Pointer(pathPointer)),
0,
uintptr(unsafe.Pointer(&info)),
unsafe.Sizeof(info),
shgfiDisplayName,
)
if result == 0 {
return fallback
}
displayName := strings.TrimSpace(syscall.UTF16ToString(info.SzDisplayName[:]))
if displayName == "" {
return fallback
}
return displayName
}
func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc {
requiredToken = strings.TrimSpace(requiredToken)
return func(w http.ResponseWriter, r *http.Request) {
if requiredToken == "" {
next(w, r)
return
}
token := strings.TrimSpace(r.Header.Get("X-Server-Folders-Token"))
if token == "" {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = strings.TrimSpace(authHeader[len("bearer "):])
}
}
if token != requiredToken {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
next(w, r)
}
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
func readJSON(r *http.Request, target any) error {
defer r.Body.Close()
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{
"error": message,
})
}

View File

@ -1,3 +1,5 @@
// backend\go.mod
module main
go 1.26.2
@ -9,13 +11,6 @@ require (
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
)
require (
github.com/jackc/pgconn v1.14.3
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect

View File

@ -3,17 +3,8 @@ 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=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
@ -26,7 +17,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@ -37,7 +27,6 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -24,6 +24,7 @@ type milestoneRuntimeConfig struct {
TokenType string
SkipTLSVerify bool
TokenExpiresAt *time.Time
ServerID string
}
type milestoneHardwarePatchDevice struct {
@ -59,6 +60,12 @@ type milestoneChildPatchDevice struct {
Enabled bool
}
type milestoneHardwareUpdateEnvelope struct {
Data map[string]any `json:"data"`
}
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
func (s *Server) getMilestoneChildPatchDevice(
ctx context.Context,
deviceID string,
@ -179,14 +186,6 @@ func milestoneCollectionForChildDeviceType(deviceType string) (string, error) {
return "cameras", nil
case "microphone", "microphones":
return "microphones", nil
case "input", "inputs":
return "inputs", nil
case "output", "outputs":
return "outputs", nil
case "speaker", "speakers":
return "speakers", nil
case "metadata":
return "metadata", nil
default:
return "", fmt.Errorf("unsupported milestone child device type: %s", deviceType)
}
@ -293,8 +292,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
if requestedDisplayName != nil {
nextDisplayName = strings.TrimSpace(*requestedDisplayName)
// Wenn der Client einen Namen sendet, immer an Milestone weitergeben.
payload["name"] = nextDisplayName
if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) {
@ -340,7 +337,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
nextIPAddress = strings.TrimSpace(addressValue)
}
// Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben.
payload["address"] = nextAddress
if nextIPAddress != strings.TrimSpace(device.IPAddress) {
@ -366,8 +362,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
if input.Enabled != nil {
nextEnabled = *input.Enabled
// Wenn der Client den Status sendet, immer an Milestone weitergeben.
payload["enabled"] = nextEnabled
if nextEnabled != device.MilestoneEnabled {
@ -489,29 +483,29 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
_, err = tx.Exec(
r.Context(),
`
UPDATE milestone_hardware_child_devices
SET
name = CASE
WHEN name = $2 THEN $3
WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1)
WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1)
ELSE name
END,
display_name = CASE
WHEN display_name = $2 THEN $3
WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1)
WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1)
ELSE display_name
END,
short_name = CASE
WHEN short_name = $2 THEN $3
WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1)
WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1)
ELSE short_name
END,
updated_at = now()
WHERE milestone_hardware_id = $1
`,
UPDATE milestone_hardware_child_devices
SET
name = CASE
WHEN name = $2 THEN $3
WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1)
WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1)
ELSE name
END,
display_name = CASE
WHEN display_name = $2 THEN $3
WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1)
WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1)
ELSE display_name
END,
short_name = CASE
WHEN short_name = $2 THEN $3
WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1)
WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1)
ELSE short_name
END,
updated_at = now()
WHERE milestone_hardware_id = $1
`,
device.MilestoneHardwareID,
device.MilestoneDisplayName,
nextDisplayName,
@ -636,12 +630,6 @@ func normalizeMilestonePort(value string) string {
return value
}
type milestoneHardwareUpdateEnvelope struct {
Data map[string]any `json:"data"`
}
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
func (s *Server) getMilestoneRuntimeConfig(
ctx context.Context,
) (milestoneRuntimeConfig, error) {
@ -687,7 +675,8 @@ func (s *Server) loadMilestoneRuntimeConfig(
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
COALESCE(token_type, ''),
skip_tls_verify,
token_expires_at
token_expires_at,
COALESCE(server_id, '')
FROM milestone_settings
WHERE id = 1
LIMIT 1
@ -699,6 +688,7 @@ func (s *Server) loadMilestoneRuntimeConfig(
&config.TokenType,
&config.SkipTLSVerify,
&tokenExpiresAt,
&config.ServerID,
)
if err != nil {
return config, err
@ -975,48 +965,56 @@ func putMilestoneHardware(
return nil
}
func replaceMilestoneDeviceBaseName(
cameraName string,
oldHardwareName string,
newHardwareName string,
) (string, bool) {
cameraName = strings.TrimSpace(cameraName)
oldHardwareName = strings.TrimSpace(oldHardwareName)
newHardwareName = strings.TrimSpace(newHardwareName)
func getMilestoneNamedDevice(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
deviceID string,
) (map[string]any, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" + collection + "/" + url.PathEscape(deviceID)
if cameraName == "" || newHardwareName == "" {
return cameraName, false
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
if oldHardwareName != "" {
if cameraName == oldHardwareName {
return newHardwareName, true
}
if strings.HasPrefix(cameraName, oldHardwareName) {
suffix := strings.TrimPrefix(cameraName, oldHardwareName)
if suffix == "" ||
strings.HasPrefix(suffix, " ") ||
strings.HasPrefix(suffix, "-") ||
strings.HasPrefix(suffix, "_") {
return newHardwareName + suffix, true
}
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
// Fallback für Fälle, in denen die Hardware bereits umbenannt wurde,
// die Kamera aber noch z. B. "Camera_102_Test - Kamera 1" heißt.
if baseName, suffix, found := strings.Cut(cameraName, " - "); found {
baseName = strings.TrimSpace(baseName)
suffix = strings.TrimSpace(suffix)
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
if baseName != "" && suffix != "" && baseName != newHardwareName {
return newHardwareName + " - " + suffix, true
}
client := milestoneHTTPClient(config.SkipTLSVerify)
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
return cameraName, false
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("milestone %s get failed: status=%d body=%s", collection, response.StatusCode, string(body))
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, err
}
if envelope.Data == nil {
return map[string]any{}, nil
}
return envelope.Data, nil
}
func milestoneStringValue(value any) string {
@ -1028,428 +1026,30 @@ func milestoneStringValue(value any) string {
return strings.TrimSpace(text)
}
type milestoneNamedDevice struct {
Collection string
ID string
Name string
DisplayName string
ShortName string
Enabled bool
}
func milestoneItemEnabledValue(item map[string]any) bool {
enabled := true
if value, ok := item["enabled"].(bool); ok {
enabled = value
}
if value, ok := item["disabled"].(bool); ok {
enabled = !value
}
return enabled
}
func milestoneNamedDevicesFromHardware(hardware map[string]any) []milestoneNamedDevice {
devices := []milestoneNamedDevice{}
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "cameras")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "microphones")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "inputs")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "outputs")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "speakers")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "metadata")...,
)
return devices
}
func milestoneNamedDevicesFromHardwareCollectionOnly(
hardware map[string]any,
collection string,
) []milestoneNamedDevice {
return milestoneNamedDevicesFromHardwareCollection(hardware, collection)
}
func milestoneNamedDevicesFromHardwareCollection(
hardware map[string]any,
collection string,
) []milestoneNamedDevice {
rawItems, ok := hardware[collection].([]any)
if !ok {
return nil
}
devices := make([]milestoneNamedDevice, 0, len(rawItems))
for _, rawItem := range rawItems {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
name := milestoneStringValue(item["name"])
displayName := milestoneStringValue(item["displayName"])
shortName := milestoneStringValue(item["shortName"])
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
devices = append(devices, milestoneNamedDevice{
Collection: collection,
ID: id,
Name: name,
DisplayName: displayName,
ShortName: shortName,
Enabled: enabled,
})
}
return devices
}
func mergeMilestoneNamedDevices(groups ...[]milestoneNamedDevice) []milestoneNamedDevice {
result := []milestoneNamedDevice{}
seen := map[string]bool{}
for _, group := range groups {
for _, device := range group {
key := device.Collection + ":" + device.ID
if device.ID == "" || seen[key] {
continue
}
seen[key] = true
result = append(result, device)
}
}
return result
}
func requestMilestoneNamedDevicesByHardware(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
hardwareID string,
) ([]milestoneNamedDevice, error) {
switch collection {
case "cameras", "microphones", "inputs", "outputs", "speakers", "metadata":
func milestoneBoolValue(value any) bool {
switch typedValue := value.(type) {
case bool:
return typedValue
default:
return nil, fmt.Errorf("unsupported milestone device collection: %s", collection)
return false
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" +
collection +
"?disabled"
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
requestURL,
nil,
)
if err != nil {
return nil, err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, newAppErrorf(
"milestone child device list failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"collection": collection,
"hardwareId": hardwareID,
},
"milestone child device list failed: collection=%s hardwareId=%s status=%d body=%s",
collection,
hardwareID,
response.StatusCode,
string(responseBody),
)
}
return milestoneNamedDevicesFromListResponse(responseBody, collection, hardwareID)
}
func milestoneNamedDevicesFromListResponse(
body []byte,
collection string,
hardwareID string,
) ([]milestoneNamedDevice, error) {
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, err
}
var rawItems []any
if array, ok := decoded["array"].([]any); ok {
rawItems = array
} else if array, ok := decoded["data"].([]any); ok {
rawItems = array
} else if data, ok := decoded["data"].(map[string]any); ok {
if array, ok := data["array"].([]any); ok {
rawItems = array
}
}
if len(rawItems) == 0 {
return nil, nil
}
devices := make([]milestoneNamedDevice, 0, len(rawItems))
hardwareID = strings.TrimSpace(hardwareID)
for _, rawItem := range rawItems {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
parentID := milestoneNamedDeviceParentID(item)
if parentID != hardwareID {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
displayName := milestoneStringValue(item["displayName"])
shortName := milestoneStringValue(item["shortName"])
name := milestoneStringValue(item["name"])
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
devices = append(devices, milestoneNamedDevice{
Collection: collection,
ID: id,
Name: name,
DisplayName: displayName,
ShortName: shortName,
Enabled: enabled,
})
}
return devices, nil
}
func milestoneNamedDeviceParentID(item map[string]any) string {
relations, ok := item["relations"].(map[string]any)
if !ok {
return ""
}
parent, ok := relations["parent"].(map[string]any)
if !ok {
return ""
}
return milestoneStringValue(parent["id"])
}
func milestoneNamedDeviceID(item map[string]any) string {
if id := milestoneStringValue(item["id"]); id != "" {
return id
}
relations, ok := item["relations"].(map[string]any)
if !ok {
return ""
}
self, ok := relations["self"].(map[string]any)
if !ok {
return ""
}
return milestoneStringValue(self["id"])
}
func updateMilestoneChildDeviceBaseNames(
ctx context.Context,
config milestoneRuntimeConfig,
devices []milestoneNamedDevice,
oldHardwareName string,
newHardwareName string,
) error {
for _, device := range devices {
nextName, changed := replaceMilestoneDeviceBaseName(
device.Name,
oldHardwareName,
newHardwareName,
)
if !changed {
continue
}
if err := patchMilestoneNamedDevice(
ctx,
config,
device.Collection,
device.ID,
map[string]any{
"name": nextName,
"displayName": nextName,
"shortName": nextName,
},
); err != nil {
return err
}
}
return nil
}
func patchMilestoneNamedDevice(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
deviceID string,
payload map[string]any,
) error {
switch collection {
case "cameras", "microphones":
func milestoneIntPointerValue(value any) (*int, bool) {
switch typedValue := value.(type) {
case int:
v := typedValue
return &v, true
case int32:
v := int(typedValue)
return &v, true
case int64:
v := int(typedValue)
return &v, true
case float64:
v := int(typedValue)
return &v, true
default:
return fmt.Errorf("unsupported milestone device collection: %s", collection)
return nil, false
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" +
collection +
"/" +
url.PathEscape(deviceID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPatch,
requestURL,
bytes.NewReader(body),
)
if err != nil {
return err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return newAppErrorf(
"milestone child device patch failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"collection": collection,
"deviceId": deviceID,
"payload": payload,
},
"milestone child device patch failed: collection=%s id=%s status=%d body=%s",
collection,
deviceID,
response.StatusCode,
string(responseBody),
)
}
return nil
}

View File

@ -0,0 +1,457 @@
// backend\milestone_cameras.go
package main
import (
"context"
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
)
type milestoneCameraPatchDevice struct {
DeviceID string
InventoryNumber string
ChildID string
MilestoneHardwareID string
MilestoneDeviceID string
DeviceType string
Enabled bool
RecordingEnabled bool
}
type milestoneRecordingStorageRef struct {
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
Name string `json:"name,omitempty"`
DiskPath string `json:"diskPath,omitempty"`
Mounted bool `json:"mounted,omitempty"`
}
type milestoneCameraStream struct {
Index int `json:"index"`
Codec string `json:"codec"`
FPS *int `json:"fps"`
JPEGQuality *int `json:"jPEGQuality"`
Quality *int `json:"quality"`
Resolution string `json:"resolution"`
EdgeStorageSupported bool `json:"edgeStorageSupported"`
}
type milestoneCameraDetail struct {
ID string `json:"id"`
MilestoneDeviceID string `json:"milestoneDeviceId"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
Enabled bool `json:"enabled"`
RecordingEnabled bool `json:"recordingEnabled"`
RecordingFramerate *int `json:"recordingFramerate"`
RecordKeyframesOnly bool `json:"recordKeyframesOnly"`
RecordingStorage *milestoneRecordingStorageRef `json:"recordingStorage,omitempty"`
ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"`
Streams []milestoneCameraStream `json:"streams"`
}
type updateMilestoneCameraStreamRequest struct {
Index int `json:"index"`
Codec string `json:"codec"`
FPS *int `json:"fps"`
JPEGQuality *int `json:"jPEGQuality"`
Quality *int `json:"quality"`
Resolution string `json:"resolution"`
}
type updateMilestoneCameraDetailsRequest struct {
Enabled *bool `json:"enabled"`
RecordingEnabled *bool `json:"recordingEnabled"`
RecordingFramerate *int `json:"recordingFramerate"`
RecordKeyframesOnly *bool `json:"recordKeyframesOnly"`
RecordingStorageID *string `json:"recordingStorageId"`
Streams []updateMilestoneCameraStreamRequest `json:"streams"`
}
func (s *Server) getMilestoneCameraPatchDevice(
ctx context.Context,
deviceID string,
childID string,
) (milestoneCameraPatchDevice, error) {
var camera milestoneCameraPatchDevice
err := s.db.QueryRow(
ctx,
`
SELECT
devices.id::TEXT,
devices.inventory_number,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.milestone_device_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.enabled,
COALESCE(milestone_hardware_child_devices.recording_enabled, false)
FROM milestone_hardware_child_devices
INNER JOIN devices
ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id
WHERE devices.id = $1
AND milestone_hardware_child_devices.id = $2
LIMIT 1
`,
deviceID,
childID,
).Scan(
&camera.DeviceID,
&camera.InventoryNumber,
&camera.ChildID,
&camera.MilestoneHardwareID,
&camera.MilestoneDeviceID,
&camera.DeviceType,
&camera.Enabled,
&camera.RecordingEnabled,
)
return camera, err
}
func (s *Server) handleGetMilestoneCameraDetails(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" &&
strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" {
writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
cameraData, err := getMilestoneNamedDevice(
r.Context(),
config,
"cameras",
cameraDevice.MilestoneDeviceID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden")
return
}
archiveStorages := []milestoneArchiveStorage{}
var currentStorage *milestoneStorage
if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok {
recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"]))
if recordingStorageID != "" {
currentStorage, err = getMilestoneStorage(
r.Context(),
config,
recordingStorageID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Storage konnte nicht geladen werden")
return
}
archiveStorages, err = getMilestoneArchiveStorages(
r.Context(),
config,
recordingStorageID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Archive-Storage konnte nicht geladen werden")
return
}
}
}
camera := mapMilestoneCameraDetail(cameraData, cameraDevice, currentStorage, archiveStorages)
writeJSON(w, http.StatusOK, map[string]any{
"camera": camera,
})
}
func (s *Server) handleUpdateMilestoneCameraDetails(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" &&
strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" {
writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera")
return
}
var input updateMilestoneCameraDetailsRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
currentCamera, err := getMilestoneNamedDevice(
r.Context(),
config,
"cameras",
cameraDevice.MilestoneDeviceID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden")
return
}
if input.Enabled != nil {
currentCamera["enabled"] = *input.Enabled
}
if input.RecordingEnabled != nil {
currentCamera["recordingEnabled"] = *input.RecordingEnabled
}
if input.RecordingFramerate != nil {
currentCamera["recordingFramerate"] = *input.RecordingFramerate
}
if input.RecordKeyframesOnly != nil {
currentCamera["recordKeyframesOnly"] = *input.RecordKeyframesOnly
}
if input.RecordingStorageID != nil {
recordingStorageID := strings.TrimSpace(*input.RecordingStorageID)
if recordingStorageID == "" {
currentCamera["recordingStorage"] = nil
} else {
currentCamera["recordingStorage"] = map[string]any{
"type": "storages",
"id": recordingStorageID,
}
}
}
applyMilestoneCameraStreamUpdates(currentCamera, input.Streams)
if err := patchMilestoneNamedDevice(
r.Context(),
config,
"cameras",
cameraDevice.MilestoneDeviceID,
currentCamera,
); err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht aktualisiert werden")
return
}
_, err = s.db.Exec(
r.Context(),
`
UPDATE milestone_hardware_child_devices
SET
enabled = COALESCE($2, enabled),
recording_enabled = COALESCE($3, recording_enabled),
recording_storage_id = COALESCE($4, recording_storage_id),
updated_at = now()
WHERE id = $1
`,
cameraDevice.ChildID,
input.Enabled,
input.RecordingEnabled,
input.RecordingStorageID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte lokal nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func mapMilestoneCameraDetail(
cameraData map[string]any,
cameraDevice milestoneCameraPatchDevice,
currentStorage *milestoneStorage,
archiveStorages []milestoneArchiveStorage,
) milestoneCameraDetail {
detail := milestoneCameraDetail{
ID: cameraDevice.ChildID,
MilestoneDeviceID: cameraDevice.MilestoneDeviceID,
MilestoneHardwareID: cameraDevice.MilestoneHardwareID,
Enabled: milestoneBoolValue(cameraData["enabled"]),
RecordingEnabled: milestoneBoolValue(cameraData["recordingEnabled"]),
RecordKeyframesOnly: milestoneBoolValue(cameraData["recordKeyframesOnly"]),
Streams: extractMilestoneCameraStreams(cameraData),
ArchiveStorages: archiveStorages,
}
if value, ok := milestoneIntPointerValue(cameraData["recordingFramerate"]); ok {
detail.RecordingFramerate = value
}
if currentStorage != nil && strings.TrimSpace(currentStorage.ID) != "" {
detail.RecordingStorage = &milestoneRecordingStorageRef{
ID: currentStorage.ID,
DisplayName: currentStorage.DisplayName,
Name: currentStorage.Name,
DiskPath: currentStorage.DiskPath,
Mounted: currentStorage.Mounted,
}
} else if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok {
recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"]))
if recordingStorageID != "" {
detail.RecordingStorage = &milestoneRecordingStorageRef{
ID: recordingStorageID,
}
}
}
return detail
}
func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraStream {
settings, ok := cameraData["settings"].([]any)
if !ok {
return []milestoneCameraStream{}
}
streams := []milestoneCameraStream{}
for _, rawSetting := range settings {
setting, ok := rawSetting.(map[string]any)
if !ok {
continue
}
rawStreams, ok := setting["stream"].([]any)
if !ok {
continue
}
for index, rawStream := range rawStreams {
streamMap, ok := rawStream.(map[string]any)
if !ok {
continue
}
stream := milestoneCameraStream{
Index: index,
Codec: milestoneStringValue(streamMap["codec"]),
Resolution: milestoneStringValue(streamMap["resolution"]),
EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]),
}
if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok {
stream.FPS = value
}
if value, ok := milestoneIntPointerValue(streamMap["jPEGQuality"]); ok {
stream.JPEGQuality = value
}
if value, ok := milestoneIntPointerValue(streamMap["quality"]); ok {
stream.Quality = value
}
streams = append(streams, stream)
}
}
return streams
}
func applyMilestoneCameraStreamUpdates(
cameraData map[string]any,
streamUpdates []updateMilestoneCameraStreamRequest,
) {
if len(streamUpdates) == 0 {
return
}
settings, ok := cameraData["settings"].([]any)
if !ok {
return
}
updatesByIndex := map[int]updateMilestoneCameraStreamRequest{}
for _, streamUpdate := range streamUpdates {
updatesByIndex[streamUpdate.Index] = streamUpdate
}
for _, rawSetting := range settings {
setting, ok := rawSetting.(map[string]any)
if !ok {
continue
}
rawStreams, ok := setting["stream"].([]any)
if !ok {
continue
}
for index, rawStream := range rawStreams {
streamMap, ok := rawStream.(map[string]any)
if !ok {
continue
}
update, exists := updatesByIndex[index]
if !exists {
continue
}
streamMap["codec"] = update.Codec
streamMap["resolution"] = update.Resolution
if update.FPS != nil {
streamMap["FPS"] = *update.FPS
}
if update.JPEGQuality != nil {
streamMap["jPEGQuality"] = *update.JPEGQuality
}
if update.Quality != nil {
streamMap["quality"] = *update.Quality
}
}
}
}

View File

@ -0,0 +1,481 @@
// backend\milestone_child_devices.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
type milestoneNamedDevice struct {
Collection string
ID string
Name string
DisplayName string
ShortName string
Enabled bool
RecordingEnabled bool
RecordingStorageID string
}
func replaceMilestoneDeviceBaseName(
deviceName string,
oldHardwareName string,
newHardwareName string,
) (string, bool) {
deviceName = strings.TrimSpace(deviceName)
oldHardwareName = strings.TrimSpace(oldHardwareName)
newHardwareName = strings.TrimSpace(newHardwareName)
if deviceName == "" || newHardwareName == "" {
return deviceName, false
}
if oldHardwareName != "" {
if deviceName == oldHardwareName {
return newHardwareName, true
}
if strings.HasPrefix(deviceName, oldHardwareName) {
suffix := strings.TrimPrefix(deviceName, oldHardwareName)
if suffix == "" ||
strings.HasPrefix(suffix, " ") ||
strings.HasPrefix(suffix, "-") ||
strings.HasPrefix(suffix, "_") {
return newHardwareName + suffix, true
}
}
}
if baseName, suffix, found := strings.Cut(deviceName, " - "); found {
baseName = strings.TrimSpace(baseName)
suffix = strings.TrimSpace(suffix)
if baseName != "" && suffix != "" && baseName != newHardwareName {
return newHardwareName + " - " + suffix, true
}
}
return deviceName, false
}
func milestoneItemEnabledValue(item map[string]any) bool {
enabled := true
if value, ok := item["enabled"].(bool); ok {
enabled = value
}
if value, ok := item["disabled"].(bool); ok {
enabled = !value
}
return enabled
}
func milestoneNamedDevicesFromHardware(hardware map[string]any) []milestoneNamedDevice {
devices := []milestoneNamedDevice{}
devices = append(devices, milestoneNamedDevicesFromHardwareCollection(hardware, "cameras")...)
devices = append(devices, milestoneNamedDevicesFromHardwareCollection(hardware, "microphones")...)
return devices
}
func milestoneNamedDevicesFromHardwareCollectionOnly(
hardware map[string]any,
collection string,
) []milestoneNamedDevice {
return milestoneNamedDevicesFromHardwareCollection(hardware, collection)
}
func milestoneNamedDevicesFromHardwareCollection(
hardware map[string]any,
collection string,
) []milestoneNamedDevice {
rawItems, ok := hardware[collection].([]any)
if !ok {
return nil
}
devices := make([]milestoneNamedDevice, 0, len(rawItems))
for _, rawItem := range rawItems {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
name := milestoneStringValue(item["name"])
displayName := milestoneStringValue(item["displayName"])
shortName := milestoneStringValue(item["shortName"])
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
recordingEnabled := false
if value, ok := item["recordingEnabled"].(bool); ok {
recordingEnabled = value
}
recordingStorageID := ""
if recordingStorage, ok := item["recordingStorage"].(map[string]any); ok {
recordingStorageID = strings.TrimSpace(milestoneStringValue(recordingStorage["id"]))
}
devices = append(devices, milestoneNamedDevice{
Collection: collection,
ID: id,
Name: name,
DisplayName: displayName,
ShortName: shortName,
Enabled: enabled,
RecordingEnabled: recordingEnabled,
RecordingStorageID: recordingStorageID,
})
}
return devices
}
func mergeMilestoneNamedDevices(groups ...[]milestoneNamedDevice) []milestoneNamedDevice {
result := []milestoneNamedDevice{}
seen := map[string]bool{}
for _, group := range groups {
for _, device := range group {
key := device.Collection + ":" + device.ID
if device.ID == "" || seen[key] {
continue
}
seen[key] = true
result = append(result, device)
}
}
return result
}
func requestMilestoneNamedDevicesByHardware(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
hardwareID string,
) ([]milestoneNamedDevice, error) {
switch collection {
case "cameras", "microphones":
default:
return nil, fmt.Errorf("unsupported milestone device collection: %s", collection)
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" +
collection +
"?disabled"
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
requestURL,
nil,
)
if err != nil {
return nil, err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, newAppErrorf(
"milestone child device list failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"collection": collection,
"hardwareId": hardwareID,
},
"milestone child device list failed: collection=%s hardwareId=%s status=%d body=%s",
collection,
hardwareID,
response.StatusCode,
string(responseBody),
)
}
return milestoneNamedDevicesFromListResponse(responseBody, collection, hardwareID)
}
func milestoneNamedDevicesFromListResponse(
body []byte,
collection string,
hardwareID string,
) ([]milestoneNamedDevice, error) {
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, err
}
var rawItems []any
if array, ok := decoded["array"].([]any); ok {
rawItems = array
} else if array, ok := decoded["data"].([]any); ok {
rawItems = array
} else if data, ok := decoded["data"].(map[string]any); ok {
if array, ok := data["array"].([]any); ok {
rawItems = array
}
}
if len(rawItems) == 0 {
return nil, nil
}
devices := make([]milestoneNamedDevice, 0, len(rawItems))
hardwareID = strings.TrimSpace(hardwareID)
for _, rawItem := range rawItems {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
parentID := milestoneNamedDeviceParentID(item)
if parentID != hardwareID {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
displayName := milestoneStringValue(item["displayName"])
shortName := milestoneStringValue(item["shortName"])
name := milestoneStringValue(item["name"])
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
recordingEnabled := false
if value, ok := item["recordingEnabled"].(bool); ok {
recordingEnabled = value
}
recordingStorageID := ""
if recordingStorage, ok := item["recordingStorage"].(map[string]any); ok {
recordingStorageID = strings.TrimSpace(milestoneStringValue(recordingStorage["id"]))
}
devices = append(devices, milestoneNamedDevice{
Collection: collection,
ID: id,
Name: name,
DisplayName: displayName,
ShortName: shortName,
Enabled: enabled,
RecordingEnabled: recordingEnabled,
RecordingStorageID: recordingStorageID,
})
}
return devices, nil
}
func milestoneNamedDeviceParentID(item map[string]any) string {
relations, ok := item["relations"].(map[string]any)
if !ok {
return ""
}
parent, ok := relations["parent"].(map[string]any)
if !ok {
return ""
}
return milestoneStringValue(parent["id"])
}
func milestoneNamedDeviceID(item map[string]any) string {
if id := milestoneStringValue(item["id"]); id != "" {
return id
}
relations, ok := item["relations"].(map[string]any)
if !ok {
return ""
}
self, ok := relations["self"].(map[string]any)
if !ok {
return ""
}
return milestoneStringValue(self["id"])
}
func updateMilestoneChildDeviceBaseNames(
ctx context.Context,
config milestoneRuntimeConfig,
devices []milestoneNamedDevice,
oldHardwareName string,
newHardwareName string,
) error {
for _, device := range devices {
nextName, changed := replaceMilestoneDeviceBaseName(
device.Name,
oldHardwareName,
newHardwareName,
)
if !changed {
continue
}
if err := patchMilestoneNamedDevice(
ctx,
config,
device.Collection,
device.ID,
map[string]any{
"name": nextName,
"displayName": nextName,
"shortName": nextName,
},
); err != nil {
return err
}
}
return nil
}
func patchMilestoneNamedDevice(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
deviceID string,
payload map[string]any,
) error {
switch collection {
case "cameras", "microphones":
default:
return fmt.Errorf("unsupported milestone device collection: %s", collection)
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" +
collection +
"/" +
url.PathEscape(deviceID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPatch,
requestURL,
bytes.NewReader(body),
)
if err != nil {
return err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return newAppErrorf(
"milestone child device patch failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"collection": collection,
"deviceId": deviceID,
"payload": payload,
},
"milestone child device patch failed: collection=%s id=%s status=%d body=%s",
collection,
deviceID,
response.StatusCode,
string(responseBody),
)
}
return nil
}

View File

@ -1,490 +0,0 @@
// backend\milestone_devices.go
package main
import (
"context"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
)
func syncMilestoneHardwareDevices(
ctx context.Context,
tx pgx.Tx,
hardwareDevices []milestoneHardwareDevice,
) (int, error) {
syncedCount := 0
for _, hardwareDevice := range hardwareDevices {
hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID)
hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID)
hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName)
hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName)
hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer)
hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName)
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port)
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
if hardwareDevice.HardwareID == "" {
continue
}
if hardwareDevice.Manufacturer != "" {
if err := ensureOptionalDeviceLookupValue(
ctx,
tx,
"device_manufacturers",
hardwareDevice.Manufacturer,
); err != nil {
return syncedCount, err
}
}
synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice)
if err != nil {
return syncedCount, err
}
if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil {
return syncedCount, err
}
if synced {
syncedCount++
}
}
return syncedCount, nil
}
func syncMilestoneHardwareChildDevices(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) error {
hardwareID := strings.TrimSpace(hardwareDevice.HardwareID)
if hardwareID == "" {
return nil
}
_, err := tx.Exec(
ctx,
`
DELETE FROM milestone_hardware_child_devices
WHERE milestone_hardware_id = $1
`,
hardwareID,
)
if err != nil {
return err
}
for _, camera := range hardwareDevice.Cameras {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"camera",
camera,
); err != nil {
return err
}
}
for _, microphone := range hardwareDevice.Microphones {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"microphone",
microphone,
); err != nil {
return err
}
}
for _, input := range hardwareDevice.Inputs {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"input",
input,
); err != nil {
return err
}
}
for _, output := range hardwareDevice.Outputs {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"output",
output,
); err != nil {
return err
}
}
for _, speaker := range hardwareDevice.Speakers {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"speaker",
speaker,
); err != nil {
return err
}
}
for _, metadata := range hardwareDevice.Metadata {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"metadata",
metadata,
); err != nil {
return err
}
}
return nil
}
func upsertMilestoneHardwareChildDevice(
ctx context.Context,
tx pgx.Tx,
hardwareID string,
deviceType string,
childDevice milestoneNamedDevice,
) error {
milestoneDeviceID := strings.TrimSpace(childDevice.ID)
if milestoneDeviceID == "" {
return nil
}
name := strings.TrimSpace(childDevice.Name)
displayName := strings.TrimSpace(childDevice.DisplayName)
shortName := strings.TrimSpace(childDevice.ShortName)
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
_, err := tx.Exec(
ctx,
`
INSERT INTO milestone_hardware_child_devices (
milestone_hardware_id,
milestone_device_id,
device_type,
name,
display_name,
short_name,
enabled
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (milestone_hardware_id, milestone_device_id, device_type)
DO UPDATE SET
name = EXCLUDED.name,
display_name = EXCLUDED.display_name,
short_name = EXCLUDED.short_name,
enabled = EXCLUDED.enabled,
updated_at = now()
`,
hardwareID,
milestoneDeviceID,
deviceType,
name,
displayName,
shortName,
childDevice.Enabled,
)
return err
}
func createOrUpdateMilestoneHardwareDevice(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) (bool, error) {
inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice)
if err != nil {
return false, err
}
commandTag, err := tx.Exec(
ctx,
`
INSERT INTO devices (
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
ip_address,
milestone_port,
location,
loan_status,
is_bao_device,
comment,
firmware_version,
milestone_recording_server_id,
milestone_hardware_id,
milestone_display_name,
milestone_enabled,
milestone_synced_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
'',
'Verfügbar',
false,
'',
$8,
$9,
$10,
$11,
$12,
now()
)
ON CONFLICT (milestone_hardware_id)
WHERE milestone_hardware_id <> ''
DO UPDATE SET
inventory_number = EXCLUDED.inventory_number,
manufacturer = EXCLUDED.manufacturer,
model = EXCLUDED.model,
serial_number = EXCLUDED.serial_number,
mac_address = EXCLUDED.mac_address,
ip_address = EXCLUDED.ip_address,
milestone_port = EXCLUDED.milestone_port,
firmware_version = EXCLUDED.firmware_version,
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
milestone_display_name = EXCLUDED.milestone_display_name,
milestone_enabled = EXCLUDED.milestone_enabled,
milestone_synced_at = now(),
updated_at = now()
`,
inventoryNumber,
hardwareDevice.Manufacturer,
hardwareDevice.DeviceModelName,
hardwareDevice.SerialNumber,
hardwareDevice.MacAddress,
hardwareDevice.IPAddress,
hardwareDevice.Port,
hardwareDevice.FirmwareVersion,
hardwareDevice.RecordingServerID,
hardwareDevice.HardwareID,
hardwareDevice.DisplayName,
hardwareDevice.Enabled,
)
if err != nil {
return false, err
}
return commandTag.RowsAffected() > 0, nil
}
func normalizeMilestoneManufacturer(value string, model string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
model = strings.TrimSpace(model)
normalizedValue := strings.ToLower(value)
if strings.EqualFold(value, model) ||
strings.HasSuffix(normalizedValue, " device") ||
strings.Contains(normalizedValue, " camera") ||
strings.Contains(normalizedValue, " encoder") ||
strings.Contains(normalizedValue, " video") {
if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" {
return manufacturer
}
}
return value
}
func extractMilestoneManufacturerFromName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return unicode.IsSpace(r) ||
r == '_' ||
r == '-' ||
r == '/' ||
r == '\\' ||
r == '(' ||
r == ')' ||
r == ','
})
ignoredWords := map[string]bool{
"device": true,
"camera": true,
"video": true,
"encoder": true,
"ip": true,
"network": true,
"hardware": true,
}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if isOnlyDigits(part) {
continue
}
if ignoredWords[strings.ToLower(part)] {
continue
}
return part
}
return ""
}
func buildMilestoneInventoryNumber(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) (string, error) {
inventoryNumber := extractInventoryNumberFromMilestoneDeviceName(
hardwareDevice.DeviceName,
)
if inventoryNumber == "" {
inventoryNumber = extractInventoryNumberFromMilestoneDeviceName(
hardwareDevice.DisplayName,
)
}
if inventoryNumber == "" {
inventoryNumber = buildGeneratedMilestoneInventoryNumber(
hardwareDevice.HardwareID,
)
}
var exists bool
err := tx.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM devices
WHERE lower(inventory_number) = lower($1)
AND milestone_hardware_id <> $2
)
`,
inventoryNumber,
hardwareDevice.HardwareID,
).Scan(&exists)
if err != nil {
return "", err
}
if !exists {
return inventoryNumber, nil
}
suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", ""))
if len(suffix) > 8 {
suffix = suffix[:8]
}
if suffix == "" {
suffix = "MS"
}
return inventoryNumber + "-" + suffix, nil
}
func extractInventoryNumberFromMilestoneDeviceName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == '_' || r == '-' || unicode.IsSpace(r)
})
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if isOnlyDigits(part) {
return part
}
}
return ""
}
func isOnlyDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", ""))
if len(value) > 12 {
value = value[:12]
}
if value == "" {
value = "UNKNOWN"
}
return "MS-" + value
}

View File

@ -0,0 +1,190 @@
// backend\milestone_inventory.go
package main
import (
"context"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
)
func normalizeMilestoneManufacturer(value string, model string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
model = strings.TrimSpace(model)
normalizedValue := strings.ToLower(value)
if strings.EqualFold(value, model) ||
strings.HasSuffix(normalizedValue, " device") ||
strings.Contains(normalizedValue, " camera") ||
strings.Contains(normalizedValue, " encoder") ||
strings.Contains(normalizedValue, " video") {
if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" {
return manufacturer
}
}
return value
}
func extractMilestoneManufacturerFromName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return unicode.IsSpace(r) ||
r == '_' ||
r == '-' ||
r == '/' ||
r == '\\' ||
r == '(' ||
r == ')' ||
r == ','
})
ignoredWords := map[string]bool{
"device": true,
"camera": true,
"video": true,
"encoder": true,
"ip": true,
"network": true,
"hardware": true,
}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if isOnlyDigits(part) {
continue
}
if ignoredWords[strings.ToLower(part)] {
continue
}
return part
}
return ""
}
func buildMilestoneInventoryNumber(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) (string, error) {
inventoryNumber := extractInventoryNumberFromMilestoneDeviceName(
hardwareDevice.DeviceName,
)
if inventoryNumber == "" {
inventoryNumber = extractInventoryNumberFromMilestoneDeviceName(
hardwareDevice.DisplayName,
)
}
if inventoryNumber == "" {
inventoryNumber = buildGeneratedMilestoneInventoryNumber(
hardwareDevice.HardwareID,
)
}
var exists bool
err := tx.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM devices
WHERE lower(inventory_number) = lower($1)
AND milestone_hardware_id <> $2
)
`,
inventoryNumber,
hardwareDevice.HardwareID,
).Scan(&exists)
if err != nil {
return "", err
}
if !exists {
return inventoryNumber, nil
}
suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", ""))
if len(suffix) > 8 {
suffix = suffix[:8]
}
if suffix == "" {
suffix = "MS"
}
return inventoryNumber + "-" + suffix, nil
}
func extractInventoryNumberFromMilestoneDeviceName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == '_' || r == '-' || unicode.IsSpace(r)
})
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if isOnlyDigits(part) {
return part
}
}
return ""
}
func isOnlyDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", ""))
if len(value) > 12 {
value = value[:12]
}
if value == "" {
value = "UNKNOWN"
}
return "MS-" + value
}

1119
backend/milestone_storage.go Normal file

File diff suppressed because it is too large Load Diff

1107
backend/milestone_sync.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,9 @@
package main
import "net/http"
import (
"net/http"
)
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
@ -40,6 +42,15 @@ func (s *Server) Routes() http.Handler {
s.requireAuth(s.handleUpdateMilestoneChildDevice),
)
mux.HandleFunc(
"GET /devices/{id}/milestone-cameras/{childId}",
s.requireAuth(s.handleGetMilestoneCameraDetails),
)
mux.HandleFunc(
"PATCH /devices/{id}/milestone-cameras/{childId}",
s.requireAuth(s.handleUpdateMilestoneCameraDetails),
)
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
@ -103,6 +114,39 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
mux.HandleFunc(
"GET /admin/server-folders",
s.requireAuth(s.requireRight("administration:read", s.handleProxyListServerFolders)),
)
mux.HandleFunc(
"POST /admin/server-folders",
s.requireAuth(s.requireRight("administration:write", s.handleProxyCreateServerFolder)),
)
mux.HandleFunc(
"GET /admin/milestone/storages",
s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneStorages)),
)
mux.HandleFunc(
"POST /admin/milestone/storages",
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneStorageWithArchive)),
)
mux.HandleFunc(
"POST /admin/milestone/storages/{id}/archive-storages",
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneArchiveStorage)),
)
mux.HandleFunc(
"POST /admin/milestone/storages/{id}/archive-storages/auto",
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneAutoArchiveStorage)),
)
mux.HandleFunc(
"PATCH /admin/milestone/archive-storages/{id}",
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)),
)
mux.HandleFunc(
"GET /admin/milestone/storage-information/{id}",
s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)),
)
return s.cors(mux)
}

View File

@ -0,0 +1,213 @@
// backend/server_folders_proxy.go
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type serverFoldersAgentConfig struct {
MilestoneHost string
Scheme string
Port string
Token string
}
func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) {
s.proxyServerFoldersRequest(w, r, http.MethodGet)
}
func (s *Server) handleProxyCreateServerFolder(w http.ResponseWriter, r *http.Request) {
s.proxyServerFoldersRequest(w, r, http.MethodPost)
}
func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) {
config, err := s.getServerFoldersAgentConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Server-Folders-Agent-Konfiguration konnte nicht geladen werden")
return
}
targetURL, err := serverFoldersAgentURL(config, forwardedServerFoldersQuery(r.URL.Query()))
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
var body []byte
if method == http.MethodPost {
body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden")
return
}
}
request, err := http.NewRequestWithContext(
r.Context(),
method,
targetURL,
bytes.NewReader(body),
)
if err != nil {
writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden")
return
}
request.Header.Set("Accept", "application/json")
if method == http.MethodPost {
request.Header.Set("Content-Type", "application/json")
}
if config.Token != "" {
request.Header.Set("X-Server-Folders-Token", config.Token)
}
client := &http.Client{
Timeout: 20 * time.Second,
}
response, err := client.Do(request)
if err != nil {
writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar")
return
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
writeError(w, http.StatusBadGateway, "Antwort vom Server-Folders-Agent konnte nicht gelesen werden")
return
}
contentType := response.Header.Get("Content-Type")
if strings.TrimSpace(contentType) == "" {
contentType = "application/json"
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(response.StatusCode)
_, _ = w.Write(responseBody)
}
func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) {
var config serverFoldersAgentConfig
err := s.db.QueryRow(
ctx,
`
SELECT
host,
COALESCE(server_folders_agent_scheme, 'http'),
COALESCE(server_folders_agent_port, '8099'),
COALESCE(pgp_sym_decrypt(server_folders_agent_token_encrypted, $1), '')
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
s.milestoneEncryptionKey(),
).Scan(
&config.MilestoneHost,
&config.Scheme,
&config.Port,
&config.Token,
)
if err != nil {
return config, err
}
config.Scheme = normalizeServerFoldersAgentScheme(config.Scheme)
if config.Scheme == "" {
config.Scheme = "http"
}
config.Port = normalizeServerFoldersAgentPort(config.Port)
if config.Port == "" {
config.Port = "8099"
}
config.MilestoneHost = strings.TrimSpace(config.MilestoneHost)
config.Token = strings.TrimSpace(config.Token)
return config, nil
}
func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (string, error) {
if strings.TrimSpace(config.MilestoneHost) == "" {
return "", fmt.Errorf("Milestone Host fehlt")
}
normalizedMilestoneHost := strings.TrimSpace(config.MilestoneHost)
if !strings.Contains(normalizedMilestoneHost, "://") {
normalizedMilestoneHost = "http://" + normalizedMilestoneHost
}
parsedMilestoneURL, err := url.Parse(normalizedMilestoneHost)
if err != nil || parsedMilestoneURL.Hostname() == "" {
return "", fmt.Errorf("Milestone Host ist ungültig")
}
agentURL := url.URL{
Scheme: config.Scheme,
Host: parsedMilestoneURL.Hostname() + ":" + config.Port,
Path: "/server-folders",
RawQuery: rawQuery,
}
return agentURL.String(), nil
}
func forwardedServerFoldersQuery(values url.Values) string {
// rootType=storage/archive wird bewusst weitergeleitet.
// Nur Verbindungs-/Auth-Parameter werden entfernt.
nextValues := url.Values{}
for key, value := range values {
switch strings.ToLower(strings.TrimSpace(key)) {
case "schema", "scheme", "agentschema", "agentscheme", "port", "agentport", "token", "agenttoken":
continue
default:
for _, item := range value {
nextValues.Add(key, item)
}
}
}
return nextValues.Encode()
}
func normalizeServerFoldersAgentScheme(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "", "http", "https":
return value
default:
return ""
}
}
func normalizeServerFoldersAgentPort(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, ":")
if value == "" {
return ""
}
for _, char := range value {
if char < '0' || char > '9' {
return ""
}
}
return value
}

View File

@ -358,6 +358,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
display_name TEXT NOT NULL DEFAULT '',
short_name TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT true,
recording_enabled BOOLEAN NOT NULL DEFAULT false,
recording_storage_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -498,11 +500,30 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
server_version TEXT NOT NULL DEFAULT '',
server_updated_at TIMESTAMPTZ,
server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http',
server_folders_agent_port TEXT NOT NULL DEFAULT '8099',
server_folders_agent_token_encrypted BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_agent_port TEXT NOT NULL DEFAULT '8099'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA
`,
`
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
@ -608,6 +629,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_hardware_id ON milestone_hardware_child_devices(milestone_hardware_id)`,
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_device_id ON milestone_hardware_child_devices(milestone_device_id)`,
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_recording_storage_id ON milestone_hardware_child_devices(recording_storage_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,

View File

@ -18,6 +18,7 @@ import NotificationCenter from './components/NotificationCenter'
import type { Notification, User } from './components/types'
import AdministrationPage from './pages/administration/AdministrationPage'
import Feedback from './components/Feedback'
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
import { hasRight } from './components/permissions'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -433,6 +434,17 @@ function App() {
}
/>
<Route
path="/einsaetze/milestone-speicher"
element={
<RequireRight user={user} right="administration:read">
<MilestoneStoragePage
canWrite={hasRight(user, 'administration:write')}
/>
</RequireRight>
}
/>
<Route
path="/kalender"
element={

View File

@ -13,6 +13,7 @@ import {
Cog6ToothIcon,
ComputerDesktopIcon,
DocumentDuplicateIcon,
ServerStackIcon,
ShieldCheckIcon,
VideoCameraIcon,
HomeIcon,
@ -22,7 +23,15 @@ import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions'
import type { User, Team } from './types'
const navigation = [
type NavigationItem = {
name: string
href: string
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
right?: string
isChild?: boolean
}
const navigation: NavigationItem[] = [
{
name: 'Dashboard',
href: '/',
@ -40,6 +49,13 @@ const navigation = [
icon: VideoCameraIcon,
right: 'operations:read',
},
{
name: 'Milestone-Speicher',
href: '/einsaetze/milestone-speicher',
icon: ServerStackIcon,
right: 'administration:read',
isChild: true,
},
{
name: 'Kalender',
href: '/kalender',
@ -136,11 +152,20 @@ function SidebarContent({
isActive
? 'bg-white/5 text-white'
: 'text-gray-400 hover:bg-white/5 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
item.isChild
? 'ml-7 gap-x-2 px-2 py-1.5 text-sm/6 font-medium'
: 'gap-x-3 p-2 text-sm/6 font-semibold',
'group flex rounded-md',
)
}
>
<item.icon aria-hidden="true" className="size-6 shrink-0" />
<item.icon
aria-hidden="true"
className={classNames(
'shrink-0',
item.isChild ? 'size-5' : 'size-6',
)}
/>
{item.name}
</NavLink>
</li>

View File

@ -0,0 +1,362 @@
// frontend\src\components\TreeList.tsx
import { useMemo, useState } from "react";
import {
ChevronRightIcon,
FolderIcon,
FolderPlusIcon,
} from "@heroicons/react/20/solid";
export type TreeListNode = {
id: string;
name: string;
path: string;
/** Optionaler echter Dateisystem-/Ordnername, wenn name ein Windows-Anzeigename ist. */
secondaryName?: string;
children?: TreeListNode[];
disabled?: boolean;
};
type TreeListProps = {
label?: string;
description?: string;
selectedPath: string;
onSelectedPathChange?: (path: string) => void;
nodes?: TreeListNode[];
rootPath?: string;
pathPlaceholder?: string;
newFolderPlaceholder?: string;
newFolderValue?: string;
onNewFolderValueChange?: (value: string) => void;
onCreateFolder?: (folderName: string, folderPath: string) => void;
showPathInput?: boolean;
showCreateButton?: boolean;
readOnly?: boolean;
disabled?: boolean;
className?: string;
};
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
function splitPath(value: string) {
const trimmedValue = value.trim();
if (!trimmedValue) {
return [];
}
const normalizedValue = trimmedValue.replace(/\\+/g, "\\");
const driveMatch = normalizedValue.match(/^[A-Za-z]:/);
const parts = normalizedValue
.split(/[\\/]+/)
.map((part) => part.trim())
.filter(Boolean);
if (driveMatch && parts.length > 0 && parts[0] !== driveMatch[0]) {
return [driveMatch[0], ...parts];
}
return parts;
}
function getPathSeparator(value: string) {
return value.includes("/") && !value.includes("\\") ? "/" : "\\";
}
function joinPath(basePath: string, childPath: string) {
const base = basePath.trim().replace(/[\\/]+$/, "");
const child = childPath.trim().replace(/^[\\/]+/, "");
if (!base) {
return child;
}
if (!child) {
return base;
}
return `${base}${getPathSeparator(base)}${child}`;
}
function buildFallbackNodes(path: string): TreeListNode[] {
const parts = splitPath(path);
if (parts.length === 0) {
return [];
}
const separator = getPathSeparator(path);
let currentPath = "";
let root: TreeListNode | null = null;
let currentNode: TreeListNode | null = null;
parts.forEach((part, index) => {
if (index === 0) {
currentPath = part;
} else {
currentPath = `${currentPath}${separator}${part}`;
}
const node: TreeListNode = {
id: currentPath,
name: part,
path: currentPath,
children: [],
};
if (!root) {
root = node;
}
if (currentNode) {
currentNode.children = [node];
}
currentNode = node;
});
return root ? [root] : [];
}
function renderTreeNodes({
nodes,
selectedPath,
onSelectedPathChange,
level = 0,
disabled = false,
readOnly = false,
}: {
nodes: TreeListNode[];
selectedPath: string;
onSelectedPathChange?: (path: string) => void;
level?: number;
disabled?: boolean;
readOnly?: boolean;
}) {
return nodes.map((node) => {
const active = node.path === selectedPath;
const selectable = !disabled && !readOnly && !node.disabled;
const children = Array.isArray(node.children) ? node.children : [];
return (
<li key={node.id}>
<button
type="button"
disabled={!selectable}
onClick={() => onSelectedPathChange?.(node.path)}
className={classNames(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition",
active
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5",
!selectable &&
"cursor-default hover:bg-transparent dark:hover:bg-transparent",
)}
style={{ paddingLeft: `${0.5 + level * 1.25}rem` }}
>
<FolderIcon
aria-hidden="true"
className={classNames(
active
? "text-indigo-500 dark:text-indigo-300"
: "text-gray-400 dark:text-gray-500",
"size-4 shrink-0",
)}
/>
<span className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate">{node.name || node.path}</span>
{node.secondaryName && node.secondaryName !== node.name && (
<span
title={node.secondaryName}
className={classNames(
"min-w-0 text-xs",
active
? "text-indigo-400 dark:text-indigo-300/70"
: "text-gray-400 dark:text-gray-500",
)}
>
{node.secondaryName}
</span>
)}
</span>
</button>
{children.length > 0 && (
<ul className="mt-0.5 space-y-0.5">
{renderTreeNodes({
nodes: children,
selectedPath,
onSelectedPathChange,
level: level + 1,
disabled,
readOnly,
})}
</ul>
)}
</li>
);
});
}
export default function TreeList({
label,
description,
selectedPath,
onSelectedPathChange,
nodes,
rootPath,
pathPlaceholder = "z. B. D:\\Milestone\\Storage01",
newFolderPlaceholder = "Neuen Ordner erstellen",
newFolderValue,
onNewFolderValueChange,
onCreateFolder,
showPathInput = true,
showCreateButton = true,
readOnly = false,
disabled = false,
className,
}: TreeListProps) {
const [internalNewFolderValue, setInternalNewFolderValue] = useState("");
const displayNodes = useMemo(
() =>
Array.isArray(nodes) && nodes.length > 0
? nodes
: buildFallbackNodes(selectedPath),
[nodes, selectedPath],
);
const canEdit = !disabled && !readOnly;
const isNewFolderControlled = newFolderValue !== undefined;
const currentNewFolderValue = isNewFolderControlled
? newFolderValue
: internalNewFolderValue;
const newFolderPath = currentNewFolderValue.trim()
? joinPath(rootPath || selectedPath, currentNewFolderValue)
: "";
function updateNewFolderValue(value: string) {
if (!isNewFolderControlled) {
setInternalNewFolderValue(value);
}
onNewFolderValueChange?.(value);
}
function applyNewFolder() {
const folderName = currentNewFolderValue.trim();
if (!folderName || !newFolderPath) {
return;
}
if (onCreateFolder) {
onCreateFolder(folderName, newFolderPath);
} else {
onSelectedPathChange?.(newFolderPath);
}
if (!isNewFolderControlled) {
setInternalNewFolderValue("");
}
}
return (
<div className={className}>
{label && (
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
{label}
</label>
)}
{description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-white/[0.03]">
{showPathInput && (
<div className="border-b border-gray-200 p-3 dark:border-white/10">
<input
type="text"
value={selectedPath}
onChange={(event) => onSelectedPathChange?.(event.target.value)}
placeholder={pathPlaceholder}
disabled={!canEdit}
readOnly={readOnly}
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",
"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 &&
"cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400",
)}
/>
</div>
)}
<div className="max-h-56 overflow-y-auto p-2">
{displayNodes.length > 0 ? (
<ul className="space-y-0.5">
{renderTreeNodes({
nodes: displayNodes,
selectedPath,
onSelectedPathChange,
disabled,
readOnly,
})}
</ul>
) : (
<div className="rounded-md border border-dashed border-gray-200 px-3 py-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Noch kein Speicherpfad ausgewählt.
</div>
)}
<div className="mt-2 rounded-md border border-dashed border-gray-300 bg-gray-50 p-2 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-2">
<FolderPlusIcon
aria-hidden="true"
className="size-4 shrink-0 text-gray-400 dark:text-gray-500"
/>
<input
type="text"
value={currentNewFolderValue}
onChange={(event) => updateNewFolderValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyNewFolder();
}
}}
disabled={!canEdit}
placeholder={newFolderPlaceholder}
className="min-w-0 flex-1 bg-transparent text-sm text-gray-900 outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:text-gray-500 dark:text-white dark:placeholder:text-gray-500"
/>
{showCreateButton && (
<button
type="button"
onClick={applyNewFolder}
disabled={!canEdit || !currentNewFolderValue.trim()}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
>
Übernehmen
</button>
)}
</div>
{newFolderPath && (
<div className="mt-2 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<ChevronRightIcon aria-hidden="true" className="size-3.5" />
<span className="truncate">{newFolderPath}</span>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -94,11 +94,51 @@ export type MilestoneHardwareChildDevice = {
id: string
milestoneHardwareId: string
milestoneDeviceId: string
deviceType: 'camera' | 'microphone' | string
deviceType: 'camera' | 'microphone' | 'inputEvent' | 'output' | 'speaker' | 'metadata' | string
name: string
displayName: string
shortName: string
enabled: boolean
recordingEnabled?: boolean
}
export type MilestoneArchiveStorage = {
id: string
displayName: string
name: string
diskPath: string
mounted: boolean
}
export type MilestoneRecordingStorageRef = {
id: string
displayName?: string
name?: string
diskPath?: string
mounted?: boolean
}
export type MilestoneCameraStream = {
index: number
codec: string
fps: number | null
jPEGQuality: number | null
quality: number | null
resolution: string
edgeStorageSupported?: boolean
}
export type MilestoneCameraDetail = {
id: string
milestoneDeviceId: string
milestoneHardwareId: string
enabled: boolean
recordingEnabled: boolean
recordingFramerate: number | null
recordKeyframesOnly: boolean
recordingStorage?: MilestoneRecordingStorageRef | null
archiveStorages: MilestoneArchiveStorage[]
streams: MilestoneCameraStream[]
}
export type Operation = {

View File

@ -30,6 +30,9 @@ type MilestoneSettings = {
serverName: string
serverVersion: string
serverUpdatedAt?: string
serverFoldersAgentScheme: string
serverFoldersAgentPort: string
serverFoldersAgentTokenConfigured: boolean
updatedAt?: string
}
type MilestoneSyncEvent = {
@ -84,8 +87,8 @@ function createMilestoneSyncSteps(): TaskListStep[] {
},
{
id: 'hardware',
name: 'Hardware synchronisieren',
description: 'Hardware-Geräte und Treiberdaten werden geladen.',
name: 'Milestone-Daten synchronisieren',
description: 'Geräte, Untergeräte und Treiberdaten werden geladen.',
status: 'upcoming',
},
]
@ -210,12 +213,12 @@ export default function Milestone() {
void loadSettings()
}, [])
function getHardwareCountMessage(count: number) {
function getMilestoneDataCountMessage(count: number) {
if (count === 1) {
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
return '1 Gerät wurde von Milestone geladen und gespeichert.'
}
return `${count} Hardware-Geräte wurden von Milestone geladen und gespeichert.`
return `${count} Geräte wurden von Milestone geladen und gespeichert.`
}
function handleMilestoneSyncEvent(event: MilestoneSyncEvent) {
@ -271,8 +274,8 @@ export default function Milestone() {
if (event.type === 'hardware') {
showToast({
variant: 'success',
title: 'Hardware synchronisiert',
message: getHardwareCountMessage(event.hardwareCount ?? 0),
title: 'Milestone-Daten synchronisiert',
message: getMilestoneDataCountMessage(event.hardwareCount ?? 0),
})
return
@ -403,6 +406,15 @@ export default function Milestone() {
username: String(formData.get('username') ?? ''),
password: String(formData.get('password') ?? ''),
skipTlsVerify,
serverFoldersAgentScheme: String(
formData.get('serverFoldersAgentScheme') ?? 'http',
),
serverFoldersAgentPort: String(
formData.get('serverFoldersAgentPort') ?? '8099',
),
serverFoldersAgentToken: String(
formData.get('serverFoldersAgentToken') ?? '',
),
}),
})
@ -490,7 +502,7 @@ export default function Milestone() {
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Token, Recording-Server und Hardware werden nacheinander abgefragt.
Token, Recording-Server und Milestone-Daten werden nacheinander abgefragt.
</p>
</div>
@ -633,6 +645,95 @@ export default function Milestone() {
/>
</div>
</div>
<div className="sm:col-span-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
TreeView-Agent
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.
</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="server-folders-agent-scheme"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Schema
</label>
<div className="mt-2">
<select
id="server-folders-agent-scheme"
name="serverFoldersAgentScheme"
defaultValue={settings?.serverFoldersAgentScheme || 'http'}
className={inputClassName}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div>
<label
htmlFor="server-folders-agent-port"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Port
</label>
<div className="mt-2">
<input
id="server-folders-agent-port"
name="serverFoldersAgentPort"
type="text"
inputMode="numeric"
defaultValue={settings?.serverFoldersAgentPort || '8099'}
placeholder="8099"
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-2">
<label
htmlFor="server-folders-agent-token"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Agent-Token{' '}
{settings?.serverFoldersAgentTokenConfigured
? '(leer lassen = unverändert)'
: ''}
</label>
<div className="mt-2">
<input
id="server-folders-agent-token"
name="serverFoldersAgentToken"
type="password"
autoComplete="new-password"
placeholder={
settings?.serverFoldersAgentTokenConfigured
? 'Token ist bereits gespeichert'
: 'Token des Server-Folders-Agent'
}
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Der Token wird verschlüsselt gespeichert und vom Backend als
X-Server-Folders-Token an den Agent weitergegeben.
</p>
</div>
</div>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-x-3">
@ -719,6 +820,31 @@ export default function Milestone() {
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TreeView-Agent
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{(settings?.serverFoldersAgentScheme || 'http') +
'://' +
(settings?.host || 'Milestone-Server') +
':' +
(settings?.serverFoldersAgentPort || '8099')}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TreeView-Agent Token
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.serverFoldersAgentTokenConfigured
? 'Gespeichert'
: 'Nicht gespeichert'}
</dd>
</div>
{settings?.tokenExpiresAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">

View File

@ -1,4 +1,4 @@
// frontend/src/pages/administration/users/UsersAdministration.tsx
// frontend\src\pages\administration\users\UsersAdministration.tsx
import {
useEffect,

View File

@ -0,0 +1,386 @@
// frontend\src\pages\devices\CameraDetailsModal.tsx
import { type FormEvent } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
CheckIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import Switch from '../../components/Switch'
import LoadingSpinner from '../../components/LoadingSpinner'
import type { Device, MilestoneCameraDetail } from '../../components/types'
type CameraDetailsModalProps = {
open: boolean
setOpen: (open: boolean) => void
device: Device | null
childDevice: NonNullable<Device['milestoneChildDevices']>[number] | null
camera: MilestoneCameraDetail | null
isLoading: boolean
isSaving: boolean
error: string | null
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}
function inputClassName(disabled = false) {
return [
'block w-full rounded-md px-3 py-1.5 text-sm outline-1 -outline-offset-1',
'bg-white text-gray-900 outline-gray-300',
'focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600',
'dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500',
disabled && 'cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400',
]
.filter(Boolean)
.join(' ')
}
export default function CameraDetailsModal({
open,
setOpen,
device,
childDevice,
camera,
isLoading,
isSaving,
error,
onSubmit,
}: CameraDetailsModalProps) {
const title =
childDevice?.displayName ||
childDevice?.name ||
'Kamera bearbeiten'
const streams = camera?.streams ?? []
const selectedRecordingStorageLabel = camera?.recordingStorage
? camera.recordingStorage.displayName ||
camera.recordingStorage.name ||
camera.recordingStorage.id
: ''
const recordingStorageOptions = (() => {
const options = new Map<string, string>()
if (camera?.recordingStorage?.id) {
options.set(camera.recordingStorage.id, selectedRecordingStorageLabel)
}
for (const storage of camera?.archiveStorages ?? []) {
options.set(
storage.id,
storage.displayName || storage.name || storage.id,
)
}
return Array.from(options.entries()).map(([value, label]) => ({
value,
label,
}))
})()
return (
<Modal
open={open}
setOpen={setOpen}
zIndexClassName="z-[10000]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-5xl bg-white dark:bg-gray-900"
>
<form
onSubmit={onSubmit}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
>
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
<VideoCameraIcon
aria-hidden="true"
className="size-5 text-indigo-600 dark:text-indigo-400"
/>
<span>{title}</span>
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{device?.milestoneDisplayName || device?.inventoryNumber || 'Milestone-Kamera'}
</p>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isSaving}
onClick={() => setOpen(false)}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{isLoading && (
<div className="flex min-h-32 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
<LoadingSpinner
size="md"
label="Kamera-Details werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
/>
</div>
)}
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
{!isLoading && camera && (
<div className="space-y-6">
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Allgemein
</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Basisdaten und Aufzeichnungsoptionen der Kamera.
</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3 rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<Switch
id="cameraEnabled"
name="enabled"
label="Kamera aktiviert"
defaultChecked={camera.enabled}
/>
</div>
<div className="sm:col-span-3 rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<Switch
id="cameraRecordingEnabled"
name="recordingEnabled"
label="Aufzeichnung aktiviert"
defaultChecked={camera.recordingEnabled}
/>
</div>
<div className="sm:col-span-3 rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<Switch
id="cameraRecordKeyframesOnly"
name="recordKeyframesOnly"
label="Nur Keyframes aufzeichnen"
defaultChecked={camera.recordKeyframesOnly}
/>
</div>
<div className="sm:col-span-3">
<label
htmlFor="recordingFramerate"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Aufnahme-FPS
</label>
<div className="mt-2">
<input
id="recordingFramerate"
name="recordingFramerate"
type="number"
min="0"
step="1"
defaultValue={camera.recordingFramerate ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-6">
<label
htmlFor="recordingStorageId"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Recording-Storage
</label>
{camera.recordingStorage?.id && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Gewählt: {selectedRecordingStorageLabel}
{camera.recordingStorage.diskPath
? ` · ${camera.recordingStorage.diskPath}`
: ''}
{camera.recordingStorage.mounted === false
? ' · nicht gemountet'
: ''}
</p>
)}
<div className="mt-2">
<select
id="recordingStorageId"
name="recordingStorageId"
defaultValue={camera.recordingStorage?.id ?? ''}
className={inputClassName()}
>
<option value="">Kein Storage gewählt</option>
{recordingStorageOptions.map((storage) => (
<option key={storage.value} value={storage.value}>
{storage.label}
</option>
))}
</select>
</div>
</div>
</div>
</section>
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Streams
</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Codec, FPS, Auflösung und Qualität je Stream.
</p>
</div>
<div className="mt-4 space-y-4">
{streams.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Keine Streams gefunden.
</p>
) : (
streams.map((stream, index) => (
<div
key={stream.index}
className="rounded-lg border border-gray-200 p-4 dark:border-white/10"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h5 className="text-sm font-semibold text-gray-900 dark:text-white">
{`Stream ${index + 1}`}
</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">
Index: {stream.index}
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<input
type="hidden"
name={`streamIndex-${index}`}
value={stream.index}
/>
<div className="sm:col-span-2">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Codec
</label>
<div className="mt-2">
<input
name={`streamCodec-${index}`}
type="text"
defaultValue={stream.codec}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-1">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
FPS
</label>
<div className="mt-2">
<input
name={`streamFps-${index}`}
type="number"
min="0"
step="1"
defaultValue={stream.fps ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-1">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
JPEG-Qualität
</label>
<div className="mt-2">
<input
name={`streamJPEGQuality-${index}`}
type="number"
min="0"
step="1"
defaultValue={stream.jPEGQuality ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-1">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Qualität
</label>
<div className="mt-2">
<input
name={`streamQuality-${index}`}
type="number"
min="0"
step="1"
defaultValue={stream.quality ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Auflösung
</label>
<div className="mt-2">
<input
name={`streamResolution-${index}`}
type="text"
defaultValue={stream.resolution}
className={inputClassName()}
/>
</div>
</div>
</div>
</div>
))
)}
</div>
</section>
</div>
)}
</div>
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="gray"
disabled={isSaving}
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
disabled={isLoading || !camera}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
>
Kamera speichern
</Button>
</div>
</form>
</Modal>
)
}

View File

@ -1,5 +1,6 @@
// frontend\src\pages\devices\ChildDeviceCard.tsx
import type { KeyboardEvent } from 'react'
import Switch from '../../components/Switch'
import type { Device } from '../../components/types'
import {
@ -16,10 +17,16 @@ type MilestoneChildDevice = NonNullable<Device['milestoneChildDevices']>[number]
type ChildDeviceCardProps = {
item: MilestoneChildDevice
isBusy?: boolean
isRecordingBusy?: boolean
onToggle?: (
childDevice: MilestoneChildDevice,
enabled: boolean,
) => void | Promise<void>
onToggleRecording?: (
childDevice: MilestoneChildDevice,
enabled: boolean,
) => void | Promise<void>
onOpenDetails?: (childDevice: MilestoneChildDevice) => void
}
function getChildDevicePresentation(deviceType: string) {
@ -32,7 +39,7 @@ function getChildDevicePresentation(deviceType: string) {
}
}
if (normalizedType === 'input' || normalizedType === 'inputs') {
if (normalizedType === 'inputevent' || normalizedType === 'inputevents') {
return {
Icon: ArrowDownTrayIcon,
typeLabel: 'Eingang',
@ -66,37 +73,133 @@ function getChildDevicePresentation(deviceType: string) {
}
}
function splitMobileChildTitle(fullTitle: string) {
const trimmedTitle = fullTitle.trim()
if (!trimmedTitle) {
return {
mobileTitle: 'Unbenanntes Gerät',
mobileSubtitle: '',
}
}
const separator = ' - '
const separatorIndex = trimmedTitle.lastIndexOf(separator)
if (separatorIndex === -1) {
return {
mobileTitle: trimmedTitle,
mobileSubtitle: '',
}
}
const baseName = trimmedTitle.slice(0, separatorIndex).trim()
const suffixName = trimmedTitle.slice(separatorIndex + separator.length).trim()
if (!baseName || !suffixName) {
return {
mobileTitle: trimmedTitle,
mobileSubtitle: '',
}
}
return {
mobileTitle: suffixName,
mobileSubtitle: baseName,
}
}
export default function ChildDeviceCard({
item,
isBusy = false,
isRecordingBusy = false,
onToggle,
onToggleRecording,
onOpenDetails,
}: ChildDeviceCardProps) {
const itemTitle =
item.displayName || item.name || item.shortName || 'Unbenanntes Gerät'
const isEnabled = item.enabled ?? true
const isCamera =
item.deviceType === 'camera' || item.deviceType === 'cameras'
const isRecordingEnabled = isCamera && (item.recordingEnabled ?? false)
const { Icon, typeLabel } = getChildDevicePresentation(item.deviceType)
const { mobileTitle, mobileSubtitle } = splitMobileChildTitle(itemTitle)
const canOpenDetails = Boolean(onOpenDetails)
function handleOpenDetails() {
if (!onOpenDetails) {
return
}
onOpenDetails(item)
}
function handleCardKeyDown(event: KeyboardEvent<HTMLElement>) {
if (!canOpenDetails) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleOpenDetails()
}
}
return (
<article className="w-full rounded-md border border-gray-200 bg-white px-3 py-2.5 shadow-xs dark:border-white/10 dark:bg-gray-900/50">
<article
role={canOpenDetails ? 'button' : undefined}
tabIndex={canOpenDetails ? 0 : undefined}
onClick={canOpenDetails ? handleOpenDetails : undefined}
onKeyDown={handleCardKeyDown}
className={[
'w-full rounded-md border border-gray-200 bg-white px-3 py-2.5 shadow-xs transition dark:border-white/10 dark:bg-gray-900/50',
canOpenDetails &&
'cursor-pointer hover:bg-gray-50 dark:hover:bg-white/10',
]
.filter(Boolean)
.join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
<div
className={[
'flex size-8 shrink-0 items-center justify-center rounded-md',
isRecordingEnabled
? 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400',
].join(' ')}
>
<Icon aria-hidden="true" className="size-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white sm:hidden">
{mobileTitle}
</p>
<p className="hidden truncate text-sm font-medium text-gray-900 dark:text-white sm:block">
{itemTitle}
</p>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{typeLabel}
<p className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400 sm:hidden">
{mobileSubtitle || typeLabel}
</p>
<div className="mt-0.5 hidden items-center gap-2 text-xs text-gray-500 dark:text-gray-400 sm:flex">
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="shrink-0 self-start">
<div
className="shrink-0 self-start flex flex-col items-end gap-2"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Switch
id={`milestone-child-${item.id}`}
checked={isEnabled}
@ -105,6 +208,35 @@ export default function ChildDeviceCard({
label=""
className="scale-90"
/>
{isCamera && (
<div className="flex items-center gap-2">
<button
type="button"
disabled={isRecordingBusy || !onToggleRecording}
onClick={(event) => {
event.stopPropagation()
void onToggleRecording?.(item, !(item.recordingEnabled ?? false))
}}
className={[
'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-semibold ring-1 transition',
isRecordingEnabled
? 'bg-red-50 text-red-700 ring-red-200 hover:bg-red-100 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-400/20'
: 'bg-gray-50 text-gray-600 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
(isRecordingBusy || !onToggleRecording) && 'cursor-not-allowed opacity-60',
].join(' ')}
title={isRecordingEnabled ? 'Aufzeichnung deaktivieren' : 'Aufzeichnung aktivieren'}
>
<span
className={[
'size-2 rounded-full',
isRecordingEnabled ? 'bg-red-500' : 'bg-gray-400',
].join(' ')}
/>
<span>REC</span>
</button>
</div>
)}
</div>
</div>
</article>

View File

@ -9,11 +9,7 @@ import Textarea from '../../components/Textarea'
import ChildDeviceCard from './ChildDeviceCard'
import type { Device } from '../../components/types'
import {
ArrowDownTrayIcon,
ArrowUpTrayIcon,
CircleStackIcon,
MicrophoneIcon,
SpeakerWaveIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
@ -31,6 +27,19 @@ type DeviceFormFieldsProps = {
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
onToggleMilestoneChildDeviceGroup?: (
items: NonNullable<Device['milestoneChildDevices']>,
enabled: boolean,
title: string,
) => void | Promise<void>
onToggleMilestoneChildDeviceRecording?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
onOpenMilestoneCameraDetails?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
) => void
togglingMilestoneCameraRecordingIds?: Set<string>
}
export const inputClassName =
@ -40,7 +49,7 @@ export const selectClassName =
'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 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:*:bg-gray-800 dark:focus:outline-indigo-500'
export const sectionClassName =
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
'flex min-h-0 flex-col rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
@ -73,21 +82,65 @@ function MilestoneChildDeviceCards({
emptyText,
items,
togglingIds,
togglingRecordingIds,
onToggle,
onToggleRecording,
onOpenDetails,
onToggleGroup,
icon: Icon,
}: {
title: string
emptyText: string
items: NonNullable<Device['milestoneChildDevices']>
togglingIds: Set<string>
togglingRecordingIds: Set<string>
onToggle?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
onToggleRecording?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
onOpenDetails?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
) => void
onToggleGroup?: (
items: NonNullable<Device['milestoneChildDevices']>,
enabled: boolean,
title: string,
) => void | Promise<void>
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 (
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 dark:border-white/10 dark:bg-white/5">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border border-gray-200 bg-gray-50/60 p-4 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 pb-3 dark:border-white/10">
<div className="flex items-center gap-2">
<Icon
@ -99,25 +152,43 @@ function MilestoneChildDeviceCards({
</h4>
</div>
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{items.length}
</span>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{items.length}
</span>
<Switch
id={`milestone-child-group-${title.toLowerCase().replace(/\s+/g, '-')}`}
checked={allEnabled}
disabled={items.length === 0 || isBulkBusy || !onToggle}
onChange={(event) => void handleToggleAll(event.target.checked)}
label=""
className="scale-90"
/>
</div>
</div>
{items.length === 0 ? (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</p>
<div className="mt-4 flex min-h-0 flex-1 items-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</p>
</div>
) : (
<div className="mt-4 flex flex-wrap gap-2">
{items.map((item) => (
<ChildDeviceCard
key={`${item.deviceType}-${item.milestoneDeviceId}`}
item={item}
isBusy={togglingIds.has(item.id)}
onToggle={onToggle}
/>
))}
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid grid-cols-1 gap-2">
{items.map((item) => (
<ChildDeviceCard
key={`${item.deviceType}-${item.milestoneDeviceId}`}
item={item}
isBusy={togglingIds.has(item.id)}
isRecordingBusy={togglingRecordingIds.has(item.id)}
onToggle={onToggle}
onToggleRecording={onToggleRecording}
onOpenDetails={onOpenDetails}
/>
))}
</div>
</div>
)}
</div>
@ -228,6 +299,43 @@ async function fetchDeviceLookups() {
return deviceLookupPromise
}
const milestoneChildDeviceCollator = new Intl.Collator('de-DE', {
numeric: true,
sensitivity: 'base',
})
function getMilestoneChildDeviceTitle(
item: NonNullable<Device['milestoneChildDevices']>[number],
) {
return (
item.displayName ||
item.name ||
item.shortName ||
item.milestoneDeviceId ||
''
)
}
function sortMilestoneChildDevices(
items: NonNullable<Device['milestoneChildDevices']>,
) {
return [...items].sort((a, b) => {
const byTitle = milestoneChildDeviceCollator.compare(
getMilestoneChildDeviceTitle(a),
getMilestoneChildDeviceTitle(b),
)
if (byTitle !== 0) {
return byTitle
}
return milestoneChildDeviceCollator.compare(
a.milestoneDeviceId ?? '',
b.milestoneDeviceId ?? '',
)
})
}
export default function DeviceFormFields({
open,
activeTab,
@ -237,6 +345,10 @@ export default function DeviceFormFields({
onToggleRelatedDevice,
togglingMilestoneChildDeviceIds,
onToggleMilestoneChildDevice,
onToggleMilestoneChildDeviceGroup,
onToggleMilestoneChildDeviceRecording,
onOpenMilestoneCameraDetails,
togglingMilestoneCameraRecordingIds,
}: DeviceFormFieldsProps) {
const assignableDevices = useMemo(
() => devices.filter((item) => item.id !== device?.id),
@ -307,28 +419,16 @@ export default function DeviceFormFields({
const milestoneChildDevices = device?.milestoneChildDevices ?? []
const milestoneCameras = milestoneChildDevices.filter(
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
const milestoneCameras = sortMilestoneChildDevices(
milestoneChildDevices.filter(
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
),
)
const milestoneMicrophones = milestoneChildDevices.filter(
(item) => item.deviceType === 'microphone' || item.deviceType === 'microphones',
)
const milestoneInputs = milestoneChildDevices.filter(
(item) => item.deviceType === 'input' || item.deviceType === 'inputs',
)
const milestoneOutputs = milestoneChildDevices.filter(
(item) => item.deviceType === 'output' || item.deviceType === 'outputs',
)
const milestoneSpeakers = milestoneChildDevices.filter(
(item) => item.deviceType === 'speaker' || item.deviceType === 'speakers',
)
const milestoneMetadata = milestoneChildDevices.filter(
(item) => item.deviceType === 'metadata',
const milestoneMicrophones = sortMilestoneChildDevices(
milestoneChildDevices.filter(
(item) => item.deviceType === 'microphone' || item.deviceType === 'microphones',
),
)
const milestoneReadonlyClassName =
@ -419,7 +519,7 @@ export default function DeviceFormFields({
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="grid min-h-full grid-cols-1 gap-4 sm:grid-cols-6">
{lookupError && (
<div className="sm:col-span-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{lookupError}
@ -973,13 +1073,17 @@ export default function DeviceFormFields({
Dieses Gerät ist kein Milestone-Gerät.
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="mt-4 grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-2 lg:auto-rows-fr">
<MilestoneChildDeviceCards
title="Kameras"
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}
icon={VideoCameraIcon}
/>
@ -988,45 +1092,11 @@ export default function DeviceFormFields({
emptyText="Keine Mikrofone für diese Hardware gefunden."
items={milestoneMicrophones}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
togglingRecordingIds={togglingMilestoneCameraRecordingIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
onToggleGroup={onToggleMilestoneChildDeviceGroup}
icon={MicrophoneIcon}
/>
<MilestoneChildDeviceCards
title="Eingänge"
emptyText="Keine Eingänge für diese Hardware gefunden."
items={milestoneInputs}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={ArrowDownTrayIcon}
/>
<MilestoneChildDeviceCards
title="Ausgänge"
emptyText="Keine Ausgänge für diese Hardware gefunden."
items={milestoneOutputs}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={ArrowUpTrayIcon}
/>
<MilestoneChildDeviceCards
title="Lautsprecher"
emptyText="Keine Lautsprecher für diese Hardware gefunden."
items={milestoneSpeakers}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={SpeakerWaveIcon}
/>
<MilestoneChildDeviceCards
title="Metadaten"
emptyText="Keine Metadaten für diese Hardware gefunden."
items={milestoneMetadata}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={CircleStackIcon}
/>
</div>
)}
</section>

View File

@ -15,9 +15,14 @@ import Button from '../../components/Button'
import { Badge } from '../../components/Badge'
import CreateDeviceModal from './CreateDeviceModal'
import EditDeviceModal from './EditDeviceModal'
import CameraDetailsModal from './CameraDetailsModal'
import Tables, { type TableColumn } from '../../components/Tables'
import LoadingSpinner from '../../components/LoadingSpinner'
import type { Device, DeviceHistoryEntry } from '../../components/types'
import type {
Device,
DeviceHistoryEntry,
MilestoneCameraDetail,
} from '../../components/types'
import { useNotifications } from '../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -184,6 +189,16 @@ export default function DevicesPage() {
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
const closeCleanupTimeoutRef = useRef<number | null>(null)
const [togglingMilestoneCameraRecordingIds, setTogglingMilestoneCameraRecordingIds] = useState<Set<string>>(() => new Set())
const [isCameraDetailsOpen, setIsCameraDetailsOpen] = useState(false)
const [editingCameraChildDevice, setEditingCameraChildDevice] =
useState<NonNullable<Device['milestoneChildDevices']>[number] | null>(null)
const [cameraDetails, setCameraDetails] = useState<MilestoneCameraDetail | null>(null)
const [isCameraDetailsLoading, setIsCameraDetailsLoading] = useState(false)
const [isCameraDetailsSaving, setIsCameraDetailsSaving] = useState(false)
const [cameraDetailsError, setCameraDetailsError] = useState<string | null>(null)
const [togglingMilestoneChildDeviceIds, setTogglingMilestoneChildDeviceIds] = useState<Set<string>>(() => new Set())
useEffect(() => {
@ -242,6 +257,40 @@ export default function DevicesPage() {
}
}, [editingDevice])
async function loadMilestoneCameraDetails(
device: Device,
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
) {
setIsCameraDetailsLoading(true)
setCameraDetailsError(null)
try {
const response = await fetch(
`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}`,
{
credentials: 'include',
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Kamera-Details konnten nicht geladen werden'),
)
}
const data = await response.json()
setCameraDetails(data.camera ?? null)
} catch (error) {
setCameraDetailsError(
error instanceof Error
? error.message
: 'Kamera-Details konnten nicht geladen werden',
)
} finally {
setIsCameraDetailsLoading(false)
}
}
async function loadFirmwareCheckState() {
try {
const response = await fetch(`${API_URL}/devices/firmware-check`, {
@ -266,6 +315,33 @@ export default function DevicesPage() {
}
}
async function updateMilestoneChildDevice(
device: Device,
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) {
const response = await fetch(
`${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ enabled }),
},
)
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Child-Device konnte nicht aktualisiert werden',
),
)
}
}
async function handleToggleMilestoneChildDevice(
device: Device,
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
@ -278,22 +354,7 @@ export default function DevicesPage() {
})
try {
const response = await fetch(
`${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ enabled }),
},
)
if (!response.ok) {
throw new Error(await readApiError(response, 'Milestone-Child-Device konnte nicht aktualisiert werden'))
}
await updateMilestoneChildDevice(device, childDevice, enabled)
await loadDevices({ silent: true })
showToast({
@ -316,6 +377,60 @@ export default function DevicesPage() {
}
}
async function handleToggleMilestoneChildDeviceGroup(
device: Device,
items: NonNullable<Device['milestoneChildDevices']>,
enabled: boolean,
title: string,
) {
if (items.length === 0) {
return
}
setTogglingMilestoneChildDeviceIds((currentIds) => {
const nextIds = new Set(currentIds)
for (const item of items) {
nextIds.add(item.id)
}
return nextIds
})
try {
for (const item of items) {
await updateMilestoneChildDevice(device, item, enabled)
}
await loadDevices({ silent: true })
showToast({
variant: 'success',
title: enabled ? `${title} aktiviert` : `${title} deaktiviert`,
message:
items.length === 1
? `1 Eintrag wurde aktualisiert.`
: `${items.length} Einträge wurden aktualisiert.`,
})
} catch (error) {
showErrorToast({
title: 'Sammel-Aktualisierung fehlgeschlagen',
error,
fallback: `${title} konnten nicht aktualisiert werden`,
})
} finally {
setTogglingMilestoneChildDeviceIds((currentIds) => {
const nextIds = new Set(currentIds)
for (const item of items) {
nextIds.delete(item.id)
}
return nextIds
})
}
}
async function handleCheckFirmware() {
setIsCheckingFirmware(true)
@ -504,6 +619,19 @@ export default function DevicesPage() {
}
}
function openMilestoneCameraDetails(
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
) {
if (!editingDevice) {
return
}
setEditingCameraChildDevice(childDevice)
setCameraDetails(null)
setIsCameraDetailsOpen(true)
void loadMilestoneCameraDetails(editingDevice, childDevice)
}
function openCreateModal() {
if (closeCleanupTimeoutRef.current) {
window.clearTimeout(closeCleanupTimeoutRef.current)
@ -537,6 +665,12 @@ export default function DevicesPage() {
setHistoryError(null)
setIsHistoryLoading(false)
setCreateError(null)
setIsCameraDetailsOpen(false)
setEditingCameraChildDevice(null)
setCameraDetails(null)
setCameraDetailsError(null)
setIsCameraDetailsLoading(false)
setIsCameraDetailsSaving(false)
closeCleanupTimeoutRef.current = null
}, 220)
}
@ -557,6 +691,131 @@ export default function DevicesPage() {
void loadDeviceHistory(device.id)
}
async function handleToggleMilestoneCameraRecording(
device: Device,
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
recordingEnabled: boolean,
) {
setTogglingMilestoneCameraRecordingIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.add(childDevice.id)
return nextIds
})
try {
const response = await fetch(
`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
recordingEnabled,
}),
},
)
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Aufzeichnungsstatus konnte nicht aktualisiert werden',
),
)
}
await loadDevices({ silent: true })
showToast({
variant: 'success',
title: recordingEnabled ? 'Aufzeichnung aktiviert' : 'Aufzeichnung deaktiviert',
message: `${childDevice.displayName || childDevice.name || childDevice.milestoneDeviceId} wurde aktualisiert.`,
})
} catch (error) {
showErrorToast({
title: 'Aktualisierung fehlgeschlagen',
error,
fallback: 'Aufzeichnungsstatus konnte nicht aktualisiert werden',
})
} finally {
setTogglingMilestoneCameraRecordingIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.delete(childDevice.id)
return nextIds
})
}
}
async function handleSaveMilestoneCameraDetails(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!editingDevice || !editingCameraChildDevice || !cameraDetails) {
return
}
const form = event.currentTarget
const formData = new FormData(form)
setIsCameraDetailsSaving(true)
setCameraDetailsError(null)
const payload = {
enabled: formData.get('enabled') === 'on',
recordingEnabled: formData.get('recordingEnabled') === 'on',
recordingFramerate:
Number(formData.get('recordingFramerate') ?? 0) || null,
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
recordingStorageId:
String(formData.get('recordingStorageId') ?? '').trim() || null,
streams: cameraDetails.streams.map((stream, index) => ({
index,
codec: String(formData.get(`streamCodec-${index}`) ?? ''),
fps: Number(formData.get(`streamFps-${index}`) ?? 0) || null,
jPEGQuality:
Number(formData.get(`streamJPEGQuality-${index}`) ?? 0) || null,
resolution: String(formData.get(`streamResolution-${index}`) ?? ''),
quality: Number(formData.get(`streamQuality-${index}`) ?? 0) || null,
})),
}
try {
const response = await fetch(
`${API_URL}/devices/${editingDevice.id}/milestone-cameras/${editingCameraChildDevice.id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Kamera konnte nicht aktualisiert werden'),
)
}
await loadDevices({ silent: true })
await loadMilestoneCameraDetails(editingDevice, editingCameraChildDevice)
showToast({
variant: 'success',
title: 'Kamera aktualisiert',
message: `${editingCameraChildDevice.displayName || editingCameraChildDevice.name || editingCameraChildDevice.milestoneDeviceId} wurde gespeichert.`,
})
} catch (error) {
setCameraDetailsError(
error instanceof Error ? error.message : 'Kamera konnte nicht aktualisiert werden',
)
} finally {
setIsCameraDetailsSaving(false)
}
}
function toggleRelatedDevice(deviceId: string) {
setRelatedDeviceIds((currentDeviceIds) => {
if (currentDeviceIds.includes(deviceId)) {
@ -1101,6 +1360,39 @@ export default function DevicesPage() {
return handleToggleMilestoneChildDevice(editingDevice, childDevice, enabled)
}}
onToggleMilestoneChildDeviceGroup={(items, enabled, title) => {
if (!editingDevice) {
return
}
return handleToggleMilestoneChildDeviceGroup(
editingDevice,
items,
enabled,
title,
)
}}
togglingMilestoneCameraRecordingIds={togglingMilestoneCameraRecordingIds}
onToggleMilestoneChildDeviceRecording={(childDevice, enabled) => {
if (!editingDevice) {
return
}
return handleToggleMilestoneCameraRecording(editingDevice, childDevice, enabled)
}}
onOpenMilestoneCameraDetails={openMilestoneCameraDetails}
/>
<CameraDetailsModal
open={isCameraDetailsOpen}
setOpen={setIsCameraDetailsOpen}
device={editingDevice}
childDevice={editingCameraChildDevice}
camera={cameraDetails}
isLoading={isCameraDetailsLoading}
isSaving={isCameraDetailsSaving}
error={cameraDetailsError}
onSubmit={handleSaveMilestoneCameraDetails}
/>
</div>

View File

@ -49,6 +49,19 @@ type EditDeviceModalProps = {
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
onToggleMilestoneChildDeviceGroup?: (
items: NonNullable<Device['milestoneChildDevices']>,
enabled: boolean,
title: string,
) => void | Promise<void>
togglingMilestoneCameraRecordingIds?: Set<string>
onToggleMilestoneChildDeviceRecording?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
onOpenMilestoneCameraDetails?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
) => void
}
type JournalItems = ComponentProps<typeof Journal>['items']
@ -126,6 +139,19 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function filterMilestoneChildDevices(device: Device | null) {
if (!device) {
return device
}
return {
...device,
milestoneChildDevices: (device.milestoneChildDevices ?? []).filter((childDevice) =>
['camera', 'microphone'].includes(childDevice.deviceType),
),
}
}
export default function EditDeviceModal({
open,
setOpen,
@ -144,8 +170,13 @@ export default function EditDeviceModal({
onJournalEntryUpdate,
togglingMilestoneChildDeviceIds,
onToggleMilestoneChildDevice,
onToggleMilestoneChildDeviceGroup,
togglingMilestoneCameraRecordingIds,
onToggleMilestoneChildDeviceRecording,
onOpenMilestoneCameraDetails,
}: EditDeviceModalProps) {
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
@ -223,13 +254,13 @@ export default function EditDeviceModal({
return (
<Modal
open={open && Boolean(device)}
open={open && Boolean(milestoneFilteredDevice)}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
>
<form
key={device?.id ?? 'edit-device'}
key={milestoneFilteredDevice?.id ?? 'edit-device'}
onSubmit={handleFormSubmit}
onInvalidCapture={handleInvalid}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
@ -238,11 +269,11 @@ export default function EditDeviceModal({
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Gerät bearbeiten
{device && (
{milestoneFilteredDevice && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{[
device.inventoryNumber,
device.milestoneDisplayName,
milestoneFilteredDevice.inventoryNumber,
milestoneFilteredDevice.milestoneDisplayName,
]
.filter(Boolean)
.join(' · ')}
@ -251,12 +282,12 @@ export default function EditDeviceModal({
</DialogTitle>
<div className="mt-1 space-y-1">
{device?.milestoneHardwareId && (
{milestoneFilteredDevice?.milestoneHardwareId && (
<p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
<span>
{device.milestoneSyncedAt
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(device.milestoneSyncedAt)}`
{milestoneFilteredDevice.milestoneSyncedAt
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(milestoneFilteredDevice.milestoneSyncedAt)}`
: 'Noch nicht mit Milestone synchronisiert.'}
</span>
</p>
@ -332,12 +363,16 @@ export default function EditDeviceModal({
<DeviceFormFields
open={open}
activeTab={activeTab}
device={device ?? undefined}
device={milestoneFilteredDevice ?? undefined}
devices={devices}
relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={onToggleRelatedDevice}
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
onToggleMilestoneChildDevice={onToggleMilestoneChildDevice}
onToggleMilestoneChildDeviceGroup={onToggleMilestoneChildDeviceGroup}
togglingMilestoneCameraRecordingIds={togglingMilestoneCameraRecordingIds}
onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording}
onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails}
/>
)}
</div>

File diff suppressed because it is too large Load Diff