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"`
|
DisplayName string `json:"displayName"`
|
||||||
ShortName string `json:"shortName"`
|
ShortName string `json:"shortName"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
RecordingEnabled bool `json:"recordingEnabled"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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.display_name,
|
||||||
milestone_hardware_child_devices.short_name,
|
milestone_hardware_child_devices.short_name,
|
||||||
milestone_hardware_child_devices.enabled,
|
milestone_hardware_child_devices.enabled,
|
||||||
|
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
|
||||||
milestone_hardware_child_devices.created_at,
|
milestone_hardware_child_devices.created_at,
|
||||||
milestone_hardware_child_devices.updated_at
|
milestone_hardware_child_devices.updated_at
|
||||||
FROM milestone_hardware_child_devices
|
FROM milestone_hardware_child_devices
|
||||||
@ -290,6 +292,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
&childDevice.DisplayName,
|
&childDevice.DisplayName,
|
||||||
&childDevice.ShortName,
|
&childDevice.ShortName,
|
||||||
&childDevice.Enabled,
|
&childDevice.Enabled,
|
||||||
|
&childDevice.RecordingEnabled,
|
||||||
&childDevice.CreatedAt,
|
&childDevice.CreatedAt,
|
||||||
&childDevice.UpdatedAt,
|
&childDevice.UpdatedAt,
|
||||||
); err != nil {
|
); 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
|
module main
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
@ -9,13 +11,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
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/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/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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@ -24,6 +24,7 @@ type milestoneRuntimeConfig struct {
|
|||||||
TokenType string
|
TokenType string
|
||||||
SkipTLSVerify bool
|
SkipTLSVerify bool
|
||||||
TokenExpiresAt *time.Time
|
TokenExpiresAt *time.Time
|
||||||
|
ServerID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type milestoneHardwarePatchDevice struct {
|
type milestoneHardwarePatchDevice struct {
|
||||||
@ -59,6 +60,12 @@ type milestoneChildPatchDevice struct {
|
|||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type milestoneHardwareUpdateEnvelope struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
|
||||||
|
|
||||||
func (s *Server) getMilestoneChildPatchDevice(
|
func (s *Server) getMilestoneChildPatchDevice(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
deviceID string,
|
deviceID string,
|
||||||
@ -179,14 +186,6 @@ func milestoneCollectionForChildDeviceType(deviceType string) (string, error) {
|
|||||||
return "cameras", nil
|
return "cameras", nil
|
||||||
case "microphone", "microphones":
|
case "microphone", "microphones":
|
||||||
return "microphones", nil
|
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:
|
default:
|
||||||
return "", fmt.Errorf("unsupported milestone child device type: %s", deviceType)
|
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 {
|
if requestedDisplayName != nil {
|
||||||
nextDisplayName = strings.TrimSpace(*requestedDisplayName)
|
nextDisplayName = strings.TrimSpace(*requestedDisplayName)
|
||||||
|
|
||||||
// Wenn der Client einen Namen sendet, immer an Milestone weitergeben.
|
|
||||||
payload["name"] = nextDisplayName
|
payload["name"] = nextDisplayName
|
||||||
|
|
||||||
if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) {
|
if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) {
|
||||||
@ -340,7 +337,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
|
|||||||
nextIPAddress = strings.TrimSpace(addressValue)
|
nextIPAddress = strings.TrimSpace(addressValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben.
|
|
||||||
payload["address"] = nextAddress
|
payload["address"] = nextAddress
|
||||||
|
|
||||||
if nextIPAddress != strings.TrimSpace(device.IPAddress) {
|
if nextIPAddress != strings.TrimSpace(device.IPAddress) {
|
||||||
@ -366,8 +362,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
nextEnabled = *input.Enabled
|
nextEnabled = *input.Enabled
|
||||||
|
|
||||||
// Wenn der Client den Status sendet, immer an Milestone weitergeben.
|
|
||||||
payload["enabled"] = nextEnabled
|
payload["enabled"] = nextEnabled
|
||||||
|
|
||||||
if nextEnabled != device.MilestoneEnabled {
|
if nextEnabled != device.MilestoneEnabled {
|
||||||
@ -489,29 +483,29 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
|
|||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
UPDATE milestone_hardware_child_devices
|
UPDATE milestone_hardware_child_devices
|
||||||
SET
|
SET
|
||||||
name = CASE
|
name = CASE
|
||||||
WHEN name = $2 THEN $3
|
WHEN name = $2 THEN $3
|
||||||
WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1)
|
WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1)
|
||||||
WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1)
|
WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1)
|
||||||
ELSE name
|
ELSE name
|
||||||
END,
|
END,
|
||||||
display_name = CASE
|
display_name = CASE
|
||||||
WHEN display_name = $2 THEN $3
|
WHEN display_name = $2 THEN $3
|
||||||
WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1)
|
WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1)
|
||||||
WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1)
|
WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1)
|
||||||
ELSE display_name
|
ELSE display_name
|
||||||
END,
|
END,
|
||||||
short_name = CASE
|
short_name = CASE
|
||||||
WHEN short_name = $2 THEN $3
|
WHEN short_name = $2 THEN $3
|
||||||
WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1)
|
WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1)
|
||||||
WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1)
|
WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1)
|
||||||
ELSE short_name
|
ELSE short_name
|
||||||
END,
|
END,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE milestone_hardware_id = $1
|
WHERE milestone_hardware_id = $1
|
||||||
`,
|
`,
|
||||||
device.MilestoneHardwareID,
|
device.MilestoneHardwareID,
|
||||||
device.MilestoneDisplayName,
|
device.MilestoneDisplayName,
|
||||||
nextDisplayName,
|
nextDisplayName,
|
||||||
@ -636,12 +630,6 @@ func normalizeMilestonePort(value string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
type milestoneHardwareUpdateEnvelope struct {
|
|
||||||
Data map[string]any `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
|
|
||||||
|
|
||||||
func (s *Server) getMilestoneRuntimeConfig(
|
func (s *Server) getMilestoneRuntimeConfig(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (milestoneRuntimeConfig, error) {
|
) (milestoneRuntimeConfig, error) {
|
||||||
@ -687,7 +675,8 @@ func (s *Server) loadMilestoneRuntimeConfig(
|
|||||||
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
|
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
|
||||||
COALESCE(token_type, ''),
|
COALESCE(token_type, ''),
|
||||||
skip_tls_verify,
|
skip_tls_verify,
|
||||||
token_expires_at
|
token_expires_at,
|
||||||
|
COALESCE(server_id, '')
|
||||||
FROM milestone_settings
|
FROM milestone_settings
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -699,6 +688,7 @@ func (s *Server) loadMilestoneRuntimeConfig(
|
|||||||
&config.TokenType,
|
&config.TokenType,
|
||||||
&config.SkipTLSVerify,
|
&config.SkipTLSVerify,
|
||||||
&tokenExpiresAt,
|
&tokenExpiresAt,
|
||||||
|
&config.ServerID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
@ -975,48 +965,56 @@ func putMilestoneHardware(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceMilestoneDeviceBaseName(
|
func getMilestoneNamedDevice(
|
||||||
cameraName string,
|
ctx context.Context,
|
||||||
oldHardwareName string,
|
config milestoneRuntimeConfig,
|
||||||
newHardwareName string,
|
collection string,
|
||||||
) (string, bool) {
|
deviceID string,
|
||||||
cameraName = strings.TrimSpace(cameraName)
|
) (map[string]any, error) {
|
||||||
oldHardwareName = strings.TrimSpace(oldHardwareName)
|
requestURL := strings.TrimRight(config.Host, "/") +
|
||||||
newHardwareName = strings.TrimSpace(newHardwareName)
|
"/API/rest/v1/" + collection + "/" + url.PathEscape(deviceID)
|
||||||
|
|
||||||
if cameraName == "" || newHardwareName == "" {
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||||
return cameraName, false
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldHardwareName != "" {
|
tokenType := strings.TrimSpace(config.TokenType)
|
||||||
if cameraName == oldHardwareName {
|
if tokenType == "" {
|
||||||
return newHardwareName, true
|
tokenType = "Bearer"
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(cameraName, oldHardwareName) {
|
|
||||||
suffix := strings.TrimPrefix(cameraName, oldHardwareName)
|
|
||||||
|
|
||||||
if suffix == "" ||
|
|
||||||
strings.HasPrefix(suffix, " ") ||
|
|
||||||
strings.HasPrefix(suffix, "-") ||
|
|
||||||
strings.HasPrefix(suffix, "_") {
|
|
||||||
return newHardwareName + suffix, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback für Fälle, in denen die Hardware bereits umbenannt wurde,
|
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
|
||||||
// die Kamera aber noch z. B. "Camera_102_Test - Kamera 1" heißt.
|
request.Header.Set("Accept", "application/json")
|
||||||
if baseName, suffix, found := strings.Cut(cameraName, " - "); found {
|
|
||||||
baseName = strings.TrimSpace(baseName)
|
|
||||||
suffix = strings.TrimSpace(suffix)
|
|
||||||
|
|
||||||
if baseName != "" && suffix != "" && baseName != newHardwareName {
|
client := milestoneHTTPClient(config.SkipTLSVerify)
|
||||||
return newHardwareName + " - " + suffix, true
|
response, err := client.Do(request)
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return cameraName, false
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("milestone %s get failed: status=%d body=%s", collection, response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if envelope.Data == nil {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func milestoneStringValue(value any) string {
|
func milestoneStringValue(value any) string {
|
||||||
@ -1028,428 +1026,30 @@ func milestoneStringValue(value any) string {
|
|||||||
return strings.TrimSpace(text)
|
return strings.TrimSpace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
type milestoneNamedDevice struct {
|
func milestoneBoolValue(value any) bool {
|
||||||
Collection string
|
switch typedValue := value.(type) {
|
||||||
ID string
|
case bool:
|
||||||
Name string
|
return typedValue
|
||||||
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":
|
|
||||||
default:
|
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(
|
func milestoneIntPointerValue(value any) (*int, bool) {
|
||||||
body []byte,
|
switch typedValue := value.(type) {
|
||||||
collection string,
|
case int:
|
||||||
hardwareID string,
|
v := typedValue
|
||||||
) ([]milestoneNamedDevice, error) {
|
return &v, true
|
||||||
var decoded map[string]any
|
case int32:
|
||||||
|
v := int(typedValue)
|
||||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
return &v, true
|
||||||
return nil, err
|
case int64:
|
||||||
}
|
v := int(typedValue)
|
||||||
|
return &v, true
|
||||||
var rawItems []any
|
case float64:
|
||||||
|
v := int(typedValue)
|
||||||
if array, ok := decoded["array"].([]any); ok {
|
return &v, true
|
||||||
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":
|
|
||||||
default:
|
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
|
package main
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
func (s *Server) Routes() http.Handler {
|
func (s *Server) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@ -40,6 +42,15 @@ func (s *Server) Routes() http.Handler {
|
|||||||
s.requireAuth(s.handleUpdateMilestoneChildDevice),
|
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("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
||||||
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
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("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", 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("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)
|
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 '',
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
short_name TEXT NOT NULL DEFAULT '',
|
short_name TEXT NOT NULL DEFAULT '',
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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_version TEXT NOT NULL DEFAULT '',
|
||||||
server_updated_at TIMESTAMPTZ,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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 (
|
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
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_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_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_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_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 <> ''`,
|
`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 type { Notification, User } from './components/types'
|
||||||
import AdministrationPage from './pages/administration/AdministrationPage'
|
import AdministrationPage from './pages/administration/AdministrationPage'
|
||||||
import Feedback from './components/Feedback'
|
import Feedback from './components/Feedback'
|
||||||
|
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
|
||||||
import { hasRight } from './components/permissions'
|
import { hasRight } from './components/permissions'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
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
|
<Route
|
||||||
path="/kalender"
|
path="/kalender"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
DocumentDuplicateIcon,
|
DocumentDuplicateIcon,
|
||||||
|
ServerStackIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
@ -22,7 +23,15 @@ import { NavLink, useLocation } from 'react-router'
|
|||||||
import { hasRight } from './permissions'
|
import { hasRight } from './permissions'
|
||||||
import type { User, Team } from './types'
|
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',
|
name: 'Dashboard',
|
||||||
href: '/',
|
href: '/',
|
||||||
@ -40,6 +49,13 @@ const navigation = [
|
|||||||
icon: VideoCameraIcon,
|
icon: VideoCameraIcon,
|
||||||
right: 'operations:read',
|
right: 'operations:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Milestone-Speicher',
|
||||||
|
href: '/einsaetze/milestone-speicher',
|
||||||
|
icon: ServerStackIcon,
|
||||||
|
right: 'administration:read',
|
||||||
|
isChild: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Kalender',
|
name: 'Kalender',
|
||||||
href: '/kalender',
|
href: '/kalender',
|
||||||
@ -136,11 +152,20 @@ function SidebarContent({
|
|||||||
isActive
|
isActive
|
||||||
? 'bg-white/5 text-white'
|
? 'bg-white/5 text-white'
|
||||||
: 'text-gray-400 hover:bg-white/5 hover: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}
|
{item.name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</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
|
id: string
|
||||||
milestoneHardwareId: string
|
milestoneHardwareId: string
|
||||||
milestoneDeviceId: string
|
milestoneDeviceId: string
|
||||||
deviceType: 'camera' | 'microphone' | string
|
deviceType: 'camera' | 'microphone' | 'inputEvent' | 'output' | 'speaker' | 'metadata' | string
|
||||||
name: string
|
name: string
|
||||||
displayName: string
|
displayName: string
|
||||||
shortName: string
|
shortName: string
|
||||||
enabled: boolean
|
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 = {
|
export type Operation = {
|
||||||
|
|||||||
@ -30,6 +30,9 @@ type MilestoneSettings = {
|
|||||||
serverName: string
|
serverName: string
|
||||||
serverVersion: string
|
serverVersion: string
|
||||||
serverUpdatedAt?: string
|
serverUpdatedAt?: string
|
||||||
|
serverFoldersAgentScheme: string
|
||||||
|
serverFoldersAgentPort: string
|
||||||
|
serverFoldersAgentTokenConfigured: boolean
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
type MilestoneSyncEvent = {
|
type MilestoneSyncEvent = {
|
||||||
@ -84,8 +87,8 @@ function createMilestoneSyncSteps(): TaskListStep[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hardware',
|
id: 'hardware',
|
||||||
name: 'Hardware synchronisieren',
|
name: 'Milestone-Daten synchronisieren',
|
||||||
description: 'Hardware-Geräte und Treiberdaten werden geladen.',
|
description: 'Geräte, Untergeräte und Treiberdaten werden geladen.',
|
||||||
status: 'upcoming',
|
status: 'upcoming',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -210,12 +213,12 @@ export default function Milestone() {
|
|||||||
void loadSettings()
|
void loadSettings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function getHardwareCountMessage(count: number) {
|
function getMilestoneDataCountMessage(count: number) {
|
||||||
if (count === 1) {
|
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) {
|
function handleMilestoneSyncEvent(event: MilestoneSyncEvent) {
|
||||||
@ -271,8 +274,8 @@ export default function Milestone() {
|
|||||||
if (event.type === 'hardware') {
|
if (event.type === 'hardware') {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Hardware synchronisiert',
|
title: 'Milestone-Daten synchronisiert',
|
||||||
message: getHardwareCountMessage(event.hardwareCount ?? 0),
|
message: getMilestoneDataCountMessage(event.hardwareCount ?? 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -403,6 +406,15 @@ export default function Milestone() {
|
|||||||
username: String(formData.get('username') ?? ''),
|
username: String(formData.get('username') ?? ''),
|
||||||
password: String(formData.get('password') ?? ''),
|
password: String(formData.get('password') ?? ''),
|
||||||
skipTlsVerify,
|
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>
|
</DialogTitle>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -633,6 +645,95 @@ export default function Milestone() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-x-3">
|
<div className="mt-6 flex justify-end gap-x-3">
|
||||||
@ -719,6 +820,31 @@ export default function Milestone() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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 && (
|
{settings?.tokenExpiresAt && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-gray-500 dark:text-gray-400">
|
<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 {
|
import {
|
||||||
useEffect,
|
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
|
// frontend\src\pages\devices\ChildDeviceCard.tsx
|
||||||
|
|
||||||
|
import type { KeyboardEvent } from 'react'
|
||||||
import Switch from '../../components/Switch'
|
import Switch from '../../components/Switch'
|
||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
import {
|
import {
|
||||||
@ -16,10 +17,16 @@ type MilestoneChildDevice = NonNullable<Device['milestoneChildDevices']>[number]
|
|||||||
type ChildDeviceCardProps = {
|
type ChildDeviceCardProps = {
|
||||||
item: MilestoneChildDevice
|
item: MilestoneChildDevice
|
||||||
isBusy?: boolean
|
isBusy?: boolean
|
||||||
|
isRecordingBusy?: boolean
|
||||||
onToggle?: (
|
onToggle?: (
|
||||||
childDevice: MilestoneChildDevice,
|
childDevice: MilestoneChildDevice,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
) => void | Promise<void>
|
) => void | Promise<void>
|
||||||
|
onToggleRecording?: (
|
||||||
|
childDevice: MilestoneChildDevice,
|
||||||
|
enabled: boolean,
|
||||||
|
) => void | Promise<void>
|
||||||
|
onOpenDetails?: (childDevice: MilestoneChildDevice) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChildDevicePresentation(deviceType: string) {
|
function getChildDevicePresentation(deviceType: string) {
|
||||||
@ -32,7 +39,7 @@ function getChildDevicePresentation(deviceType: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedType === 'input' || normalizedType === 'inputs') {
|
if (normalizedType === 'inputevent' || normalizedType === 'inputevents') {
|
||||||
return {
|
return {
|
||||||
Icon: ArrowDownTrayIcon,
|
Icon: ArrowDownTrayIcon,
|
||||||
typeLabel: 'Eingang',
|
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({
|
export default function ChildDeviceCard({
|
||||||
item,
|
item,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
isRecordingBusy = false,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onToggleRecording,
|
||||||
|
onOpenDetails,
|
||||||
}: ChildDeviceCardProps) {
|
}: ChildDeviceCardProps) {
|
||||||
const itemTitle =
|
const itemTitle =
|
||||||
item.displayName || item.name || item.shortName || 'Unbenanntes Gerät'
|
item.displayName || item.name || item.shortName || 'Unbenanntes Gerät'
|
||||||
|
|
||||||
const isEnabled = item.enabled ?? true
|
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 { 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 (
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex items-center gap-2.5">
|
<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" />
|
<Icon aria-hidden="true" className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<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}
|
{itemTitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400 sm:hidden">
|
||||||
{typeLabel}
|
{mobileSubtitle || typeLabel}
|
||||||
</p>
|
</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>
|
</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
|
<Switch
|
||||||
id={`milestone-child-${item.id}`}
|
id={`milestone-child-${item.id}`}
|
||||||
checked={isEnabled}
|
checked={isEnabled}
|
||||||
@ -105,6 +208,35 @@ export default function ChildDeviceCard({
|
|||||||
label=""
|
label=""
|
||||||
className="scale-90"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import Textarea from '../../components/Textarea'
|
|||||||
import ChildDeviceCard from './ChildDeviceCard'
|
import ChildDeviceCard from './ChildDeviceCard'
|
||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
import {
|
import {
|
||||||
ArrowDownTrayIcon,
|
|
||||||
ArrowUpTrayIcon,
|
|
||||||
CircleStackIcon,
|
|
||||||
MicrophoneIcon,
|
MicrophoneIcon,
|
||||||
SpeakerWaveIcon,
|
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
|
||||||
@ -31,6 +27,19 @@ type DeviceFormFieldsProps = {
|
|||||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
) => void | Promise<void>
|
) => 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 =
|
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'
|
'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 =
|
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>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
@ -73,21 +82,65 @@ function MilestoneChildDeviceCards({
|
|||||||
emptyText,
|
emptyText,
|
||||||
items,
|
items,
|
||||||
togglingIds,
|
togglingIds,
|
||||||
|
togglingRecordingIds,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onToggleRecording,
|
||||||
|
onOpenDetails,
|
||||||
|
onToggleGroup,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
emptyText: string
|
emptyText: string
|
||||||
items: NonNullable<Device['milestoneChildDevices']>
|
items: NonNullable<Device['milestoneChildDevices']>
|
||||||
togglingIds: Set<string>
|
togglingIds: Set<string>
|
||||||
|
togglingRecordingIds: Set<string>
|
||||||
onToggle?: (
|
onToggle?: (
|
||||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
) => void | Promise<void>
|
) => 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
|
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 (
|
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 justify-between gap-3 border-b border-gray-200 pb-3 dark:border-white/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon
|
<Icon
|
||||||
@ -99,25 +152,43 @@ function MilestoneChildDeviceCards({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
<div className="flex items-center gap-2">
|
||||||
{items.length}
|
<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">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{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">
|
||||||
{emptyText}
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
{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">
|
||||||
{items.map((item) => (
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<ChildDeviceCard
|
{items.map((item) => (
|
||||||
key={`${item.deviceType}-${item.milestoneDeviceId}`}
|
<ChildDeviceCard
|
||||||
item={item}
|
key={`${item.deviceType}-${item.milestoneDeviceId}`}
|
||||||
isBusy={togglingIds.has(item.id)}
|
item={item}
|
||||||
onToggle={onToggle}
|
isBusy={togglingIds.has(item.id)}
|
||||||
/>
|
isRecordingBusy={togglingRecordingIds.has(item.id)}
|
||||||
))}
|
onToggle={onToggle}
|
||||||
|
onToggleRecording={onToggleRecording}
|
||||||
|
onOpenDetails={onOpenDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -228,6 +299,43 @@ async function fetchDeviceLookups() {
|
|||||||
return deviceLookupPromise
|
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({
|
export default function DeviceFormFields({
|
||||||
open,
|
open,
|
||||||
activeTab,
|
activeTab,
|
||||||
@ -237,6 +345,10 @@ export default function DeviceFormFields({
|
|||||||
onToggleRelatedDevice,
|
onToggleRelatedDevice,
|
||||||
togglingMilestoneChildDeviceIds,
|
togglingMilestoneChildDeviceIds,
|
||||||
onToggleMilestoneChildDevice,
|
onToggleMilestoneChildDevice,
|
||||||
|
onToggleMilestoneChildDeviceGroup,
|
||||||
|
onToggleMilestoneChildDeviceRecording,
|
||||||
|
onOpenMilestoneCameraDetails,
|
||||||
|
togglingMilestoneCameraRecordingIds,
|
||||||
}: DeviceFormFieldsProps) {
|
}: DeviceFormFieldsProps) {
|
||||||
const assignableDevices = useMemo(
|
const assignableDevices = useMemo(
|
||||||
() => devices.filter((item) => item.id !== device?.id),
|
() => devices.filter((item) => item.id !== device?.id),
|
||||||
@ -307,28 +419,16 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
const milestoneChildDevices = device?.milestoneChildDevices ?? []
|
const milestoneChildDevices = device?.milestoneChildDevices ?? []
|
||||||
|
|
||||||
const milestoneCameras = milestoneChildDevices.filter(
|
const milestoneCameras = sortMilestoneChildDevices(
|
||||||
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
|
milestoneChildDevices.filter(
|
||||||
|
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const milestoneMicrophones = milestoneChildDevices.filter(
|
const milestoneMicrophones = sortMilestoneChildDevices(
|
||||||
(item) => item.deviceType === 'microphone' || item.deviceType === 'microphones',
|
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 =
|
const milestoneReadonlyClassName =
|
||||||
@ -419,7 +519,7 @@ export default function DeviceFormFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 && (
|
{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">
|
<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}
|
{lookupError}
|
||||||
@ -973,13 +1073,17 @@ export default function DeviceFormFields({
|
|||||||
Dieses Gerät ist kein Milestone-Gerät.
|
Dieses Gerät ist kein Milestone-Gerät.
|
||||||
</div>
|
</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
|
<MilestoneChildDeviceCards
|
||||||
title="Kameras"
|
title="Kameras"
|
||||||
emptyText="Keine Kameras für diese Hardware gefunden."
|
emptyText="Keine Kameras für diese Hardware gefunden."
|
||||||
items={milestoneCameras}
|
items={milestoneCameras}
|
||||||
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
|
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
|
||||||
|
togglingRecordingIds={togglingMilestoneCameraRecordingIds ?? new Set()}
|
||||||
onToggle={onToggleMilestoneChildDevice}
|
onToggle={onToggleMilestoneChildDevice}
|
||||||
|
onToggleRecording={onToggleMilestoneChildDeviceRecording}
|
||||||
|
onOpenDetails={onOpenMilestoneCameraDetails}
|
||||||
|
onToggleGroup={onToggleMilestoneChildDeviceGroup}
|
||||||
icon={VideoCameraIcon}
|
icon={VideoCameraIcon}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -988,45 +1092,11 @@ export default function DeviceFormFields({
|
|||||||
emptyText="Keine Mikrofone für diese Hardware gefunden."
|
emptyText="Keine Mikrofone für diese Hardware gefunden."
|
||||||
items={milestoneMicrophones}
|
items={milestoneMicrophones}
|
||||||
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
|
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
|
||||||
|
togglingRecordingIds={togglingMilestoneCameraRecordingIds ?? new Set()}
|
||||||
onToggle={onToggleMilestoneChildDevice}
|
onToggle={onToggleMilestoneChildDevice}
|
||||||
|
onToggleGroup={onToggleMilestoneChildDeviceGroup}
|
||||||
icon={MicrophoneIcon}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -15,9 +15,14 @@ import Button from '../../components/Button'
|
|||||||
import { Badge } from '../../components/Badge'
|
import { Badge } from '../../components/Badge'
|
||||||
import CreateDeviceModal from './CreateDeviceModal'
|
import CreateDeviceModal from './CreateDeviceModal'
|
||||||
import EditDeviceModal from './EditDeviceModal'
|
import EditDeviceModal from './EditDeviceModal'
|
||||||
|
import CameraDetailsModal from './CameraDetailsModal'
|
||||||
import Tables, { type TableColumn } from '../../components/Tables'
|
import Tables, { type TableColumn } from '../../components/Tables'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
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'
|
import { useNotifications } from '../../components/Notifications'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
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 [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
|
||||||
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
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())
|
const [togglingMilestoneChildDeviceIds, setTogglingMilestoneChildDeviceIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -242,6 +257,40 @@ export default function DevicesPage() {
|
|||||||
}
|
}
|
||||||
}, [editingDevice])
|
}, [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() {
|
async function loadFirmwareCheckState() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
||||||
@ -266,6 +315,33 @@ export default function DevicesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateMilestoneChildDevice(
|
||||||
|
device: Device,
|
||||||
|
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(
|
||||||
|
response,
|
||||||
|
'Milestone-Child-Device konnte nicht aktualisiert werden',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggleMilestoneChildDevice(
|
async function handleToggleMilestoneChildDevice(
|
||||||
device: Device,
|
device: Device,
|
||||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||||
@ -278,22 +354,7 @@ export default function DevicesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
await updateMilestoneChildDevice(device, childDevice, enabled)
|
||||||
`${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await readApiError(response, 'Milestone-Child-Device konnte nicht aktualisiert werden'))
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadDevices({ silent: true })
|
await loadDevices({ silent: true })
|
||||||
|
|
||||||
showToast({
|
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() {
|
async function handleCheckFirmware() {
|
||||||
setIsCheckingFirmware(true)
|
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() {
|
function openCreateModal() {
|
||||||
if (closeCleanupTimeoutRef.current) {
|
if (closeCleanupTimeoutRef.current) {
|
||||||
window.clearTimeout(closeCleanupTimeoutRef.current)
|
window.clearTimeout(closeCleanupTimeoutRef.current)
|
||||||
@ -537,6 +665,12 @@ export default function DevicesPage() {
|
|||||||
setHistoryError(null)
|
setHistoryError(null)
|
||||||
setIsHistoryLoading(false)
|
setIsHistoryLoading(false)
|
||||||
setCreateError(null)
|
setCreateError(null)
|
||||||
|
setIsCameraDetailsOpen(false)
|
||||||
|
setEditingCameraChildDevice(null)
|
||||||
|
setCameraDetails(null)
|
||||||
|
setCameraDetailsError(null)
|
||||||
|
setIsCameraDetailsLoading(false)
|
||||||
|
setIsCameraDetailsSaving(false)
|
||||||
closeCleanupTimeoutRef.current = null
|
closeCleanupTimeoutRef.current = null
|
||||||
}, 220)
|
}, 220)
|
||||||
}
|
}
|
||||||
@ -557,6 +691,131 @@ export default function DevicesPage() {
|
|||||||
void loadDeviceHistory(device.id)
|
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) {
|
function toggleRelatedDevice(deviceId: string) {
|
||||||
setRelatedDeviceIds((currentDeviceIds) => {
|
setRelatedDeviceIds((currentDeviceIds) => {
|
||||||
if (currentDeviceIds.includes(deviceId)) {
|
if (currentDeviceIds.includes(deviceId)) {
|
||||||
@ -1101,6 +1360,39 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
return handleToggleMilestoneChildDevice(editingDevice, childDevice, enabled)
|
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>
|
</div>
|
||||||
|
|||||||
@ -49,6 +49,19 @@ type EditDeviceModalProps = {
|
|||||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
) => void | Promise<void>
|
) => 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']
|
type JournalItems = ComponentProps<typeof Journal>['items']
|
||||||
@ -126,6 +139,19 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
|||||||
return classes.filter(Boolean).join(' ')
|
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({
|
export default function EditDeviceModal({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
@ -144,8 +170,13 @@ export default function EditDeviceModal({
|
|||||||
onJournalEntryUpdate,
|
onJournalEntryUpdate,
|
||||||
togglingMilestoneChildDeviceIds,
|
togglingMilestoneChildDeviceIds,
|
||||||
onToggleMilestoneChildDevice,
|
onToggleMilestoneChildDevice,
|
||||||
|
onToggleMilestoneChildDeviceGroup,
|
||||||
|
togglingMilestoneCameraRecordingIds,
|
||||||
|
onToggleMilestoneChildDeviceRecording,
|
||||||
|
onOpenMilestoneCameraDetails,
|
||||||
}: EditDeviceModalProps) {
|
}: EditDeviceModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||||
|
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && isSaving) {
|
||||||
@ -223,13 +254,13 @@ export default function EditDeviceModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open && Boolean(device)}
|
open={open && Boolean(milestoneFilteredDevice)}
|
||||||
setOpen={handleOpenChange}
|
setOpen={handleOpenChange}
|
||||||
zIndexClassName="z-[9999]"
|
zIndexClassName="z-[9999]"
|
||||||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
key={device?.id ?? 'edit-device'}
|
key={milestoneFilteredDevice?.id ?? 'edit-device'}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
onInvalidCapture={handleInvalid}
|
onInvalidCapture={handleInvalid}
|
||||||
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
||||||
@ -238,11 +269,11 @@ export default function EditDeviceModal({
|
|||||||
<div>
|
<div>
|
||||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
Gerät bearbeiten
|
Gerät bearbeiten
|
||||||
{device && (
|
{milestoneFilteredDevice && (
|
||||||
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
|
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
|
||||||
{[
|
{[
|
||||||
device.inventoryNumber,
|
milestoneFilteredDevice.inventoryNumber,
|
||||||
device.milestoneDisplayName,
|
milestoneFilteredDevice.milestoneDisplayName,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
@ -251,12 +282,12 @@ export default function EditDeviceModal({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="mt-1 space-y-1">
|
<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">
|
<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" />
|
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{device.milestoneSyncedAt
|
{milestoneFilteredDevice.milestoneSyncedAt
|
||||||
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(device.milestoneSyncedAt)}`
|
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(milestoneFilteredDevice.milestoneSyncedAt)}`
|
||||||
: 'Noch nicht mit Milestone synchronisiert.'}
|
: 'Noch nicht mit Milestone synchronisiert.'}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@ -332,12 +363,16 @@ export default function EditDeviceModal({
|
|||||||
<DeviceFormFields
|
<DeviceFormFields
|
||||||
open={open}
|
open={open}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
device={device ?? undefined}
|
device={milestoneFilteredDevice ?? undefined}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
relatedDeviceIds={relatedDeviceIds}
|
relatedDeviceIds={relatedDeviceIds}
|
||||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||||
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
|
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
|
||||||
onToggleMilestoneChildDevice={onToggleMilestoneChildDevice}
|
onToggleMilestoneChildDevice={onToggleMilestoneChildDevice}
|
||||||
|
onToggleMilestoneChildDeviceGroup={onToggleMilestoneChildDeviceGroup}
|
||||||
|
togglingMilestoneCameraRecordingIds={togglingMilestoneCameraRecordingIds}
|
||||||
|
onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording}
|
||||||
|
onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user