updated
This commit is contained in:
parent
dd387a884c
commit
1abfc72e3a
321
backend/external/agent_main.go
vendored
321
backend/external/agent_main.go
vendored
@ -4,14 +4,14 @@
|
||||
//
|
||||
// Endpunkte:
|
||||
// GET /health
|
||||
// GET /server-folders?rootType=storage&path=D:\Milestone
|
||||
// POST /server-folders
|
||||
// GET /server-folders?rootType=storage&path=D:\Milestone
|
||||
// POST /server-folders
|
||||
// PATCH /server-folders
|
||||
// DELETE /server-folders
|
||||
//
|
||||
// Start per Parameter:
|
||||
// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"
|
||||
//
|
||||
// Pflichtwerte: -storage-root, -archive-root und -token. Alternativ können die passenden ENV-Variablen gesetzt werden.
|
||||
//
|
||||
// Alternativ per ENV:
|
||||
// LISTEN_ADDR=:8099
|
||||
// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage
|
||||
@ -38,9 +38,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -82,7 +80,6 @@ type serverFolderRoot struct {
|
||||
|
||||
type serverFolderNode struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Path string `json:"path"`
|
||||
HasChildren bool `json:"hasChildren"`
|
||||
}
|
||||
@ -101,6 +98,11 @@ type createServerFolderRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type renameServerFolderRequest struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
config := readConfigFromFlagsAndEnv()
|
||||
|
||||
@ -113,6 +115,12 @@ func main() {
|
||||
mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateServerFolder(w, r, config)
|
||||
}))
|
||||
mux.HandleFunc("PATCH /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRenameServerFolder(w, r, config)
|
||||
}))
|
||||
mux.HandleFunc("DELETE /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDeleteServerFolder(w, r, config)
|
||||
}))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: config.ListenAddr,
|
||||
@ -136,7 +144,7 @@ func readConfigFromFlagsAndEnv() appConfig {
|
||||
legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone")
|
||||
storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage")
|
||||
archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive")
|
||||
tokenFlag := flag.String("token", "", "Required access token for X-Server-Folders-Token")
|
||||
tokenFlag := flag.String("token", "", "Optional access token for X-Server-Folders-Token")
|
||||
flag.Parse()
|
||||
|
||||
listenAddr := strings.TrimSpace(*addrFlag)
|
||||
@ -163,56 +171,29 @@ func readConfigFromFlagsAndEnv() appConfig {
|
||||
if storageRoots == "" {
|
||||
storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv))
|
||||
}
|
||||
if storageRoots == "" {
|
||||
storageRoots = legacyRoots
|
||||
}
|
||||
|
||||
archiveRoots := strings.TrimSpace(*archiveRootsFlag)
|
||||
if archiveRoots == "" {
|
||||
archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv))
|
||||
}
|
||||
if archiveRoots == "" {
|
||||
archiveRoots = legacyRoots
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(*tokenFlag)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv(agentTokenEnv))
|
||||
}
|
||||
|
||||
config := appConfig{
|
||||
return appConfig{
|
||||
ListenAddr: listenAddr,
|
||||
StorageRoots: storageRoots,
|
||||
ArchiveRoots: archiveRoots,
|
||||
Token: token,
|
||||
}
|
||||
|
||||
validateRequiredConfig(config, legacyRoots)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func validateRequiredConfig(config appConfig, legacyRoots string) {
|
||||
missing := make([]string, 0, 3)
|
||||
|
||||
if strings.TrimSpace(config.StorageRoots) == "" {
|
||||
missing = append(missing, "-storage-root oder "+serverStorageRootsEnv)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.ArchiveRoots) == "" {
|
||||
missing = append(missing, "-archive-root oder "+serverArchiveRootsEnv)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.Token) == "" {
|
||||
missing = append(missing, "-token oder "+agentTokenEnv)
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(legacyRoots) != "" {
|
||||
log.Printf("Hinweis: -roots/%s wird nicht mehr als Ersatz fuer -storage-root und -archive-root akzeptiert.", legacyFolderRootsEnv)
|
||||
}
|
||||
|
||||
log.Fatalf(
|
||||
`Pflichtparameter fehlen: %s. Beispiel: server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"`,
|
||||
strings.Join(missing, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@ -301,11 +282,8 @@ func handleListServerFolders(w http.ResponseWriter, r *http.Request, config appC
|
||||
continue
|
||||
}
|
||||
|
||||
fileSystemName := entry.Name()
|
||||
|
||||
folders = append(folders, serverFolderNode{
|
||||
Name: fileSystemName,
|
||||
DisplayName: windowsDisplayNameForPath(childPath, fileSystemName),
|
||||
Name: entry.Name(),
|
||||
Path: childPath,
|
||||
HasChildren: folderHasVisibleSubdirectories(childPath, roots),
|
||||
})
|
||||
@ -392,13 +370,211 @@ func handleCreateServerFolder(w http.ResponseWriter, r *http.Request, config app
|
||||
"ok": true,
|
||||
"folder": serverFolderNode{
|
||||
Name: filepath.Base(targetPath),
|
||||
DisplayName: windowsDisplayNameForPath(targetPath, filepath.Base(targetPath)),
|
||||
Path: targetPath,
|
||||
HasChildren: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleRenameServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
|
||||
rootType := serverFolderRootTypeFromRequest(r)
|
||||
|
||||
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input renameServerFolderRequest
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath, err := resolveExistingPath(input.Path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
|
||||
return
|
||||
}
|
||||
|
||||
if !isPathAllowed(sourcePath, roots) {
|
||||
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
|
||||
return
|
||||
}
|
||||
|
||||
if isConfiguredRootPath(sourcePath, roots) {
|
||||
writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht umbenannt werden")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
|
||||
return
|
||||
}
|
||||
|
||||
nextName := strings.TrimSpace(input.Name)
|
||||
if !isSafeSingleFolderName(nextName) {
|
||||
writeError(w, http.StatusBadRequest, "Neuer Ordnername ist ungültig")
|
||||
return
|
||||
}
|
||||
|
||||
parentPath := filepath.Dir(sourcePath)
|
||||
targetPath, err := cleanAbsolutePath(filepath.Join(parentPath, nextName))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Neuer Zielpfad ist ungültig")
|
||||
return
|
||||
}
|
||||
|
||||
if !isPathAllowed(targetPath, roots) {
|
||||
writeError(w, http.StatusForbidden, "Neuer Zielpfad ist nicht freigegeben")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.EqualFold(sourcePath, targetPath) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"folder": serverFolderNode{
|
||||
Name: filepath.Base(sourcePath),
|
||||
Path: sourcePath,
|
||||
HasChildren: folderHasVisibleSubdirectories(sourcePath, roots),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
writeError(w, http.StatusConflict, "Zielordner existiert bereits")
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
writeError(w, http.StatusBadRequest, "Zielordner konnte nicht geprüft werden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Rename(sourcePath, targetPath); err != nil {
|
||||
writeError(w, http.StatusForbidden, "Ordner konnte nicht umbenannt werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"folder": serverFolderNode{
|
||||
Name: filepath.Base(targetPath),
|
||||
Path: targetPath,
|
||||
HasChildren: folderHasVisibleSubdirectories(targetPath, roots),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
|
||||
rootType := serverFolderRootTypeFromRequest(r)
|
||||
|
||||
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
requestedPath := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
|
||||
if requestedPath == "" && r.Body != nil {
|
||||
var input struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
if err := readJSON(r, &input); err == nil {
|
||||
requestedPath = strings.TrimSpace(input.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if requestedPath == "" {
|
||||
writeError(w, http.StatusBadRequest, "Pfad fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
targetPath, err := resolveExistingPath(requestedPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
|
||||
return
|
||||
}
|
||||
|
||||
if !isPathAllowed(targetPath, roots) {
|
||||
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
|
||||
return
|
||||
}
|
||||
|
||||
if isConfiguredRootPath(targetPath, roots) {
|
||||
writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht gelöscht werden")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"deleted": false,
|
||||
"path": targetPath,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusConflict, "Ordner konnte nicht gelöscht werden. Er ist vermutlich nicht leer.")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"deleted": true,
|
||||
"path": targetPath,
|
||||
})
|
||||
}
|
||||
|
||||
func isConfiguredRootPath(pathValue string, roots []serverFolderRoot) bool {
|
||||
pathValue, err := cleanAbsolutePath(pathValue)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
rootPath, err := cleanAbsolutePath(root.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(pathValue, rootPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType {
|
||||
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType")))
|
||||
if value == "" {
|
||||
@ -595,57 +771,6 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot)
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
shgfiDisplayName = 0x000000200
|
||||
)
|
||||
|
||||
type shFileInfo struct {
|
||||
HIcon uintptr
|
||||
IIcon int32
|
||||
DwAttributes uint32
|
||||
SzDisplayName [260]uint16
|
||||
SzTypeName [80]uint16
|
||||
}
|
||||
|
||||
var (
|
||||
shell32DLL = syscall.NewLazyDLL("shell32.dll")
|
||||
procSHGetFileInfo = shell32DLL.NewProc("SHGetFileInfoW")
|
||||
)
|
||||
|
||||
func windowsDisplayNameForPath(pathValue string, fallback string) string {
|
||||
pathValue = strings.TrimSpace(pathValue)
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
|
||||
if pathValue == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
pathPointer, err := syscall.UTF16PtrFromString(pathValue)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var info shFileInfo
|
||||
result, _, _ := procSHGetFileInfo.Call(
|
||||
uintptr(unsafe.Pointer(pathPointer)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
unsafe.Sizeof(info),
|
||||
shgfiDisplayName,
|
||||
)
|
||||
|
||||
if result == 0 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(syscall.UTF16ToString(info.SzDisplayName[:]))
|
||||
if displayName == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return displayName
|
||||
}
|
||||
|
||||
func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc {
|
||||
requiredToken = strings.TrimSpace(requiredToken)
|
||||
|
||||
|
||||
@ -1017,15 +1017,6 @@ func getMilestoneNamedDevice(
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
func milestoneStringValue(value any) string {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func milestoneBoolValue(value any) bool {
|
||||
switch typedValue := value.(type) {
|
||||
case bool:
|
||||
|
||||
@ -4,7 +4,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@ -398,6 +402,208 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS
|
||||
return streams
|
||||
}
|
||||
|
||||
type milestoneCameraListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RecordingEnabled bool `json:"recordingEnabled"`
|
||||
RecordingStorageID string `json:"recordingStorageId"`
|
||||
HardwareID string `json:"hardwareId"`
|
||||
}
|
||||
|
||||
func getMilestoneDeviceList(
|
||||
ctx context.Context,
|
||||
config milestoneRuntimeConfig,
|
||||
collection string,
|
||||
) ([]milestoneCameraListItem, error) {
|
||||
requestURL := strings.TrimRight(config.Host, "/") +
|
||||
"/API/rest/v1/" + collection + "?disabled"
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setMilestoneRequestHeaders(request, config, false)
|
||||
|
||||
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("milestone %s list failed: status=%d", collection, response.StatusCode)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rawItems []any
|
||||
if array, ok := decoded["array"].([]any); ok {
|
||||
rawItems = array
|
||||
} else if data, ok := decoded["data"].(map[string]any); ok {
|
||||
if array, ok := data["array"].([]any); ok {
|
||||
rawItems = array
|
||||
}
|
||||
}
|
||||
|
||||
deviceType := "camera"
|
||||
if collection == "microphones" {
|
||||
deviceType = "microphone"
|
||||
}
|
||||
|
||||
items := make([]milestoneCameraListItem, 0, len(rawItems))
|
||||
|
||||
for _, raw := range rawItems {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
id := milestoneNamedDeviceID(item)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
name := milestoneStringValue(item["name"])
|
||||
displayName := milestoneStringValue(item["displayName"])
|
||||
if name == "" {
|
||||
name = displayName
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = name
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
enabled := milestoneItemEnabledValue(item)
|
||||
|
||||
recordingEnabled := false
|
||||
if v, ok := item["recordingEnabled"].(bool); ok {
|
||||
recordingEnabled = v
|
||||
}
|
||||
|
||||
recordingStorageID := ""
|
||||
if rs, ok := item["recordingStorage"].(map[string]any); ok {
|
||||
recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"]))
|
||||
}
|
||||
if recordingStorageID == "" {
|
||||
if relations, ok := item["relations"].(map[string]any); ok {
|
||||
if rs, ok := relations["recordingStorage"].(map[string]any); ok {
|
||||
recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardwareID := milestoneNamedDeviceParentID(item)
|
||||
|
||||
items = append(items, milestoneCameraListItem{
|
||||
ID: id,
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Type: deviceType,
|
||||
Enabled: enabled,
|
||||
RecordingEnabled: recordingEnabled,
|
||||
RecordingStorageID: recordingStorageID,
|
||||
HardwareID: hardwareID,
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Request) {
|
||||
config, err := s.getMilestoneRuntimeConfig(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras")
|
||||
if err != nil {
|
||||
log.Printf("Milestone cameras list failed: %v", err)
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones")
|
||||
if err != nil {
|
||||
log.Printf("Milestone microphones list failed: %v", err)
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
all := make([]milestoneCameraListItem, 0, len(cameras)+len(microphones))
|
||||
all = append(all, cameras...)
|
||||
all = append(all, microphones...)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"devices": all})
|
||||
}
|
||||
|
||||
type patchMilestoneDeviceRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
RecordingEnabled *bool `json:"recordingEnabled"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchMilestoneDevice(w http.ResponseWriter, r *http.Request) {
|
||||
collection := strings.TrimSpace(r.PathValue("collection"))
|
||||
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||
|
||||
if collection != "cameras" && collection != "microphones" {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Gerätekategorie")
|
||||
return
|
||||
}
|
||||
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
var input patchMilestoneDeviceRequest
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{}
|
||||
if input.Enabled != nil {
|
||||
payload["enabled"] = *input.Enabled
|
||||
}
|
||||
if input.RecordingEnabled != nil {
|
||||
payload["recordingEnabled"] = *input.RecordingEnabled
|
||||
}
|
||||
|
||||
if len(payload) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := s.getMilestoneRuntimeConfig(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := patchMilestoneNamedDevice(r.Context(), config, collection, deviceID, payload); err != nil {
|
||||
log.Printf("Milestone device patch failed: collection=%s id=%s error=%v", collection, deviceID, err)
|
||||
writeError(w, http.StatusBadGateway, "Gerät konnte nicht aktualisiert werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func applyMilestoneCameraStreamUpdates(
|
||||
cameraData map[string]any,
|
||||
streamUpdates []updateMilestoneCameraStreamRequest,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -26,15 +26,16 @@ type Operation struct {
|
||||
OperationLeader string `json:"operationLeader"`
|
||||
OperationTeam string `json:"operationTeam"`
|
||||
Legend string `json:"legend"`
|
||||
Camera string `json:"camera"`
|
||||
Router string `json:"router"`
|
||||
Technology string `json:"technology"`
|
||||
Switchbox string `json:"switchbox"`
|
||||
HardDrive string `json:"hardDrive"`
|
||||
Laptop string `json:"laptop"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Camera string `json:"camera"`
|
||||
Router string `json:"router"`
|
||||
Technology string `json:"technology"`
|
||||
Switchbox string `json:"switchbox"`
|
||||
HardDrive string `json:"hardDrive"`
|
||||
Laptop string `json:"laptop"`
|
||||
Remark string `json:"remark"`
|
||||
MilestoneStorage string `json:"milestoneStorage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CreateOperationJournalEntryRequest struct {
|
||||
@ -55,13 +56,14 @@ type CreateOperationRequest struct {
|
||||
OperationLeader string `json:"operationLeader"`
|
||||
OperationTeam string `json:"operationTeam"`
|
||||
Legend string `json:"legend"`
|
||||
Camera string `json:"camera"`
|
||||
Router string `json:"router"`
|
||||
Technology string `json:"technology"`
|
||||
Switchbox string `json:"switchbox"`
|
||||
HardDrive string `json:"hardDrive"`
|
||||
Laptop string `json:"laptop"`
|
||||
Remark string `json:"remark"`
|
||||
Camera string `json:"camera"`
|
||||
Router string `json:"router"`
|
||||
Technology string `json:"technology"`
|
||||
Switchbox string `json:"switchbox"`
|
||||
HardDrive string `json:"hardDrive"`
|
||||
Laptop string `json:"laptop"`
|
||||
Remark string `json:"remark"`
|
||||
MilestoneStorage string `json:"milestoneStorage"`
|
||||
}
|
||||
|
||||
type UpdateOperationRequest = CreateOperationRequest
|
||||
@ -127,6 +129,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
||||
hard_drive,
|
||||
laptop,
|
||||
remark,
|
||||
milestone_storage,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM operations
|
||||
@ -163,6 +166,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
||||
&operation.HardDrive,
|
||||
&operation.Laptop,
|
||||
&operation.Remark,
|
||||
&operation.MilestoneStorage,
|
||||
&operation.CreatedAt,
|
||||
&operation.UpdatedAt,
|
||||
); err != nil {
|
||||
@ -232,9 +236,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
switchbox,
|
||||
hard_drive,
|
||||
laptop,
|
||||
remark
|
||||
remark,
|
||||
milestone_storage
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING
|
||||
id,
|
||||
operation_number,
|
||||
@ -253,6 +258,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
hard_drive,
|
||||
laptop,
|
||||
remark,
|
||||
milestone_storage,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
@ -272,6 +278,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
input.HardDrive,
|
||||
input.Laptop,
|
||||
input.Remark,
|
||||
input.MilestoneStorage,
|
||||
).Scan(
|
||||
&operation.ID,
|
||||
&operation.OperationNumber,
|
||||
@ -290,6 +297,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
&operation.HardDrive,
|
||||
&operation.Laptop,
|
||||
&operation.Remark,
|
||||
&operation.MilestoneStorage,
|
||||
&operation.CreatedAt,
|
||||
&operation.UpdatedAt,
|
||||
)
|
||||
@ -420,6 +428,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
hard_drive = $15,
|
||||
laptop = $16,
|
||||
remark = $17,
|
||||
milestone_storage = $18,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
@ -440,6 +449,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
hard_drive,
|
||||
laptop,
|
||||
remark,
|
||||
milestone_storage,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
@ -460,6 +470,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
input.HardDrive,
|
||||
input.Laptop,
|
||||
input.Remark,
|
||||
input.MilestoneStorage,
|
||||
).Scan(
|
||||
&operation.ID,
|
||||
&operation.OperationNumber,
|
||||
@ -478,6 +489,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
&operation.HardDrive,
|
||||
&operation.Laptop,
|
||||
&operation.Remark,
|
||||
&operation.MilestoneStorage,
|
||||
&operation.CreatedAt,
|
||||
&operation.UpdatedAt,
|
||||
)
|
||||
@ -642,6 +654,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
||||
hard_drive,
|
||||
laptop,
|
||||
remark,
|
||||
milestone_storage,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM operations
|
||||
@ -667,6 +680,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
||||
&operation.HardDrive,
|
||||
&operation.Laptop,
|
||||
&operation.Remark,
|
||||
&operation.MilestoneStorage,
|
||||
&operation.CreatedAt,
|
||||
&operation.UpdatedAt,
|
||||
)
|
||||
@ -726,6 +740,7 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio
|
||||
changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive)
|
||||
changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop)
|
||||
changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark)
|
||||
changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", "", input.MilestoneStorage)
|
||||
|
||||
return changes
|
||||
}
|
||||
@ -749,6 +764,7 @@ func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOper
|
||||
changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive)
|
||||
changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop)
|
||||
changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark)
|
||||
changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", oldOperation.MilestoneStorage, input.MilestoneStorage)
|
||||
|
||||
return changes
|
||||
}
|
||||
@ -792,6 +808,7 @@ func normalizeOperationInput(input *CreateOperationRequest) {
|
||||
input.HardDrive = strings.TrimSpace(input.HardDrive)
|
||||
input.Laptop = strings.TrimSpace(input.Laptop)
|
||||
input.Remark = strings.TrimSpace(input.Remark)
|
||||
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -123,6 +123,36 @@ func (s *Server) Routes() http.Handler {
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleProxyCreateServerFolder)),
|
||||
)
|
||||
|
||||
mux.HandleFunc(
|
||||
"PATCH /admin/server-folders",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleProxyRenameServerFolder)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"DELETE /admin/server-folders",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleProxyDeleteServerFolder)),
|
||||
)
|
||||
|
||||
mux.HandleFunc(
|
||||
"PATCH /admin/milestone/archive-storages/{id}",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"DELETE /admin/milestone/archive-storages/{id}",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneArchiveStorage)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"GET /admin/milestone/storage-information/{id}",
|
||||
s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"GET /admin/milestone/cameras",
|
||||
s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneCameras)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"PATCH /admin/milestone/devices/{collection}/{id}",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handlePatchMilestoneDevice)),
|
||||
)
|
||||
|
||||
mux.HandleFunc(
|
||||
"GET /admin/milestone/storages",
|
||||
s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneStorages)),
|
||||
@ -131,6 +161,14 @@ func (s *Server) Routes() http.Handler {
|
||||
"POST /admin/milestone/storages",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneStorageWithArchive)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"PATCH /admin/milestone/storages/{id}",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneStorage)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"DELETE /admin/milestone/storages/{id}",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneStorage)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"POST /admin/milestone/storages/{id}/archive-storages",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneArchiveStorage)),
|
||||
@ -140,12 +178,8 @@ func (s *Server) Routes() http.Handler {
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneAutoArchiveStorage)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"PATCH /admin/milestone/archive-storages/{id}",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)),
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"GET /admin/milestone/storage-information/{id}",
|
||||
s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)),
|
||||
"GET /admin/milestone/storages/{id}/delete-stream",
|
||||
s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneStorageStream)),
|
||||
)
|
||||
|
||||
return s.cors(mux)
|
||||
|
||||
@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -28,6 +29,14 @@ func (s *Server) handleProxyCreateServerFolder(w http.ResponseWriter, r *http.Re
|
||||
s.proxyServerFoldersRequest(w, r, http.MethodPost)
|
||||
}
|
||||
|
||||
func (s *Server) handleProxyRenameServerFolder(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyServerFoldersRequest(w, r, http.MethodPatch)
|
||||
}
|
||||
|
||||
func (s *Server) handleProxyDeleteServerFolder(w http.ResponseWriter, r *http.Request) {
|
||||
s.proxyServerFoldersRequest(w, r, http.MethodDelete)
|
||||
}
|
||||
|
||||
func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) {
|
||||
config, err := s.getServerFoldersAgentConfig(r.Context())
|
||||
if err != nil {
|
||||
@ -42,7 +51,7 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
var body []byte
|
||||
if method == http.MethodPost {
|
||||
if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete {
|
||||
body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden")
|
||||
@ -63,7 +72,7 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
if method == http.MethodPost {
|
||||
if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
@ -98,6 +107,171 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
|
||||
_, _ = w.Write(responseBody)
|
||||
}
|
||||
|
||||
func (s *Server) requestServerFoldersAgent(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
query url.Values,
|
||||
payload any,
|
||||
) ([]byte, int, error) {
|
||||
config, err := s.getServerFoldersAgentConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
targetURL, err := serverFoldersAgentURL(config, query.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
method,
|
||||
targetURL,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if config.Token != "" {
|
||||
request.Header.Set("X-Server-Folders-Token", config.Token)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return responseBody, response.StatusCode, fmt.Errorf(
|
||||
"server-folders-agent request failed: method=%s url=%s status=%d body=%s",
|
||||
method,
|
||||
targetURL,
|
||||
response.StatusCode,
|
||||
string(responseBody),
|
||||
)
|
||||
}
|
||||
|
||||
return responseBody, response.StatusCode, nil
|
||||
}
|
||||
|
||||
type serverFolderAgentFolderResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Folder struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Path string `json:"path"`
|
||||
HasChildren bool `json:"hasChildren"`
|
||||
} `json:"folder"`
|
||||
}
|
||||
|
||||
func (s *Server) deleteServerFolderPath(
|
||||
ctx context.Context,
|
||||
rootType string,
|
||||
pathValue string,
|
||||
) error {
|
||||
pathValue = strings.TrimSpace(pathValue)
|
||||
if pathValue == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("rootType", rootType)
|
||||
query.Set("path", pathValue)
|
||||
|
||||
responseBody, status, err := s.requestServerFoldersAgent(
|
||||
ctx,
|
||||
http.MethodDelete,
|
||||
query,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"Ordner konnte nicht gelöscht werden: path=%s status=%d body=%s error=%w",
|
||||
pathValue,
|
||||
status,
|
||||
string(responseBody),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) renameServerFolderPath(
|
||||
ctx context.Context,
|
||||
rootType string,
|
||||
pathValue string,
|
||||
nextName string,
|
||||
) (string, error) {
|
||||
pathValue = strings.TrimSpace(pathValue)
|
||||
nextName = strings.TrimSpace(nextName)
|
||||
|
||||
if pathValue == "" || nextName == "" {
|
||||
return pathValue, nil
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("rootType", rootType)
|
||||
|
||||
responseBody, _, err := s.requestServerFoldersAgent(
|
||||
ctx,
|
||||
http.MethodPatch,
|
||||
query,
|
||||
map[string]any{
|
||||
"path": pathValue,
|
||||
"name": nextName,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"Ordner konnte nicht umbenannt werden: path=%s name=%s body=%s error=%w",
|
||||
pathValue,
|
||||
nextName,
|
||||
string(responseBody),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var response serverFolderAgentFolderResponse
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(response.Folder.Path) == "" {
|
||||
return "", fmt.Errorf("Server-Folders-Agent hat keinen neuen Pfad zurückgegeben")
|
||||
}
|
||||
|
||||
return response.Folder.Path, nil
|
||||
}
|
||||
|
||||
func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) {
|
||||
var config serverFoldersAgentConfig
|
||||
|
||||
@ -166,8 +340,6 @@ func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (st
|
||||
}
|
||||
|
||||
func forwardedServerFoldersQuery(values url.Values) string {
|
||||
// rootType=storage/archive wird bewusst weitergeleitet.
|
||||
// Nur Verbindungs-/Auth-Parameter werden entfernt.
|
||||
nextValues := url.Values{}
|
||||
|
||||
for key, value := range values {
|
||||
|
||||
@ -439,12 +439,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
hard_drive TEXT NOT NULL DEFAULT '',
|
||||
laptop TEXT NOT NULL DEFAULT '',
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
milestone_storage TEXT NOT NULL DEFAULT '',
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS operation_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@ -19,6 +19,7 @@ import type { Notification, User } from './components/types'
|
||||
import AdministrationPage from './pages/administration/AdministrationPage'
|
||||
import Feedback from './components/Feedback'
|
||||
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
|
||||
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
|
||||
import { hasRight } from './components/permissions'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
@ -445,6 +446,17 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/einsaetze/milestone-devices"
|
||||
element={
|
||||
<RequireRight user={user} right="administration:read">
|
||||
<MilestoneDevicesPage
|
||||
canWrite={hasRight(user, 'administration:write')}
|
||||
/>
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/kalender"
|
||||
element={
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
Cog6ToothIcon,
|
||||
ComputerDesktopIcon,
|
||||
CpuChipIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
@ -19,6 +20,7 @@ import {
|
||||
HomeIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { hasRight } from './permissions'
|
||||
import type { User, Team } from './types'
|
||||
@ -26,53 +28,79 @@ import type { User, Team } from './types'
|
||||
type NavigationItem = {
|
||||
name: string
|
||||
href: string
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
right?: string
|
||||
isChild?: boolean
|
||||
children?: NavigationItem[]
|
||||
}
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
type NavigationSection = {
|
||||
name?: string
|
||||
items: NavigationItem[]
|
||||
}
|
||||
|
||||
const navigationSections: NavigationSection[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/',
|
||||
icon: HomeIcon,
|
||||
items: [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/',
|
||||
icon: HomeIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Geräte',
|
||||
href: '/geraete',
|
||||
icon: ComputerDesktopIcon,
|
||||
right: 'devices:read',
|
||||
name: 'Betrieb',
|
||||
items: [
|
||||
{
|
||||
name: 'Geräte',
|
||||
href: '/geraete',
|
||||
icon: ComputerDesktopIcon,
|
||||
right: 'devices:read',
|
||||
},
|
||||
{
|
||||
name: 'Einsätze',
|
||||
href: '/einsaetze',
|
||||
icon: VideoCameraIcon,
|
||||
right: 'operations:read',
|
||||
children: [
|
||||
{
|
||||
name: 'Milestone-Speicher',
|
||||
href: '/einsaetze/milestone-speicher',
|
||||
icon: ServerStackIcon,
|
||||
right: 'administration:read',
|
||||
},
|
||||
{
|
||||
name: 'Milestone-Geräte',
|
||||
href: '/einsaetze/milestone-devices',
|
||||
icon: CpuChipIcon,
|
||||
right: 'administration:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Einsätze',
|
||||
href: '/einsaetze',
|
||||
icon: VideoCameraIcon,
|
||||
right: 'operations:read',
|
||||
},
|
||||
{
|
||||
name: 'Milestone-Speicher',
|
||||
href: '/einsaetze/milestone-speicher',
|
||||
icon: ServerStackIcon,
|
||||
right: 'administration:read',
|
||||
isChild: true,
|
||||
},
|
||||
{
|
||||
name: 'Kalender',
|
||||
href: '/kalender',
|
||||
icon: CalendarIcon,
|
||||
right: 'calendar:read',
|
||||
},
|
||||
{
|
||||
name: 'Dokumente',
|
||||
href: '/dokumente',
|
||||
icon: DocumentDuplicateIcon,
|
||||
right: 'documents:read',
|
||||
},
|
||||
{
|
||||
name: 'Berichte',
|
||||
href: '/berichte',
|
||||
icon: ChartPieIcon,
|
||||
right: 'reports:read',
|
||||
name: 'Organisation',
|
||||
items: [
|
||||
{
|
||||
name: 'Kalender',
|
||||
href: '/kalender',
|
||||
icon: CalendarIcon,
|
||||
right: 'calendar:read',
|
||||
},
|
||||
{
|
||||
name: 'Dokumente',
|
||||
href: '/dokumente',
|
||||
icon: DocumentDuplicateIcon,
|
||||
right: 'documents:read',
|
||||
},
|
||||
{
|
||||
name: 'Berichte',
|
||||
href: '/berichte',
|
||||
icon: ChartPieIcon,
|
||||
right: 'reports:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -95,6 +123,49 @@ function getTeamInitial(team: Team) {
|
||||
return team.name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function isPathActive(pathname: string, href: string) {
|
||||
if (href === '/') {
|
||||
return pathname === '/'
|
||||
}
|
||||
|
||||
return pathname === href || pathname.startsWith(`${href}/`)
|
||||
}
|
||||
|
||||
function itemIsActive(item: NavigationItem, pathname: string): boolean {
|
||||
return (
|
||||
isPathActive(pathname, item.href) ||
|
||||
(item.children ?? []).some((child) => itemIsActive(child, pathname))
|
||||
)
|
||||
}
|
||||
|
||||
function filterNavigationItems(
|
||||
items: NavigationItem[],
|
||||
user: User,
|
||||
): NavigationItem[] {
|
||||
return items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children
|
||||
? filterNavigationItems(item.children, user)
|
||||
: undefined,
|
||||
}))
|
||||
.filter((item) => {
|
||||
const canSeeItem = !item.right || hasRight(user, item.right)
|
||||
const hasVisibleChildren = Boolean(item.children?.length)
|
||||
|
||||
return canSeeItem || hasVisibleChildren
|
||||
})
|
||||
}
|
||||
|
||||
function getVisibleNavigationSections(user: User) {
|
||||
return navigationSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: filterNavigationItems(section.items, user),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0)
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
user,
|
||||
setSidebarOpen,
|
||||
@ -105,9 +176,10 @@ function SidebarContent({
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const teams = user.teams ?? []
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const visibleNavigationSections = getVisibleNavigationSections(user)
|
||||
|
||||
const isAdministrationActive =
|
||||
location.pathname === '/administration' ||
|
||||
location.pathname.startsWith('/administration/')
|
||||
@ -116,150 +188,220 @@ function SidebarContent({
|
||||
location.pathname === '/einstellungen' ||
|
||||
location.pathname.startsWith('/einstellungen/')
|
||||
|
||||
const isFeedbackActive =
|
||||
location.pathname === '/feedback'
|
||||
|
||||
const visibleNavigation = navigation.filter((item) =>
|
||||
!item.right || hasRight(user, item.right),
|
||||
)
|
||||
const isFeedbackActive = location.pathname === '/feedback'
|
||||
|
||||
function handleNavigate() {
|
||||
onNavigate?.()
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
function renderNavigationItem(item: NavigationItem) {
|
||||
const active = itemIsActive(item, location.pathname)
|
||||
const children = item.children ?? []
|
||||
const showChildren = children.length > 0
|
||||
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
active
|
||||
? 'text-white'
|
||||
: 'text-slate-400 group-hover:text-white',
|
||||
'size-5 shrink-0 transition',
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</NavLink>
|
||||
|
||||
{showChildren && (
|
||||
<ul
|
||||
role="list"
|
||||
className="ml-[1.35rem] mt-1 space-y-1 border-l border-white/10 pl-3"
|
||||
>
|
||||
{children.map((child) => {
|
||||
const childActive = itemIsActive(child, location.pathname)
|
||||
|
||||
return (
|
||||
<li key={child.name}>
|
||||
<NavLink
|
||||
to={child.href}
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
childActive
|
||||
? 'bg-indigo-500/10 text-indigo-200 ring-1 ring-indigo-400/20'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex items-center gap-x-2 rounded-lg px-2.5 py-2 text-sm/6 font-medium transition',
|
||||
)}
|
||||
>
|
||||
<child.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
childActive
|
||||
? 'text-indigo-200'
|
||||
: 'text-slate-500 group-hover:text-white',
|
||||
'size-4 shrink-0 transition',
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{child.name}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<div className="flex h-16 shrink-0 items-center gap-3">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-white">TEG</p>
|
||||
<p className="truncate text-xs text-slate-400">SE Düsseldorf</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-6">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{visibleNavigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
onClick={handleNavigate}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
isActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
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={classNames(
|
||||
'shrink-0',
|
||||
item.isChild ? 'size-5' : 'size-6',
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
<div className="space-y-6">
|
||||
{visibleNavigationSections.map((section, sectionIndex) => (
|
||||
<section key={section.name ?? `section-${sectionIndex}`}>
|
||||
{section.name && (
|
||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||
{section.name}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<ul role="list" className="space-y-1">
|
||||
{section.items.map(renderNavigationItem)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div className="text-xs/6 font-semibold text-gray-400">
|
||||
Teams
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-5">
|
||||
<h2 className="px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||
Teams
|
||||
</h2>
|
||||
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{teams.length > 0 ? (
|
||||
teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<a
|
||||
href={team.href || '#'}
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-gray-400 group-hover:border-white/20 group-hover:text-white">
|
||||
{getTeamInitial(team)}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
<ul role="list" className="mt-2 space-y-1">
|
||||
{teams.length > 0 ? (
|
||||
teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<a
|
||||
href={team.href || '#'}
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2 text-sm/6 font-semibold transition',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-slate-300 group-hover:border-white/20 group-hover:text-white">
|
||||
{getTeamInitial(team)}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-3 py-1 text-sm/6 text-slate-500">
|
||||
Keine Teams zugeordnet
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-2 py-1 text-sm/6 text-gray-500">
|
||||
Keine Teams zugeordnet
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="mt-auto border-t border-white/10 pt-5">
|
||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||
Hilfe & System
|
||||
</h2>
|
||||
|
||||
<ul role="list" className="space-y-1">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/feedback"
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
isFeedbackActive
|
||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
||||
)}
|
||||
>
|
||||
<ChatBubbleLeftRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
Feedback
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
{hasRight(user, 'settings:read') && (
|
||||
<li>
|
||||
<NavLink
|
||||
to="/einstellungen/konto"
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
isSettingsActive
|
||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
||||
)}
|
||||
>
|
||||
<Cog6ToothIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
Einstellungen
|
||||
</NavLink>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{hasRight(user, 'administration:read') && (
|
||||
<li>
|
||||
<NavLink
|
||||
to="/administration/benutzer"
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
isAdministrationActive
|
||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
||||
)}
|
||||
>
|
||||
<ShieldCheckIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
Administration
|
||||
</NavLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li className="mt-auto space-y-1">
|
||||
<NavLink
|
||||
to="/feedback"
|
||||
onClick={handleNavigate}
|
||||
className={() =>
|
||||
classNames(
|
||||
isFeedbackActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChatBubbleLeftRightIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Feedback
|
||||
</NavLink>
|
||||
|
||||
{hasRight(user, 'settings:read') && (
|
||||
<NavLink
|
||||
to="/einstellungen/konto"
|
||||
onClick={handleNavigate}
|
||||
className={() =>
|
||||
classNames(
|
||||
isSettingsActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Cog6ToothIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Einstellungen
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{hasRight(user, 'administration:read') && (
|
||||
<NavLink
|
||||
to="/administration/benutzer"
|
||||
onClick={handleNavigate}
|
||||
className={() =>
|
||||
classNames(
|
||||
isAdministrationActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Administration
|
||||
</NavLink>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
@ -274,10 +416,14 @@ export default function Sidebar({
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-[2000] lg:hidden">
|
||||
<Dialog
|
||||
open={sidebarOpen}
|
||||
onClose={setSidebarOpen}
|
||||
className="relative z-[2000] lg:hidden"
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||
className="fixed inset-0 bg-gray-950/85 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
@ -287,14 +433,18 @@ export default function Sidebar({
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
|
||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="-m-2.5 p-2.5"
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]">
|
||||
<SidebarContent
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
@ -305,8 +455,8 @@ export default function Sidebar({
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
||||
<div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]">
|
||||
<SidebarContent
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
@ -316,4 +466,4 @@ export default function Sidebar({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,9 @@ export type TreeListNode = {
|
||||
path: string;
|
||||
/** Optionaler echter Dateisystem-/Ordnername, wenn name ein Windows-Anzeigename ist. */
|
||||
secondaryName?: string;
|
||||
/** Markiert einen Ordner, der noch nicht existiert und nur als Vorschau angezeigt wird. */
|
||||
preview?: boolean;
|
||||
previewLabel?: string;
|
||||
children?: TreeListNode[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
@ -30,6 +33,11 @@ type TreeListProps = {
|
||||
onNewFolderValueChange?: (value: string) => void;
|
||||
onCreateFolder?: (folderName: string, folderPath: string) => void;
|
||||
showPathInput?: boolean;
|
||||
pathInputReadOnly?: boolean;
|
||||
newFolderReadOnly?: boolean;
|
||||
previewPath?: string;
|
||||
previewName?: string;
|
||||
previewLabel?: string;
|
||||
showCreateButton?: boolean;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
@ -80,6 +88,147 @@ function joinPath(basePath: string, childPath: string) {
|
||||
return `${base}${getPathSeparator(base)}${child}`;
|
||||
}
|
||||
|
||||
function normalizeTreePath(value: string) {
|
||||
return value.trim().replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase();
|
||||
}
|
||||
|
||||
function pathBaseName(path: string) {
|
||||
const cleanPath = path.trim().replace(/[\\/]+$/, "");
|
||||
const parts = cleanPath.split(/[\\/]+/).filter(Boolean);
|
||||
|
||||
return parts[parts.length - 1] || cleanPath || path;
|
||||
}
|
||||
|
||||
function pathParent(path: string) {
|
||||
const cleanPath = path.trim().replace(/[\\/]+$/, "");
|
||||
|
||||
if (!cleanPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const separatorIndex = Math.max(
|
||||
cleanPath.lastIndexOf("\\"),
|
||||
cleanPath.lastIndexOf("/"),
|
||||
);
|
||||
|
||||
if (separatorIndex <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return cleanPath.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
function treeContainsPath(nodes: TreeListNode[], path: string): boolean {
|
||||
const normalizedPath = normalizeTreePath(path);
|
||||
|
||||
return nodes.some((node) => {
|
||||
if (normalizeTreePath(node.path) === normalizedPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return treeContainsPath(Array.isArray(node.children) ? node.children : [], path);
|
||||
});
|
||||
}
|
||||
|
||||
function markPreviewNode(
|
||||
nodes: TreeListNode[],
|
||||
previewPath: string,
|
||||
previewLabel?: string,
|
||||
): TreeListNode[] {
|
||||
const normalizedPreviewPath = normalizeTreePath(previewPath);
|
||||
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
preview:
|
||||
node.preview || normalizeTreePath(node.path) === normalizedPreviewPath,
|
||||
previewLabel:
|
||||
normalizeTreePath(node.path) === normalizedPreviewPath
|
||||
? previewLabel
|
||||
: node.previewLabel,
|
||||
disabled:
|
||||
node.disabled || normalizeTreePath(node.path) === normalizedPreviewPath,
|
||||
children: Array.isArray(node.children)
|
||||
? markPreviewNode(node.children, previewPath, previewLabel)
|
||||
: node.children,
|
||||
}));
|
||||
}
|
||||
|
||||
function addPreviewNodeToTree(
|
||||
nodes: TreeListNode[],
|
||||
previewPath?: string,
|
||||
previewName?: string,
|
||||
previewLabel = "Vorschau",
|
||||
): TreeListNode[] {
|
||||
const cleanPreviewPath = previewPath?.trim().replace(/[\\/]+$/, "") ?? "";
|
||||
|
||||
if (!cleanPreviewPath) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
if (treeContainsPath(nodes, cleanPreviewPath)) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const parent = pathParent(cleanPreviewPath);
|
||||
const normalizedParent = normalizeTreePath(parent);
|
||||
const previewNode: TreeListNode = {
|
||||
id: `preview:${cleanPreviewPath}`,
|
||||
name: previewName?.trim() || pathBaseName(cleanPreviewPath),
|
||||
path: cleanPreviewPath,
|
||||
preview: true,
|
||||
previewLabel,
|
||||
disabled: true,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (!parent) {
|
||||
return [...nodes, previewNode];
|
||||
}
|
||||
|
||||
let inserted = false;
|
||||
|
||||
function insertInto(currentNodes: TreeListNode[]): TreeListNode[] {
|
||||
return currentNodes.map((node) => {
|
||||
if (normalizeTreePath(node.path) === normalizedParent) {
|
||||
inserted = true;
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: [
|
||||
...(Array.isArray(node.children) ? node.children : []),
|
||||
previewNode,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: insertInto(node.children),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const nextNodes = insertInto(nodes);
|
||||
|
||||
if (inserted) {
|
||||
return nextNodes;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return markPreviewNode(
|
||||
buildFallbackNodes(cleanPreviewPath),
|
||||
cleanPreviewPath,
|
||||
previewLabel,
|
||||
);
|
||||
}
|
||||
|
||||
return nextNodes;
|
||||
}
|
||||
|
||||
function buildFallbackNodes(path: string): TreeListNode[] {
|
||||
const parts = splitPath(path);
|
||||
|
||||
@ -139,6 +288,7 @@ function renderTreeNodes({
|
||||
const active = node.path === selectedPath;
|
||||
const selectable = !disabled && !readOnly && !node.disabled;
|
||||
const children = Array.isArray(node.children) ? node.children : [];
|
||||
const Icon = node.preview ? FolderPlusIcon : FolderIcon;
|
||||
|
||||
return (
|
||||
<li key={node.id}>
|
||||
@ -151,12 +301,14 @@ function renderTreeNodes({
|
||||
active
|
||||
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300"
|
||||
: "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5",
|
||||
node.preview &&
|
||||
"border border-dashed border-indigo-200 bg-indigo-50/70 text-indigo-700 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-200",
|
||||
!selectable &&
|
||||
"cursor-default hover:bg-transparent dark:hover:bg-transparent",
|
||||
)}
|
||||
style={{ paddingLeft: `${0.5 + level * 1.25}rem` }}
|
||||
>
|
||||
<FolderIcon
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
active
|
||||
@ -213,19 +365,34 @@ export default function TreeList({
|
||||
onNewFolderValueChange,
|
||||
onCreateFolder,
|
||||
showPathInput = true,
|
||||
pathInputReadOnly = false,
|
||||
newFolderReadOnly = false,
|
||||
previewPath,
|
||||
previewName,
|
||||
previewLabel,
|
||||
showCreateButton = true,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: TreeListProps) {
|
||||
const [internalNewFolderValue, setInternalNewFolderValue] = useState("");
|
||||
const displayNodes = useMemo(
|
||||
const baseDisplayNodes = useMemo(
|
||||
() =>
|
||||
Array.isArray(nodes) && nodes.length > 0
|
||||
? nodes
|
||||
: buildFallbackNodes(selectedPath),
|
||||
[nodes, selectedPath],
|
||||
);
|
||||
const displayNodes = useMemo(
|
||||
() =>
|
||||
addPreviewNodeToTree(
|
||||
baseDisplayNodes,
|
||||
previewPath,
|
||||
previewName,
|
||||
previewLabel,
|
||||
),
|
||||
[baseDisplayNodes, previewPath, previewName, previewLabel],
|
||||
);
|
||||
|
||||
const canEdit = !disabled && !readOnly;
|
||||
const isNewFolderControlled = newFolderValue !== undefined;
|
||||
@ -284,13 +451,13 @@ export default function TreeList({
|
||||
value={selectedPath}
|
||||
onChange={(event) => onSelectedPathChange?.(event.target.value)}
|
||||
placeholder={pathPlaceholder}
|
||||
disabled={!canEdit}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly || pathInputReadOnly}
|
||||
className={classNames(
|
||||
"block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300",
|
||||
"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 &&
|
||||
(!canEdit || pathInputReadOnly) &&
|
||||
"cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400",
|
||||
)}
|
||||
/>
|
||||
@ -325,6 +492,7 @@ export default function TreeList({
|
||||
type="text"
|
||||
value={currentNewFolderValue}
|
||||
onChange={(event) => updateNewFolderValue(event.target.value)}
|
||||
readOnly={newFolderReadOnly}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@ -333,7 +501,11 @@ export default function TreeList({
|
||||
}}
|
||||
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"
|
||||
className={classNames(
|
||||
"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",
|
||||
newFolderReadOnly &&
|
||||
"cursor-not-allowed text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
/>
|
||||
|
||||
{showCreateButton && (
|
||||
|
||||
@ -159,6 +159,7 @@ export type Operation = {
|
||||
hardDrive: string
|
||||
laptop: string
|
||||
remark: string
|
||||
milestoneStorage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// frontend/src/pages/OperationModal.tsx
|
||||
// frontend/src/pages/operations/OperationModal.tsx
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
@ -13,12 +13,15 @@ import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
CheckIcon,
|
||||
CircleStackIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
DocumentTextIcon,
|
||||
MapPinIcon,
|
||||
PencilSquareIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
UserGroupIcon,
|
||||
VideoCameraIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Modal from '../../components/Modal'
|
||||
@ -55,6 +58,7 @@ type OperationTabId =
|
||||
| 'responsibilities'
|
||||
| 'notes'
|
||||
| 'equipment'
|
||||
| 'storage'
|
||||
| 'journal'
|
||||
|
||||
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
@ -83,6 +87,29 @@ type OperationAssigneesResponse = {
|
||||
officers?: OperationAssigneeUser[]
|
||||
}
|
||||
|
||||
type EquipmentDevice = {
|
||||
id: string
|
||||
inventoryNumber: string
|
||||
manufacturer: string
|
||||
model: string
|
||||
milestoneChildDevices: Array<{
|
||||
id: string
|
||||
deviceType: string
|
||||
name: string
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
type ModalMilestoneStorage = {
|
||||
id: string
|
||||
displayName: string
|
||||
name: string
|
||||
diskPath: string
|
||||
mounted: boolean
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
function getOperationAssigneeName(user: OperationAssigneeUser) {
|
||||
return user.displayName || user.username || user.email
|
||||
}
|
||||
@ -146,6 +173,31 @@ async function readApiError(response: Response, fallback: string) {
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function deviceToComboboxItem(device: EquipmentDevice): ComboboxItem {
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.inventoryNumber || device.model || device.id,
|
||||
secondaryText:
|
||||
[device.manufacturer, device.model].filter(Boolean).join(' · ') ||
|
||||
undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCameraOptionsForDevice(
|
||||
device: EquipmentDevice | undefined,
|
||||
): ComboboxItem[] {
|
||||
if (!device) return []
|
||||
return (device.milestoneChildDevices ?? [])
|
||||
.filter(
|
||||
(c) => c.deviceType === 'camera' || c.deviceType === 'cameras',
|
||||
)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
secondaryText: c.displayName !== c.name ? c.displayName : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const operationTabs: Array<{
|
||||
id: OperationTabId
|
||||
title: string
|
||||
@ -179,9 +231,15 @@ const operationTabs: Array<{
|
||||
{
|
||||
id: 'equipment',
|
||||
title: 'Technik',
|
||||
description: 'Kamera, Router, Switchbox und Laptop.',
|
||||
description: 'Router, Kameras, Switchbox, Laptop und Festplatte.',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
title: 'Speicher',
|
||||
description: 'Milestone-Speicher anlegen und verwalten.',
|
||||
icon: CircleStackIcon,
|
||||
},
|
||||
{
|
||||
id: 'journal',
|
||||
title: 'Journal',
|
||||
@ -270,6 +328,31 @@ export default function OperationModal({
|
||||
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||
|
||||
const [equipmentDevices, setEquipmentDevices] = useState<EquipmentDevice[]>([])
|
||||
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
||||
const [selectedRouter, setSelectedRouter] = useState<ComboboxItem | null>(null)
|
||||
const [selectedCameras, setSelectedCameras] = useState<ComboboxItem[]>([])
|
||||
|
||||
const [milestoneStorages, setMilestoneStorages] = useState<ModalMilestoneStorage[]>([])
|
||||
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
|
||||
const [isLoadingStorages, setIsLoadingStorages] = useState(false)
|
||||
const [showCreateStorageForm, setShowCreateStorageForm] = useState(false)
|
||||
const [isCreatingStorage, setIsCreatingStorage] = useState(false)
|
||||
const [createStorageName, setCreateStorageName] = useState('')
|
||||
const [createStorageDiskPath, setCreateStorageDiskPath] = useState('')
|
||||
const [createStorageError, setCreateStorageError] = useState<string | null>(null)
|
||||
|
||||
const routerOptions = useMemo<ComboboxItem[]>(
|
||||
() => equipmentDevices.map(deviceToComboboxItem),
|
||||
[equipmentDevices],
|
||||
)
|
||||
|
||||
const cameraOptions = useMemo<ComboboxItem[]>(() => {
|
||||
if (!selectedRouter) return []
|
||||
const device = equipmentDevices.find((d) => d.id === selectedRouter.id)
|
||||
return buildCameraOptionsForDevice(device)
|
||||
}, [selectedRouter, equipmentDevices])
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
|
||||
[isEditing],
|
||||
@ -283,6 +366,12 @@ export default function OperationModal({
|
||||
setActiveTab('masterData')
|
||||
setTargetObject(editingOperation?.targetObject ?? '')
|
||||
setKwAddress(editingOperation?.kwAddress ?? '')
|
||||
setMilestoneStorages([])
|
||||
setSelectedMilestoneStorage(null)
|
||||
setShowCreateStorageForm(false)
|
||||
setCreateStorageName('')
|
||||
setCreateStorageDiskPath('')
|
||||
setCreateStorageError(null)
|
||||
}, [
|
||||
open,
|
||||
editingOperation?.id,
|
||||
@ -298,12 +387,21 @@ export default function OperationModal({
|
||||
const abortController = new AbortController()
|
||||
|
||||
void loadOperationAssignees(abortController.signal)
|
||||
void loadEquipmentDevices(abortController.signal)
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeTab !== 'storage') {
|
||||
return
|
||||
}
|
||||
|
||||
void loadMilestoneStorages()
|
||||
}, [open, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
@ -330,6 +428,52 @@ export default function OperationModal({
|
||||
operationTeamOptions,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedRouter(null)
|
||||
setSelectedCameras([])
|
||||
return
|
||||
}
|
||||
|
||||
const routerName = editingOperation?.router ?? ''
|
||||
let routerItem: ComboboxItem | null = null
|
||||
|
||||
if (routerName) {
|
||||
const device = equipmentDevices.find(
|
||||
(d) =>
|
||||
normalizePersonName(d.inventoryNumber || d.model || d.id) ===
|
||||
normalizePersonName(routerName),
|
||||
)
|
||||
routerItem = device
|
||||
? deviceToComboboxItem(device)
|
||||
: createFallbackComboboxItem(routerName)
|
||||
}
|
||||
|
||||
setSelectedRouter(routerItem)
|
||||
|
||||
const cameraNames = parseOperationTeamMembers(editingOperation?.camera ?? '')
|
||||
if (cameraNames.length > 0 && routerItem) {
|
||||
const device = equipmentDevices.find((d) => d.id === routerItem!.id)
|
||||
const options = buildCameraOptionsForDevice(device)
|
||||
const cameras = cameraNames
|
||||
.map(
|
||||
(name) =>
|
||||
findComboboxItemByName(options, name) ??
|
||||
createFallbackComboboxItem(name),
|
||||
)
|
||||
.filter((item): item is ComboboxItem => item !== null)
|
||||
setSelectedCameras(cameras)
|
||||
} else {
|
||||
setSelectedCameras([])
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
editingOperation?.id,
|
||||
editingOperation?.router,
|
||||
editingOperation?.camera,
|
||||
equipmentDevices,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
|
||||
return
|
||||
@ -442,13 +586,105 @@ export default function OperationModal({
|
||||
return
|
||||
}
|
||||
|
||||
// Modal soll trotzdem nutzbar bleiben.
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoadingOperationAssignees(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEquipmentDevices(signal?: AbortSignal) {
|
||||
setIsLoadingDevices(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices`, {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const data = (await response.json()) as { devices?: EquipmentDevice[] }
|
||||
setEquipmentDevices(data.devices ?? [])
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoadingDevices(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMilestoneStorages(autoSelectName?: string) {
|
||||
setIsLoadingStorages(true)
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = (await response.json()) as { storages?: ModalMilestoneStorage[] }
|
||||
const storages = data.storages ?? []
|
||||
setMilestoneStorages(storages)
|
||||
|
||||
const nameToSelect = autoSelectName?.trim() || editingOperation?.milestoneStorage?.trim() || ''
|
||||
if (nameToSelect) {
|
||||
const match = storages.find(
|
||||
(s) =>
|
||||
(s.displayName || s.name) === nameToSelect ||
|
||||
s.name === nameToSelect ||
|
||||
s.displayName === nameToSelect,
|
||||
)
|
||||
if (match) setSelectedMilestoneStorage(match)
|
||||
}
|
||||
} catch {
|
||||
// silent – storage list is informational
|
||||
} finally {
|
||||
setIsLoadingStorages(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateMilestoneStorage(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = createStorageName.trim()
|
||||
const diskPath = createStorageDiskPath.trim()
|
||||
if (!name || !diskPath) {
|
||||
setCreateStorageError('Name und Pfad sind erforderlich.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingStorage(true)
|
||||
setCreateStorageError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
displayName: name,
|
||||
name,
|
||||
diskPath,
|
||||
maxSize: 0,
|
||||
retainMinutes: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
setCreateStorageError(data?.error ?? 'Speicher konnte nicht angelegt werden.')
|
||||
return
|
||||
}
|
||||
|
||||
const createdName = name
|
||||
setCreateStorageName('')
|
||||
setCreateStorageDiskPath('')
|
||||
setShowCreateStorageForm(false)
|
||||
await loadMilestoneStorages(createdName)
|
||||
} catch {
|
||||
setCreateStorageError('Verbindung fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsCreatingStorage(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOperationLeaderChange(item: ComboboxItem | null) {
|
||||
setSelectedOperationLeader(item)
|
||||
}
|
||||
@ -457,6 +693,15 @@ export default function OperationModal({
|
||||
setSelectedOperationTeam(items)
|
||||
}
|
||||
|
||||
function handleRouterChange(item: ComboboxItem | null) {
|
||||
setSelectedRouter(item)
|
||||
setSelectedCameras([])
|
||||
}
|
||||
|
||||
function handleCameraChange(items: ComboboxItem[]) {
|
||||
setSelectedCameras(items)
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && isSaving) {
|
||||
return
|
||||
@ -470,7 +715,7 @@ export default function OperationModal({
|
||||
open={open}
|
||||
setOpen={handleOpenChange}
|
||||
zIndexClassName="z-[9999]"
|
||||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-3xl bg-white dark:bg-gray-900"
|
||||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
||||
>
|
||||
<form
|
||||
key={editingOperation?.id ?? 'create'}
|
||||
@ -482,11 +727,6 @@ export default function OperationModal({
|
||||
name="technology"
|
||||
value={editingOperation?.technology ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="hardDrive"
|
||||
value={editingOperation?.hardDrive ?? ''}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
@ -500,6 +740,24 @@ export default function OperationModal({
|
||||
value={selectedOperationTeam.map((item) => item.name).join(', ')}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="router"
|
||||
value={selectedRouter?.name ?? ''}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="camera"
|
||||
value={selectedCameras.map((c) => c.name).join(', ')}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="milestoneStorage"
|
||||
value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
@ -574,6 +832,8 @@ export default function OperationModal({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="px-4 py-6 sm:px-6">
|
||||
|
||||
{/* Stammdaten */}
|
||||
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
|
||||
<section className={sectionClassName}>
|
||||
<SectionHeader
|
||||
@ -636,6 +896,7 @@ export default function OperationModal({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Adressen */}
|
||||
<div className={activeTab === 'addresses' ? 'block' : 'hidden'}>
|
||||
<section className={sectionClassName}>
|
||||
<SectionHeader
|
||||
@ -667,6 +928,7 @@ export default function OperationModal({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Zuständigkeiten */}
|
||||
<div className={activeTab === 'responsibilities' ? 'block' : 'hidden'}>
|
||||
<section className={sectionClassName}>
|
||||
<SectionHeader
|
||||
@ -760,6 +1022,7 @@ export default function OperationModal({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Zusatzinfos */}
|
||||
<div className={activeTab === 'notes' ? 'block' : 'hidden'}>
|
||||
<section className={sectionClassName}>
|
||||
<SectionHeader
|
||||
@ -801,77 +1064,307 @@ export default function OperationModal({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Technik */}
|
||||
<div className={activeTab === 'equipment' ? 'block' : 'hidden'}>
|
||||
<section className={sectionClassName}>
|
||||
<SectionHeader
|
||||
title="Technik"
|
||||
description="Eingesetzte oder vorgesehene technische Ausstattung."
|
||||
description="Router, zugeordnete Kameras und sonstige technische Ausstattung."
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<label htmlFor="camera" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Kamera
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="camera"
|
||||
name="camera"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.camera ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Kamera-Ausstattung */}
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<VideoCameraIcon className="size-4 shrink-0 text-indigo-600 dark:text-indigo-400" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
Kamera-Ausstattung
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80">
|
||||
Wähle den Router und die zugehörigen Kameras aus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-indigo-900 dark:text-indigo-200">
|
||||
Router
|
||||
</label>
|
||||
<Combobox
|
||||
items={routerOptions}
|
||||
value={selectedRouter}
|
||||
onChange={handleRouterChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Router auswählen oder eingeben'
|
||||
}
|
||||
emptyText="Kein passendes Gerät gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-indigo-900 dark:text-indigo-200">
|
||||
Kameras
|
||||
</label>
|
||||
{selectedCameras.length > 0 && (
|
||||
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
||||
{selectedCameras.length} ausgewählt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MultiCombobox
|
||||
items={cameraOptions}
|
||||
value={selectedCameras}
|
||||
onChange={handleCameraChange}
|
||||
placeholder={
|
||||
!selectedRouter
|
||||
? 'Erst einen Router auswählen'
|
||||
: cameraOptions.length === 0
|
||||
? 'Keine Kameras an diesem Gerät verfügbar'
|
||||
: 'Kameras auswählen'
|
||||
}
|
||||
emptyText="Keine Kameras gefunden."
|
||||
disabled={!selectedRouter || cameraOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="router" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Router
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="router"
|
||||
name="router"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.router ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
{/* Sonstige Ausstattung */}
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Sonstige Ausstattung
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="switchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Switchbox
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="switchbox"
|
||||
name="switchbox"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.switchbox ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="switchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Switchbox
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="switchbox"
|
||||
name="switchbox"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.switchbox ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="laptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Laptop
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="laptop"
|
||||
name="laptop"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.laptop ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="laptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Laptop
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="laptop"
|
||||
name="laptop"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.laptop ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="hardDrive" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Festplatte
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="hardDrive"
|
||||
name="hardDrive"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.hardDrive ?? ''}
|
||||
placeholder="z. B. Samsung T7 500 GB"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Speicher (Milestone) */}
|
||||
<div className={activeTab === 'storage' ? 'block' : 'hidden'}>
|
||||
<div className="space-y-4">
|
||||
{/* Loading */}
|
||||
{isLoadingStorages && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<LoadingSpinner size="md" label="Speicher werden geladen…" center />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoadingStorages && milestoneStorages.length === 0 && !showCreateStorageForm && (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center dark:border-white/20">
|
||||
<CircleStackIcon className="mx-auto size-8 text-gray-300 dark:text-gray-600" />
|
||||
<h3 className="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Keine Speicher vorhanden
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Leg einen neuen Milestone-Speicher an oder verwalte Speicher in der Speicher-Verwaltung.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage cards */}
|
||||
{!isLoadingStorages && milestoneStorages.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{milestoneStorages.map((storage) => {
|
||||
const isSelected = selectedMilestoneStorage?.id === storage.id
|
||||
return (
|
||||
<button
|
||||
key={storage.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMilestoneStorage(isSelected ? null : storage)}
|
||||
className={classNames(
|
||||
'relative flex flex-col gap-1 rounded-lg border p-4 text-left transition-all',
|
||||
isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:ring-indigo-400'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/[0.07]',
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="absolute top-3 right-3">
|
||||
<CheckIcon className="size-4 text-indigo-600 dark:text-indigo-400" />
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 pr-6">
|
||||
<span
|
||||
className={classNames(
|
||||
'truncate text-sm font-semibold',
|
||||
isSelected
|
||||
? 'text-indigo-900 dark:text-indigo-100'
|
||||
: 'text-gray-900 dark:text-white',
|
||||
)}
|
||||
>
|
||||
{storage.displayName || storage.name}
|
||||
</span>
|
||||
{storage.isDefault && (
|
||||
<span className="shrink-0 rounded-full bg-indigo-50 px-1.5 py-0.5 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
Standard
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{storage.diskPath || '—'}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(
|
||||
'mt-0.5 inline-flex w-fit rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||
storage.mounted
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
{storage.mounted ? 'Eingehängt' : 'Nicht eingehängt'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create storage */}
|
||||
{!isLoadingStorages && (
|
||||
<>
|
||||
{!showCreateStorageForm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateStorageForm(true)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600 transition hover:border-indigo-400 hover:text-indigo-600 dark:border-white/20 dark:text-gray-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Neuen Speicher anlegen
|
||||
</button>
|
||||
) : (
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
Neuen Speicher anlegen
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateStorageForm(false)
|
||||
setCreateStorageError(null)
|
||||
}}
|
||||
className="text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createStorageError && (
|
||||
<div className="mb-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{createStorageError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-indigo-900 dark:text-indigo-200">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createStorageName}
|
||||
onChange={(e) => setCreateStorageName(e.target.value)}
|
||||
placeholder="z. B. Einsatz-Speicher-2025"
|
||||
className="mt-1 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-indigo-900 dark:text-indigo-200">
|
||||
Speicherpfad *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createStorageDiskPath}
|
||||
onChange={(e) => setCreateStorageDiskPath(e.target.value)}
|
||||
placeholder={`z. B. D:\\Milestone\\${createStorageName || 'Einsatz'}`}
|
||||
className="mt-1 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-indigo-700/70 dark:text-indigo-300/70">
|
||||
Ein Archivspeicher wird automatisch unter diesem Pfad angelegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
isLoading={isCreatingStorage}
|
||||
onClick={(e) => void handleCreateMilestoneStorage(e as unknown as FormEvent)}
|
||||
leadingIcon={<PlusIcon className="size-3.5" />}
|
||||
>
|
||||
Anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Journal */}
|
||||
{isEditing && (
|
||||
<div className={activeTab === 'journal' ? 'block' : 'hidden'}>
|
||||
<section className={sectionClassName}>
|
||||
@ -940,4 +1433,4 @@ export default function OperationModal({
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,310 @@
|
||||
// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
MicrophoneIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import LoadingSpinner from "../../../components/LoadingSpinner";
|
||||
import Tables, { type TableColumn } from "../../../components/Tables";
|
||||
import { Badge } from "../../../components/Badge";
|
||||
import EmptyState from "../../../components/EmptyState";
|
||||
import { useNotifications } from "../../../components/Notifications";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
|
||||
|
||||
type MilestoneCameraListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: "camera" | "microphone";
|
||||
enabled: boolean;
|
||||
recordingEnabled: boolean;
|
||||
recordingStorageId: string;
|
||||
hardwareId: string;
|
||||
};
|
||||
|
||||
type StorageSimple = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
canWrite?: boolean;
|
||||
};
|
||||
|
||||
export default function MilestoneCamerasPage({ canWrite }: Props) {
|
||||
const { notify } = useNotifications();
|
||||
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
|
||||
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([loadDevices(), loadStorages()]);
|
||||
}, []);
|
||||
|
||||
async function loadDevices() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setError(data?.error ?? "Geräte konnten nicht geladen werden");
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
devices: MilestoneCameraListItem[];
|
||||
};
|
||||
setDevices(data.devices ?? []);
|
||||
} catch {
|
||||
setError("Verbindung zum Server fehlgeschlagen");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json()) as {
|
||||
storages?: { id: string; name: string; displayName: string }[];
|
||||
};
|
||||
const map: Record<string, StorageSimple> = {};
|
||||
for (const s of data.storages ?? []) {
|
||||
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
|
||||
}
|
||||
setStorageMap(map);
|
||||
} catch {
|
||||
// silent – storage names are cosmetic
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(
|
||||
device: MilestoneCameraListItem,
|
||||
field: "enabled" | "recordingEnabled",
|
||||
) {
|
||||
const newValue = !device[field];
|
||||
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: newValue } : d,
|
||||
),
|
||||
);
|
||||
|
||||
const collection = device.type === "camera" ? "cameras" : "microphones";
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [field]: newValue }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||||
),
|
||||
);
|
||||
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
|
||||
}
|
||||
} catch {
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||||
),
|
||||
);
|
||||
notify.error("Verbindung fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
const searchLower = search.trim().toLowerCase();
|
||||
const filtered = searchLower
|
||||
? devices.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(searchLower) ||
|
||||
d.displayName.toLowerCase().includes(searchLower),
|
||||
)
|
||||
: devices;
|
||||
|
||||
const cameraCount = filtered.filter((d) => d.type === "camera").length;
|
||||
const micCount = filtered.filter((d) => d.type === "microphone").length;
|
||||
|
||||
const columns: TableColumn<MilestoneCameraListItem>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
isPrimary: true,
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.type === "camera" ? (
|
||||
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{row.name}
|
||||
</div>
|
||||
{row.displayName && row.displayName !== row.name && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.displayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
header: "Status",
|
||||
hideOnMobile: "sm",
|
||||
render: (row) =>
|
||||
canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleToggle(row, "enabled");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge tone={row.enabled ? "green" : "gray"} hover>
|
||||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge tone={row.enabled ? "green" : "gray"}>
|
||||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recordingEnabled",
|
||||
header: "Aufnahme",
|
||||
hideOnMobile: "md",
|
||||
render: (row) =>
|
||||
canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleToggle(row, "recordingEnabled");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
|
||||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
|
||||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recordingStorageId",
|
||||
header: "Speicher",
|
||||
hideOnMobile: "lg",
|
||||
render: (row) => {
|
||||
const storage = storageMap[row.recordingStorageId];
|
||||
if (storage) {
|
||||
return (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{storage.displayName || storage.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (row.recordingStorageId) {
|
||||
return (
|
||||
<span className="font-mono text-xs text-gray-400">
|
||||
{row.recordingStorageId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">—</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Milestone-Geräte
|
||||
</h1>
|
||||
{!isLoading && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
|
||||
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Suchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon={VideoCameraIcon}
|
||||
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
|
||||
description={
|
||||
search
|
||||
? "Versuche einen anderen Suchbegriff."
|
||||
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length > 0 && (
|
||||
<Tables
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
variant="border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,310 @@
|
||||
// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
MicrophoneIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import LoadingSpinner from "../../../components/LoadingSpinner";
|
||||
import Tables, { type TableColumn } from "../../../components/Tables";
|
||||
import { Badge } from "../../../components/Badge";
|
||||
import EmptyState from "../../../components/EmptyState";
|
||||
import { useNotifications } from "../../../components/Notifications";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
|
||||
|
||||
type MilestoneCameraListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: "camera" | "microphone";
|
||||
enabled: boolean;
|
||||
recordingEnabled: boolean;
|
||||
recordingStorageId: string;
|
||||
hardwareId: string;
|
||||
};
|
||||
|
||||
type StorageSimple = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
canWrite?: boolean;
|
||||
};
|
||||
|
||||
export default function MilestoneCamerasPage({ canWrite }: Props) {
|
||||
const { notify } = useNotifications();
|
||||
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
|
||||
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([loadDevices(), loadStorages()]);
|
||||
}, []);
|
||||
|
||||
async function loadDevices() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setError(data?.error ?? "Geräte konnten nicht geladen werden");
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
devices: MilestoneCameraListItem[];
|
||||
};
|
||||
setDevices(data.devices ?? []);
|
||||
} catch {
|
||||
setError("Verbindung zum Server fehlgeschlagen");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json()) as {
|
||||
storages?: { id: string; name: string; displayName: string }[];
|
||||
};
|
||||
const map: Record<string, StorageSimple> = {};
|
||||
for (const s of data.storages ?? []) {
|
||||
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
|
||||
}
|
||||
setStorageMap(map);
|
||||
} catch {
|
||||
// silent – storage names are cosmetic
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(
|
||||
device: MilestoneCameraListItem,
|
||||
field: "enabled" | "recordingEnabled",
|
||||
) {
|
||||
const newValue = !device[field];
|
||||
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: newValue } : d,
|
||||
),
|
||||
);
|
||||
|
||||
const collection = device.type === "camera" ? "cameras" : "microphones";
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [field]: newValue }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||||
),
|
||||
);
|
||||
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
|
||||
}
|
||||
} catch {
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||||
),
|
||||
);
|
||||
notify.error("Verbindung fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
const searchLower = search.trim().toLowerCase();
|
||||
const filtered = searchLower
|
||||
? devices.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(searchLower) ||
|
||||
d.displayName.toLowerCase().includes(searchLower),
|
||||
)
|
||||
: devices;
|
||||
|
||||
const cameraCount = filtered.filter((d) => d.type === "camera").length;
|
||||
const micCount = filtered.filter((d) => d.type === "microphone").length;
|
||||
|
||||
const columns: TableColumn<MilestoneCameraListItem>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
isPrimary: true,
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.type === "camera" ? (
|
||||
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{row.name}
|
||||
</div>
|
||||
{row.displayName && row.displayName !== row.name && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.displayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
header: "Status",
|
||||
hideOnMobile: "sm",
|
||||
render: (row) =>
|
||||
canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleToggle(row, "enabled");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge tone={row.enabled ? "green" : "gray"} hover>
|
||||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge tone={row.enabled ? "green" : "gray"}>
|
||||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recordingEnabled",
|
||||
header: "Aufnahme",
|
||||
hideOnMobile: "md",
|
||||
render: (row) =>
|
||||
canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleToggle(row, "recordingEnabled");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
|
||||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
|
||||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recordingStorageId",
|
||||
header: "Speicher",
|
||||
hideOnMobile: "lg",
|
||||
render: (row) => {
|
||||
const storage = storageMap[row.recordingStorageId];
|
||||
if (storage) {
|
||||
return (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{storage.displayName || storage.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (row.recordingStorageId) {
|
||||
return (
|
||||
<span className="font-mono text-xs text-gray-400">
|
||||
{row.recordingStorageId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">—</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Milestone-Geräte
|
||||
</h1>
|
||||
{!isLoading && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
|
||||
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Suchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon={VideoCameraIcon}
|
||||
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
|
||||
description={
|
||||
search
|
||||
? "Versuche einen anderen Suchbegriff."
|
||||
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length > 0 && (
|
||||
<Tables
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
variant="border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,310 @@
|
||||
// frontend\src\pages\operations\milestone-devices\MilestoneDevicesPage.tsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
MicrophoneIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import LoadingSpinner from "../../../components/LoadingSpinner";
|
||||
import Tables, { type TableColumn } from "../../../components/Tables";
|
||||
import { Badge } from "../../../components/Badge";
|
||||
import EmptyState from "../../../components/EmptyState";
|
||||
import { useNotifications } from "../../../components/Notifications";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
|
||||
|
||||
type MilestoneCameraListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: "camera" | "microphone";
|
||||
enabled: boolean;
|
||||
recordingEnabled: boolean;
|
||||
recordingStorageId: string;
|
||||
hardwareId: string;
|
||||
};
|
||||
|
||||
type StorageSimple = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
canWrite?: boolean;
|
||||
};
|
||||
|
||||
export default function MilestoneDevicesPage({ canWrite }: Props) {
|
||||
const { notify } = useNotifications();
|
||||
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
|
||||
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([loadDevices(), loadStorages()]);
|
||||
}, []);
|
||||
|
||||
async function loadDevices() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setError(data?.error ?? "Geräte konnten nicht geladen werden");
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
devices: MilestoneCameraListItem[];
|
||||
};
|
||||
setDevices(data.devices ?? []);
|
||||
} catch {
|
||||
setError("Verbindung zum Server fehlgeschlagen");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json()) as {
|
||||
storages?: { id: string; name: string; displayName: string }[];
|
||||
};
|
||||
const map: Record<string, StorageSimple> = {};
|
||||
for (const s of data.storages ?? []) {
|
||||
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
|
||||
}
|
||||
setStorageMap(map);
|
||||
} catch {
|
||||
// silent – storage names are cosmetic
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(
|
||||
device: MilestoneCameraListItem,
|
||||
field: "enabled" | "recordingEnabled",
|
||||
) {
|
||||
const newValue = !device[field];
|
||||
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: newValue } : d,
|
||||
),
|
||||
);
|
||||
|
||||
const collection = device.type === "camera" ? "cameras" : "microphones";
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [field]: newValue }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||||
),
|
||||
);
|
||||
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
|
||||
}
|
||||
} catch {
|
||||
setDevices((current) =>
|
||||
current.map((d) =>
|
||||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||||
),
|
||||
);
|
||||
notify.error("Verbindung fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
const searchLower = search.trim().toLowerCase();
|
||||
const filtered = searchLower
|
||||
? devices.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(searchLower) ||
|
||||
d.displayName.toLowerCase().includes(searchLower),
|
||||
)
|
||||
: devices;
|
||||
|
||||
const cameraCount = filtered.filter((d) => d.type === "camera").length;
|
||||
const micCount = filtered.filter((d) => d.type === "microphone").length;
|
||||
|
||||
const columns: TableColumn<MilestoneCameraListItem>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
isPrimary: true,
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.type === "camera" ? (
|
||||
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{row.name}
|
||||
</div>
|
||||
{row.displayName && row.displayName !== row.name && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.displayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
header: "Status",
|
||||
hideOnMobile: "sm",
|
||||
render: (row) =>
|
||||
canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleToggle(row, "enabled");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge tone={row.enabled ? "green" : "gray"} hover>
|
||||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge tone={row.enabled ? "green" : "gray"}>
|
||||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recordingEnabled",
|
||||
header: "Aufnahme",
|
||||
hideOnMobile: "md",
|
||||
render: (row) =>
|
||||
canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleToggle(row, "recordingEnabled");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
|
||||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
|
||||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recordingStorageId",
|
||||
header: "Speicher",
|
||||
hideOnMobile: "lg",
|
||||
render: (row) => {
|
||||
const storage = storageMap[row.recordingStorageId];
|
||||
if (storage) {
|
||||
return (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{storage.displayName || storage.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (row.recordingStorageId) {
|
||||
return (
|
||||
<span className="font-mono text-xs text-gray-400">
|
||||
{row.recordingStorageId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">—</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Milestone-Geräte
|
||||
</h1>
|
||||
{!isLoading && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
|
||||
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Suchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon={VideoCameraIcon}
|
||||
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
|
||||
description={
|
||||
search
|
||||
? "Versuche einen anderen Suchbegriff."
|
||||
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length > 0 && (
|
||||
<Tables
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
variant="border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user