added server folder agent
This commit is contained in:
parent
5af9896514
commit
dd387a884c
File diff suppressed because it is too large
Load Diff
@ -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
45
backend/external/README.md
vendored
Normal 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
35
backend/external/agent_build.bat
vendored
Normal 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
705
backend/external/agent_main.go
vendored
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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 {
|
||||
@ -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
|
||||
tokenType := strings.TrimSpace(config.TokenType)
|
||||
if tokenType == "" {
|
||||
tokenType = "Bearer"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(cameraName, oldHardwareName) {
|
||||
suffix := strings.TrimPrefix(cameraName, oldHardwareName)
|
||||
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
if suffix == "" ||
|
||||
strings.HasPrefix(suffix, " ") ||
|
||||
strings.HasPrefix(suffix, "-") ||
|
||||
strings.HasPrefix(suffix, "_") {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if baseName != "" && suffix != "" && baseName != newHardwareName {
|
||||
return newHardwareName + " - " + suffix, true
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("milestone %s get failed: status=%d body=%s", collection, response.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return cameraName, false
|
||||
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
|
||||
}
|
||||
|
||||
457
backend/milestone_cameras.go
Normal file
457
backend/milestone_cameras.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
481
backend/milestone_child_devices.go
Normal file
481
backend/milestone_child_devices.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
190
backend/milestone_inventory.go
Normal file
190
backend/milestone_inventory.go
Normal 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
1119
backend/milestone_storage.go
Normal file
File diff suppressed because it is too large
Load Diff
1107
backend/milestone_sync.go
Normal file
1107
backend/milestone_sync.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
|
||||
213
backend/server_folders_proxy.go
Normal file
213
backend/server_folders_proxy.go
Normal 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
|
||||
}
|
||||
@ -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 <> ''`,
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
362
frontend/src/components/TreeList.tsx
Normal file
362
frontend/src/components/TreeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// frontend/src/pages/administration/users/UsersAdministration.tsx
|
||||
// frontend\src\pages\administration\users\UsersAdministration.tsx
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
|
||||
386
frontend/src/pages/devices/CameraDetailsModal.tsx
Normal file
386
frontend/src/pages/devices/CameraDetailsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,26 +152,44 @@ function MilestoneChildDeviceCards({
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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(
|
||||
const milestoneCameras = sortMilestoneChildDevices(
|
||||
milestoneChildDevices.filter(
|
||||
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
|
||||
),
|
||||
)
|
||||
|
||||
const milestoneMicrophones = milestoneChildDevices.filter(
|
||||
const milestoneMicrophones = sortMilestoneChildDevices(
|
||||
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 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>
|
||||
|
||||
@ -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,18 +315,11 @@ export default function DevicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleMilestoneChildDevice(
|
||||
async function updateMilestoneChildDevice(
|
||||
device: Device,
|
||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||
enabled: boolean,
|
||||
) {
|
||||
setTogglingMilestoneChildDeviceIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.add(childDevice.id)
|
||||
return nextIds
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`,
|
||||
{
|
||||
@ -291,9 +333,28 @@ export default function DevicesPage() {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response, 'Milestone-Child-Device konnte nicht aktualisiert werden'))
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Child-Device konnte nicht aktualisiert werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleMilestoneChildDevice(
|
||||
device: Device,
|
||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||
enabled: boolean,
|
||||
) {
|
||||
setTogglingMilestoneChildDeviceIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.add(childDevice.id)
|
||||
return nextIds
|
||||
})
|
||||
|
||||
try {
|
||||
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>
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user