This commit is contained in:
Linrador 2026-06-11 15:02:19 +02:00
parent dd387a884c
commit 1abfc72e3a
17 changed files with 6313 additions and 1307 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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) {

View File

@ -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)

View File

@ -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 {

View File

@ -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(),

View File

@ -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={

View File

@ -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>
</>
)
}
}

View File

@ -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 && (

View File

@ -159,6 +159,7 @@ export type Operation = {
hardDrive: string
laptop: string
remark: string
milestoneStorage: string
createdAt: string
updatedAt: string
}

View File

@ -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>
)
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}