Compare commits
No commits in common. "dc16a0fbd0b59e3bcc5e3617b1232c31ab1b1399" and "79fcc20b79c73648cbd52863225e1e82eae0e201" have entirely different histories.
dc16a0fbd0
...
79fcc20b79
11 changed files with 25 additions and 575 deletions
|
|
@ -526,67 +526,6 @@ Löscht ein Medien-Asset (und physische Datei falls lokal gespeichert).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Display-Steuerung (JSON API)
|
|
||||||
|
|
||||||
Beide Endpunkte erfordern `RequireAuth` + `RequireScreenAccess` (`authScreen`-Middleware).
|
|
||||||
|
|
||||||
### POST /api/v1/screens/{screenSlug}/display
|
|
||||||
|
|
||||||
Sendet einen MQTT-Befehl zum Ein- oder Ausschalten des physischen Displays.
|
|
||||||
|
|
||||||
**Auth:** Erforderlich (Bearer-Token oder Session-Cookie). Screen-Zugriff erforderlich.
|
|
||||||
|
|
||||||
**Path-Parameter:**
|
|
||||||
- `screenSlug` — Slug des Screens
|
|
||||||
|
|
||||||
**Request-Body:**
|
|
||||||
```json
|
|
||||||
{"state": "on"}
|
|
||||||
```
|
|
||||||
oder
|
|
||||||
```json
|
|
||||||
{"state": "off"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `204 No Content`
|
|
||||||
|
|
||||||
**Fehler:**
|
|
||||||
- `400 Bad Request` — `state` ist nicht `"on"` oder `"off"`, oder ungültiges JSON
|
|
||||||
- `502 Bad Gateway` — MQTT-Publish fehlgeschlagen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /api/v1/screens/{screenSlug}/schedule
|
|
||||||
|
|
||||||
Speichert den Zeitplan für das automatische Ein-/Ausschalten eines Displays.
|
|
||||||
|
|
||||||
**Auth:** Erforderlich. Screen-Zugriff erforderlich.
|
|
||||||
|
|
||||||
**Path-Parameter:**
|
|
||||||
- `screenSlug` — Slug des Screens
|
|
||||||
|
|
||||||
**Request-Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schedule_enabled": true,
|
|
||||||
"power_on_time": "06:00",
|
|
||||||
"power_off_time": "22:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Zeitangaben müssen im Format `HH:MM` (24h) oder als leerer String `""` übergeben werden.
|
|
||||||
Der Scheduler prüft jede Minute, ob die aktuelle Uhrzeit mit `power_on_time` oder `power_off_time`
|
|
||||||
übereinstimmt, und sendet dann den entsprechenden MQTT-Befehl.
|
|
||||||
|
|
||||||
**Response:** `204 No Content`
|
|
||||||
|
|
||||||
**Fehler:**
|
|
||||||
- `400 Bad Request` — Zeitformat ungültig (nicht `HH:MM`), oder ungültiges JSON
|
|
||||||
- `404 Not Found` — Screen nicht vorhanden
|
|
||||||
- `500 Internal Server Error` — DB-Fehler
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Message Wall
|
## Message Wall
|
||||||
|
|
||||||
### POST /api/v1/tools/message-wall/resolve
|
### POST /api/v1/tools/message-wall/resolve
|
||||||
|
|
|
||||||
|
|
@ -488,41 +488,6 @@ temperature_celsius numeric(5,2) null
|
||||||
updated_at timestamptz not null
|
updated_at timestamptz not null
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hinweis:** Die Spalten oben beschreiben das geplante Langzeitschema. Migration `004_screen_status.sql`
|
|
||||||
implementiert eine vereinfachte Version dieser Tabelle mit nur drei Spalten:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
screen_id TEXT PRIMARY KEY REFERENCES screens(id) ON DELETE CASCADE
|
|
||||||
display_state TEXT NOT NULL DEFAULT 'unknown' -- "on", "off", "unknown"
|
|
||||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
```
|
|
||||||
|
|
||||||
Diese kompakte Tabelle speichert ausschliesslich den zuletzt vom Agent gemeldeten Display-Zustand.
|
|
||||||
Weitere Laufzeitfelder (online, heartbeat, etc.) werden in einem spaeteren Migrations-Schritt ergaenzt.
|
|
||||||
|
|
||||||
### `screen_schedules`
|
|
||||||
|
|
||||||
Zweck:
|
|
||||||
|
|
||||||
- taeglich konfigurierbarer Ein-/Ausschalt-Zeitplan pro Screen
|
|
||||||
|
|
||||||
Migration: `005_screen_schedules.sql`
|
|
||||||
|
|
||||||
Spalten:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
screen_id TEXT PRIMARY KEY REFERENCES screens(id) ON DELETE CASCADE
|
|
||||||
schedule_enabled BOOLEAN NOT NULL DEFAULT false
|
|
||||||
power_on_time TEXT NOT NULL DEFAULT '' -- HH:MM (24h), leer = nicht gesetzt
|
|
||||||
power_off_time TEXT NOT NULL DEFAULT '' -- HH:MM (24h), leer = nicht gesetzt
|
|
||||||
```
|
|
||||||
|
|
||||||
Regeln:
|
|
||||||
|
|
||||||
- Wird durch den `scheduler`-Package ausgewertet (prueft jede Minute alle aktiven Zeitplaene)
|
|
||||||
- `schedule_enabled = false` bedeutet: Zeitplan vorhanden, aber deaktiviert
|
|
||||||
- Leere Zeitfelder bedeuten: kein Einschalt- bzw. kein Ausschaltbefehl
|
|
||||||
|
|
||||||
### `screen_snapshots`
|
### `screen_snapshots`
|
||||||
|
|
||||||
Zweck:
|
Zweck:
|
||||||
|
|
@ -591,10 +556,6 @@ und sind vollstaendig unter den Abschnitten `users` und `sessions` oben beschrie
|
||||||
Die Screen-Usserverwaltung wird durch `server/backend/internal/db/migrations/003_user_screen_permissions.sql` angelegt
|
Die Screen-Usserverwaltung wird durch `server/backend/internal/db/migrations/003_user_screen_permissions.sql` angelegt
|
||||||
und ist unter dem Abschnitt `user_screen_permissions` oben beschrieben.
|
und ist unter dem Abschnitt `user_screen_permissions` oben beschrieben.
|
||||||
|
|
||||||
Die Display-Steuerung wird durch zwei weitere Migrationen angelegt:
|
|
||||||
- `004_screen_status.sql` — vereinfachte `screen_status`-Tabelle (display_state + reported_at)
|
|
||||||
- `005_screen_schedules.sql` — `screen_schedules`-Tabelle fuer Zeitplaene
|
|
||||||
|
|
||||||
Der `AuthStore` (`internal/store/auth.go`) stellt folgende Methoden bereit:
|
Der `AuthStore` (`internal/store/auth.go`) stellt folgende Methoden bereit:
|
||||||
|
|
||||||
- `GetUserByUsername(ctx, username)` — Nutzer per Username laden (inkl. `TenantSlug` via LEFT JOIN)
|
- `GetUserByUsername(ctx, username)` — Nutzer per Username laden (inkl. `TenantSlug` via LEFT JOIN)
|
||||||
|
|
|
||||||
|
|
@ -29,19 +29,9 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
||||||
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (manage-Package)
|
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (manage-Package)
|
||||||
- `internal/httpapi/tenant/` — Tenant-Self-Service-Dashboard
|
- `internal/httpapi/tenant/` — Tenant-Self-Service-Dashboard
|
||||||
- `internal/httpapi/tenant/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (tenant-Package, Import-Cycle-Isolation)
|
- `internal/httpapi/tenant/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (tenant-Package, Import-Cycle-Isolation)
|
||||||
- `internal/mqttnotifier/` — MQTT-Notifizierungen (`NotifyChanged`, `RequestScreenshot`, `SendDisplayCommand`)
|
- `internal/mqttnotifier/` — MQTT-Notifizierungen (`NotifyChanged`, `RequestScreenshot`)
|
||||||
- `internal/scheduler/` — Display-Zeitplan-Scheduler (prueft jede Minute aktive Zeitplaene und sendet MQTT-Befehle)
|
|
||||||
- `internal/reqcontext/` — Context-Keys fuer authentifizierten User
|
- `internal/reqcontext/` — Context-Keys fuer authentifizierten User
|
||||||
|
|
||||||
### scheduler (`internal/scheduler/scheduler.go`)
|
|
||||||
|
|
||||||
Startet eine Goroutine (`scheduler.Run`), die jede Minute alle aktiven Zeitplaene aus
|
|
||||||
`screen_schedules` laedt und — sofern `power_on_time` oder `power_off_time` mit der aktuellen
|
|
||||||
Uhrzeit übereinstimmt — per MQTT den Befehl `display_on` bzw. `display_off` sendet.
|
|
||||||
|
|
||||||
Der Scheduler wird in `internal/app/app.go` als Goroutine gestartet und laeuft bis zum
|
|
||||||
Kontext-Abbruch beim Server-Shutdown.
|
|
||||||
|
|
||||||
## Datenbank-Stores
|
## Datenbank-Stores
|
||||||
|
|
||||||
### AuthStore (`internal/store/auth.go`)
|
### AuthStore (`internal/store/auth.go`)
|
||||||
|
|
@ -115,8 +105,6 @@ Kontext-Abbruch beim Server-Shutdown.
|
||||||
| PATCH | `/api/v1/playlists/{playlistId}/duration` | Standard-Dauer setzen (API) |
|
| PATCH | `/api/v1/playlists/{playlistId}/duration` | Standard-Dauer setzen (API) |
|
||||||
| DELETE | `/api/v1/media/{id}` | Medium loeschen (API) |
|
| DELETE | `/api/v1/media/{id}` | Medium loeschen (API) |
|
||||||
| GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen |
|
| GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen |
|
||||||
| POST | `/api/v1/screens/{screenSlug}/display` | Display ein-/ausschalten (MQTT) |
|
|
||||||
| POST | `/api/v1/screens/{screenSlug}/schedule` | Display-Zeitplan speichern |
|
|
||||||
|
|
||||||
### Nur Admins (`RequireAuth` + `RequireAdmin`)
|
### Nur Admins (`RequireAuth` + `RequireAdmin`)
|
||||||
|
|
||||||
|
|
@ -185,5 +173,3 @@ Middleware zur rollenbasierten Zugriffskontrolle auf Screen-Ressourcen.
|
||||||
- `001_core.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.)
|
- `001_core.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.)
|
||||||
- `002_auth.sql` — Auth-Tabellen (`users`, `sessions`)
|
- `002_auth.sql` — Auth-Tabellen (`users`, `sessions`)
|
||||||
- `003_user_screen_permissions.sql` — Screen-User Management (`user_screen_permissions`)
|
- `003_user_screen_permissions.sql` — Screen-User Management (`user_screen_permissions`)
|
||||||
- `004_screen_status.sql` — Display-Zustand pro Screen (`screen_status`: screen_id, display_state, reported_at)
|
|
||||||
- `005_screen_schedules.sql` — Zeitplan pro Screen (`screen_schedules`: screen_id, schedule_enabled, power_on_time, power_off_time)
|
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,16 @@ import (
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/db"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/db"
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/scheduler"
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
server *http.Server
|
server *http.Server
|
||||||
notifier *mqttnotifier.Notifier
|
notifier *mqttnotifier.Notifier
|
||||||
authStore *store.AuthStore
|
authStore *store.AuthStore
|
||||||
scheduleStore *store.ScreenScheduleStore
|
dbPool *db.Pool // V7: für db.Close() im Shutdown
|
||||||
screenStore *store.ScreenStore
|
logger *log.Logger
|
||||||
dbPool *db.Pool // V7: für db.Close() im Shutdown
|
|
||||||
logger *log.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() (*App, error) {
|
func New() (*App, error) {
|
||||||
|
|
@ -61,7 +58,6 @@ func New() (*App, error) {
|
||||||
media := store.NewMediaStore(pool.Pool)
|
media := store.NewMediaStore(pool.Pool)
|
||||||
playlists := store.NewPlaylistStore(pool.Pool)
|
playlists := store.NewPlaylistStore(pool.Pool)
|
||||||
authStore := store.NewAuthStore(pool.Pool)
|
authStore := store.NewAuthStore(pool.Pool)
|
||||||
schedules := store.NewScreenScheduleStore(pool.Pool)
|
|
||||||
|
|
||||||
// Ensure admin user exists — generate a random password if none is configured.
|
// Ensure admin user exists — generate a random password if none is configured.
|
||||||
adminPassword := cfg.AdminPassword
|
adminPassword := cfg.AdminPassword
|
||||||
|
|
@ -100,21 +96,18 @@ func New() (*App, error) {
|
||||||
AuthStore: authStore,
|
AuthStore: authStore,
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
ScreenshotStore: ss,
|
ScreenshotStore: ss,
|
||||||
ScheduleStore: schedules,
|
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
UploadDir: cfg.UploadDir,
|
UploadDir: cfg.UploadDir,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler},
|
server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler},
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
authStore: authStore,
|
authStore: authStore,
|
||||||
scheduleStore: schedules,
|
dbPool: pool, // V7: Referenz für Shutdown
|
||||||
screenStore: screens,
|
logger: logger,
|
||||||
dbPool: pool, // V7: Referenz für Shutdown
|
|
||||||
logger: logger,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,9 +137,6 @@ func (a *App) Run() error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Display-Zeitplan-Scheduler
|
|
||||||
go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.notifier)
|
|
||||||
|
|
||||||
// W2: Signal-Handler für Graceful Shutdown.
|
// W2: Signal-Handler für Graceful Shutdown.
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Migration 005: Display-Steuerung – Zeitplan pro Screen
|
|
||||||
|
|
||||||
create table if not exists screen_schedules (
|
|
||||||
screen_id text primary key references screens(id) on delete cascade,
|
|
||||||
schedule_enabled boolean not null default false,
|
|
||||||
power_on_time text not null default '',
|
|
||||||
power_off_time text not null default ''
|
|
||||||
);
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
package manage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleUpdateSchedule speichert den Zeitplan für ein Display.
|
|
||||||
// Body: {"schedule_enabled":true,"power_on_time":"06:00","power_off_time":"22:00"}
|
|
||||||
func HandleUpdateSchedule(screens *store.ScreenStore, schedules *store.ScreenScheduleStore) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
screenSlug := r.PathValue("screenSlug")
|
|
||||||
screen, err := screens.GetBySlug(r.Context(), screenSlug)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "screen not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !requireScreenAccess(w, r, screen) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
ScheduleEnabled bool `json:"schedule_enabled"`
|
|
||||||
PowerOnTime string `json:"power_on_time"`
|
|
||||||
PowerOffTime string `json:"power_off_time"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.PowerOnTime != "" {
|
|
||||||
if _, err := time.Parse("15:04", body.PowerOnTime); err != nil {
|
|
||||||
http.Error(w, "invalid power_on_time format (use HH:MM)", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if body.PowerOffTime != "" {
|
|
||||||
if _, err := time.Parse("15:04", body.PowerOffTime); err != nil {
|
|
||||||
http.Error(w, "invalid power_off_time format (use HH:MM)", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := schedules.Upsert(r.Context(), &store.ScreenSchedule{
|
|
||||||
ScreenID: screen.ID,
|
|
||||||
ScheduleEnabled: body.ScheduleEnabled,
|
|
||||||
PowerOnTime: body.PowerOnTime,
|
|
||||||
PowerOffTime: body.PowerOffTime,
|
|
||||||
}); err != nil {
|
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -777,16 +777,6 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
.morz-toast.show { transform:translateX(0); }
|
.morz-toast.show { transform:translateX(0); }
|
||||||
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
||||||
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
|
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
|
||||||
/* Display control box */
|
|
||||||
.display-ctrl { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
|
|
||||||
.display-state-badge { font-size:.75rem; padding:.2em .65em; border-radius:99px; font-weight:700; }
|
|
||||||
.display-state-badge.on { background:#dcfce7; color:#166534; }
|
|
||||||
.display-state-badge.off { background:#fee2e2; color:#991b1b; }
|
|
||||||
.display-state-badge.unknown { background:#f3f4f6; color:#6b7280; }
|
|
||||||
/* Schedule control */
|
|
||||||
.schedule-row { display:flex; gap:.75rem; align-items:flex-end; flex-wrap:wrap; margin-top:.75rem; }
|
|
||||||
.schedule-row .field { margin:0; }
|
|
||||||
.schedule-row label { font-size:.75rem; color:#6b7280; display:block; margin-bottom:.2rem; font-weight:600; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -942,46 +932,6 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
|
|
||||||
<!-- RIGHT: Library + Upload -->
|
<!-- RIGHT: Library + Upload -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Display control -->
|
|
||||||
<div class="box mb-3">
|
|
||||||
<h3 class="title is-6 mb-3">Display</h3>
|
|
||||||
<div class="display-ctrl">
|
|
||||||
<span id="display-state-badge" class="display-state-badge {{.DisplayState}}">
|
|
||||||
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}Unbekannt{{end}}
|
|
||||||
</span>
|
|
||||||
<button class="button is-small is-success is-light" type="button"
|
|
||||||
onclick="sendDisplayCmd('on')">Einschalten</button>
|
|
||||||
<button class="button is-small is-danger is-light" type="button"
|
|
||||||
onclick="sendDisplayCmd('off')">Ausschalten</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Schedule control -->
|
|
||||||
<div class="box mb-3">
|
|
||||||
<h3 class="title is-6 mb-2">Zeitplan</h3>
|
|
||||||
<label class="pl-toggle" title="Zeitplan aktivieren">
|
|
||||||
<input type="checkbox" id="schedule-enabled"
|
|
||||||
{{if .Schedule.ScheduleEnabled}}checked{{end}}
|
|
||||||
onchange="saveSchedule()">
|
|
||||||
<span class="pl-toggle-track"></span>
|
|
||||||
<span class="pl-toggle-thumb"></span>
|
|
||||||
<span class="pl-toggle-label" style="font-size:.8rem">Zeitplan aktiv</span>
|
|
||||||
</label>
|
|
||||||
<div class="schedule-row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Einschalten</label>
|
|
||||||
<input class="input is-small" type="time" id="power-on-time"
|
|
||||||
value="{{.Schedule.PowerOnTime}}"
|
|
||||||
onchange="saveSchedule()" style="width:8rem">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Ausschalten</label>
|
|
||||||
<input class="input is-small" type="time" id="power-off-time"
|
|
||||||
value="{{.Schedule.PowerOffTime}}"
|
|
||||||
onchange="saveSchedule()" style="width:8rem">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p id="schedule-save-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
|
||||||
</div>
|
|
||||||
<!-- Upload (collapsed) -->
|
<!-- Upload (collapsed) -->
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<details id="upload-details">
|
<details id="upload-details">
|
||||||
|
|
@ -1135,60 +1085,6 @@ function showToast(msg, type) {
|
||||||
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
|
setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Display control ─────────────────────────────────────────────
|
|
||||||
function sendDisplayCmd(state) {
|
|
||||||
var slug = {{.Screen.Slug | printf "%q"}};
|
|
||||||
fetch('/api/v1/screens/' + slug + '/display', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': getCsrf(),
|
|
||||||
'X-Requested-With': 'fetch'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({state: state})
|
|
||||||
}).then(function(r) {
|
|
||||||
if (r.ok) {
|
|
||||||
var badge = document.getElementById('display-state-badge');
|
|
||||||
if (badge) {
|
|
||||||
badge.className = 'display-state-badge ' + state;
|
|
||||||
badge.textContent = state === 'on' ? 'An' : 'Aus';
|
|
||||||
}
|
|
||||||
showToast('Display ' + (state === 'on' ? 'eingeschaltet' : 'ausgeschaltet'), 'is-success');
|
|
||||||
} else {
|
|
||||||
showToast('Fehler beim Schalten', 'is-danger');
|
|
||||||
}
|
|
||||||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Schedule control ────────────────────────────────────────────────────────
|
|
||||||
function saveSchedule() {
|
|
||||||
var slug = {{.Screen.Slug | printf "%q"}};
|
|
||||||
var enabled = document.getElementById('schedule-enabled').checked;
|
|
||||||
var onTime = document.getElementById('power-on-time').value;
|
|
||||||
var offTime = document.getElementById('power-off-time').value;
|
|
||||||
fetch('/api/v1/screens/' + slug + '/schedule', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': getCsrf(),
|
|
||||||
'X-Requested-With': 'fetch'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
schedule_enabled: enabled,
|
|
||||||
power_on_time: onTime,
|
|
||||||
power_off_time: offTime
|
|
||||||
})
|
|
||||||
}).then(function(r) {
|
|
||||||
var ok = document.getElementById('schedule-save-ok');
|
|
||||||
if (r.ok && ok) {
|
|
||||||
ok.classList.add('show');
|
|
||||||
setTimeout(function() { ok.classList.remove('show'); }, 2000);
|
|
||||||
} else if (!r.ok) {
|
|
||||||
showToast('Zeitplan konnte nicht gespeichert werden', 'is-danger');
|
|
||||||
}
|
|
||||||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ?msg= toast ─────────────────────────────────────────────────
|
// ─── ?msg= toast ─────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
var msg = new URLSearchParams(window.location.search).get('msg');
|
var msg = new URLSearchParams(window.location.search).get('msg');
|
||||||
|
|
@ -1262,11 +1158,7 @@ if (sortableEl) {
|
||||||
var ids = Array.from(sortableEl.querySelectorAll('.pl-item[data-id]')).map(function(el) { return el.dataset.id; });
|
var ids = Array.from(sortableEl.querySelectorAll('.pl-item[data-id]')).map(function(el) { return el.dataset.id; });
|
||||||
fetch('/manage/' + SCREEN_SLUG + '/reorder', {
|
fetch('/manage/' + SCREEN_SLUG + '/reorder', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {'Content-Type':'application/json'},
|
||||||
'Content-Type':'application/json',
|
|
||||||
'X-CSRF-Token': getCsrf(),
|
|
||||||
'X-Requested-With': 'fetch'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(ids)
|
body: JSON.stringify(ids)
|
||||||
}).then(function(r) {
|
}).then(function(r) {
|
||||||
if (!r.ok) { showToast('⚠ Reihenfolge nicht gespeichert.','is-danger'); window.location.reload(); }
|
if (!r.ok) { showToast('⚠ Reihenfolge nicht gespeichert.','is-danger'); window.location.reload(); }
|
||||||
|
|
@ -1364,12 +1256,6 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
|
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
|
||||||
.morz-toast.show { transform:translateX(0); }
|
.morz-toast.show { transform:translateX(0); }
|
||||||
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
||||||
.display-btn-row { display:flex; gap:.4rem; margin-top:.5rem; }
|
|
||||||
.bulk-bar { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:.85rem 1rem; margin-bottom:1.25rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
|
|
||||||
.display-state-badge { font-size:.7rem; padding:.15em .55em; border-radius:99px; font-weight:700; }
|
|
||||||
.display-state-badge.on { background:#dcfce7; color:#166534; }
|
|
||||||
.display-state-badge.off { background:#fee2e2; color:#991b1b; }
|
|
||||||
.display-state-badge.unknown { background:#f3f4f6; color:#6b7280; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -1392,14 +1278,6 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
||||||
{{if gt (len .Cards) 1}}
|
|
||||||
<div class="bulk-bar">
|
|
||||||
<span style="font-size:.875rem;font-weight:600;color:#374151">Alle Displays:</span>
|
|
||||||
<button class="button is-small is-success is-light" type="button" onclick="bulkDisplay('on')">Alle einschalten</button>
|
|
||||||
<button class="button is-small is-danger is-light" type="button" onclick="bulkDisplay('off')">Alle ausschalten</button>
|
|
||||||
<span id="bulk-result" style="font-size:.8rem;color:#6b7280"></span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
{{range .Cards}}
|
{{range .Cards}}
|
||||||
<div class="column is-one-third-desktop is-half-tablet">
|
<div class="column is-one-third-desktop is-half-tablet">
|
||||||
|
|
@ -1415,15 +1293,6 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
<div class="screen-card-sub">{{orientationLabel .Screen.Orientation}} · {{.Screen.Slug}}</div>
|
<div class="screen-card-sub">{{orientationLabel .Screen.Orientation}} · {{.Screen.Slug}}</div>
|
||||||
<a class="button is-primary is-fullwidth" href="/manage/{{.Screen.Slug}}">Verwalten →</a>
|
<a class="button is-primary is-fullwidth" href="/manage/{{.Screen.Slug}}">Verwalten →</a>
|
||||||
<div class="display-btn-row">
|
|
||||||
<span id="ds-{{.Screen.Slug}}" class="display-state-badge {{.DisplayState}}">
|
|
||||||
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}?{{end}}
|
|
||||||
</span>
|
|
||||||
<button class="button is-small is-success is-light" type="button"
|
|
||||||
onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
|
|
||||||
<button class="button is-small is-danger is-light" type="button"
|
|
||||||
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1464,55 +1333,6 @@ function injectCSRF() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
|
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',injectCSRF); else injectCSRF();
|
||||||
|
|
||||||
// ─── Display control ─────────────────────────────────────────────
|
|
||||||
function sendDisplayCmd(slug, state) {
|
|
||||||
fetch('/api/v1/screens/' + slug + '/display', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': getCsrf(),
|
|
||||||
'X-Requested-With': 'fetch'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({state: state})
|
|
||||||
}).then(function(r) {
|
|
||||||
var badge = document.getElementById('ds-' + slug);
|
|
||||||
if (r.ok && badge) {
|
|
||||||
badge.className = 'display-state-badge ' + state;
|
|
||||||
badge.textContent = state === 'on' ? 'An' : 'Aus';
|
|
||||||
}
|
|
||||||
}).catch(function(){});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bulkDisplay(state) {
|
|
||||||
var slugs = [];
|
|
||||||
document.querySelectorAll('[id^="ds-"]').forEach(function(el) {
|
|
||||||
slugs.push(el.id.replace('ds-', ''));
|
|
||||||
});
|
|
||||||
var result = document.getElementById('bulk-result');
|
|
||||||
var done = 0;
|
|
||||||
slugs.forEach(function(slug) {
|
|
||||||
fetch('/api/v1/screens/' + slug + '/display', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': getCsrf(),
|
|
||||||
'X-Requested-With': 'fetch'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({state: state})
|
|
||||||
}).then(function(r) {
|
|
||||||
if (r.ok) {
|
|
||||||
var badge = document.getElementById('ds-' + slug);
|
|
||||||
if (badge) {
|
|
||||||
badge.className = 'display-state-badge ' + state;
|
|
||||||
badge.textContent = state === 'on' ? 'An' : 'Aus';
|
|
||||||
}
|
|
||||||
done++;
|
|
||||||
if (result) result.textContent = done + '/' + slugs.length + ' geschaltet';
|
|
||||||
}
|
|
||||||
}).catch(function(){});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
|
||||||
|
|
@ -281,8 +281,7 @@ func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
type screenCard struct {
|
type screenCard struct {
|
||||||
Screen *store.Screen
|
Screen *store.Screen
|
||||||
DisplayState string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
|
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
|
||||||
|
|
@ -309,8 +308,7 @@ func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Not
|
||||||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||||
cards := make([]screenCard, 0, len(accessible))
|
cards := make([]screenCard, 0, len(accessible))
|
||||||
for _, sc := range accessible {
|
for _, sc := range accessible {
|
||||||
ds, _ := screens.GetDisplayState(r.Context(), sc.ID)
|
cards = append(cards, screenCard{Screen: sc})
|
||||||
cards = append(cards, screenCard{Screen: sc, DisplayState: ds})
|
|
||||||
}
|
}
|
||||||
renderTemplate(w, t, map[string]any{
|
renderTemplate(w, t, map[string]any{
|
||||||
"Cards": cards,
|
"Cards": cards,
|
||||||
|
|
@ -323,7 +321,6 @@ func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Not
|
||||||
func HandleManageUI(
|
func HandleManageUI(
|
||||||
tenants *store.TenantStore,
|
tenants *store.TenantStore,
|
||||||
screens *store.ScreenStore,
|
screens *store.ScreenStore,
|
||||||
schedules *store.ScreenScheduleStore,
|
|
||||||
media *store.MediaStore,
|
media *store.MediaStore,
|
||||||
playlists *store.PlaylistStore,
|
playlists *store.PlaylistStore,
|
||||||
cfg config.Config,
|
cfg config.Config,
|
||||||
|
|
@ -383,13 +380,6 @@ func HandleManageUI(
|
||||||
// M6: CSRF-Token an Template-Daten weitergeben.
|
// M6: CSRF-Token an Template-Daten weitergeben.
|
||||||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||||
|
|
||||||
displayState, _ := screens.GetDisplayState(r.Context(), screen.ID)
|
|
||||||
|
|
||||||
schedule, _ := schedules.Get(r.Context(), screen.ID)
|
|
||||||
if schedule == nil {
|
|
||||||
schedule = &store.ScreenSchedule{ScreenID: screen.ID}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine back-navigation based on ?from= query parameter.
|
// Determine back-navigation based on ?from= query parameter.
|
||||||
backLink := "/admin"
|
backLink := "/admin"
|
||||||
backLabel := "← Admin"
|
backLabel := "← Admin"
|
||||||
|
|
@ -441,8 +431,6 @@ func HandleManageUI(
|
||||||
"AccessibleScreens": accessibleScreens,
|
"AccessibleScreens": accessibleScreens,
|
||||||
"ServerTimezone": serverTimezone,
|
"ServerTimezone": serverTimezone,
|
||||||
"CSRFToken": csrfToken,
|
"CSRFToken": csrfToken,
|
||||||
"DisplayState": displayState,
|
|
||||||
"Schedule": schedule,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,17 @@ import (
|
||||||
|
|
||||||
// RouterDeps holds all dependencies needed to build the HTTP router.
|
// RouterDeps holds all dependencies needed to build the HTTP router.
|
||||||
type RouterDeps struct {
|
type RouterDeps struct {
|
||||||
StatusStore playerStatusStore
|
StatusStore playerStatusStore
|
||||||
TenantStore *store.TenantStore
|
TenantStore *store.TenantStore
|
||||||
ScreenStore *store.ScreenStore
|
ScreenStore *store.ScreenStore
|
||||||
MediaStore *store.MediaStore
|
MediaStore *store.MediaStore
|
||||||
PlaylistStore *store.PlaylistStore
|
PlaylistStore *store.PlaylistStore
|
||||||
AuthStore *store.AuthStore
|
AuthStore *store.AuthStore
|
||||||
Notifier *mqttnotifier.Notifier
|
Notifier *mqttnotifier.Notifier
|
||||||
ScreenshotStore *ScreenshotStore
|
ScreenshotStore *ScreenshotStore
|
||||||
ScheduleStore *store.ScreenScheduleStore
|
|
||||||
Config config.Config
|
Config config.Config
|
||||||
UploadDir string
|
UploadDir string
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(deps RouterDeps) http.Handler {
|
func NewRouter(deps RouterDeps) http.Handler {
|
||||||
|
|
@ -166,7 +165,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
mux.Handle("GET /manage",
|
mux.Handle("GET /manage",
|
||||||
authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config))))
|
authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config))))
|
||||||
mux.Handle("GET /manage/{screenSlug}",
|
mux.Handle("GET /manage/{screenSlug}",
|
||||||
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.ScheduleStore, d.MediaStore, d.PlaylistStore, d.Config, notifier))))
|
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore, d.Config, notifier))))
|
||||||
mux.Handle("POST /manage/{screenSlug}/upload",
|
mux.Handle("POST /manage/{screenSlug}/upload",
|
||||||
authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
|
authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
|
||||||
mux.Handle("POST /manage/{screenSlug}/items",
|
mux.Handle("POST /manage/{screenSlug}/items",
|
||||||
|
|
@ -188,10 +187,6 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
mux.Handle("POST /api/v1/screens/{screenSlug}/display",
|
mux.Handle("POST /api/v1/screens/{screenSlug}/display",
|
||||||
authScreen(http.HandlerFunc(manage.HandleDisplayCommand(notifier))))
|
authScreen(http.HandlerFunc(manage.HandleDisplayCommand(notifier))))
|
||||||
|
|
||||||
// ── Schedule control ──────────────────────────────────────────────────
|
|
||||||
mux.Handle("POST /api/v1/screens/{screenSlug}/schedule",
|
|
||||||
authScreen(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore))))
|
|
||||||
|
|
||||||
// ── JSON API — screens ────────────────────────────────────────────────
|
// ── JSON API — screens ────────────────────────────────────────────────
|
||||||
// Self-registration: no auth (player calls this on startup).
|
// Self-registration: no auth (player calls this on startup).
|
||||||
mux.HandleFunc("POST /api/v1/screens/register",
|
mux.HandleFunc("POST /api/v1/screens/register",
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
// Package scheduler enthält den Display-Zeitplan-Scheduler.
|
|
||||||
// Er prüft jede Minute ob ein Screen ein- oder ausgeschaltet werden soll.
|
|
||||||
package scheduler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DisplayCommander sendet einen Display-Befehl per MQTT.
|
|
||||||
type DisplayCommander interface {
|
|
||||||
SendDisplayCommand(screenSlug, action string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScreenSlugGetter lädt den Slug für eine Screen-ID.
|
|
||||||
type ScreenSlugGetter interface {
|
|
||||||
GetByID(ctx context.Context, id string) (*store.Screen, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird.
|
|
||||||
func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
check(ctx, schedules, screens, notifier)
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check prüft alle aktiven Zeitpläne und sendet ggf. Befehle.
|
|
||||||
func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
|
||||||
// Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin).
|
|
||||||
now := time.Now().Format("15:04")
|
|
||||||
|
|
||||||
enabled, err := schedules.ListEnabled(ctx)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("scheduler: list enabled schedules failed", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sc := range enabled {
|
|
||||||
screen, err := screens.GetByID(ctx, sc.ScreenID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("scheduler: screen not found", "screen_id", sc.ScreenID, "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var action string
|
|
||||||
if sc.PowerOnTime != "" && sc.PowerOnTime == now {
|
|
||||||
action = "display_on"
|
|
||||||
} else if sc.PowerOffTime != "" && sc.PowerOffTime == now {
|
|
||||||
action = "display_off"
|
|
||||||
}
|
|
||||||
|
|
||||||
if action == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
|
|
||||||
slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err)
|
|
||||||
} else {
|
|
||||||
slog.Info("scheduler: display command sent", "screen_id", sc.ScreenID, "slug", screen.Slug, "action", action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,13 +40,6 @@ type ScreenStatus struct {
|
||||||
ReportedAt time.Time `json:"reported_at"`
|
ReportedAt time.Time `json:"reported_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScreenSchedule struct {
|
|
||||||
ScreenID string `json:"screen_id"`
|
|
||||||
ScheduleEnabled bool `json:"schedule_enabled"`
|
|
||||||
PowerOnTime string `json:"power_on_time"`
|
|
||||||
PowerOffTime string `json:"power_off_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaAsset struct {
|
type MediaAsset struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
|
|
@ -102,15 +94,11 @@ type TenantStore struct{ pool *pgxpool.Pool }
|
||||||
type ScreenStore struct{ pool *pgxpool.Pool }
|
type ScreenStore struct{ pool *pgxpool.Pool }
|
||||||
type MediaStore struct{ pool *pgxpool.Pool }
|
type MediaStore struct{ pool *pgxpool.Pool }
|
||||||
type PlaylistStore struct{ pool *pgxpool.Pool }
|
type PlaylistStore struct{ pool *pgxpool.Pool }
|
||||||
type ScreenScheduleStore struct{ pool *pgxpool.Pool }
|
|
||||||
|
|
||||||
func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} }
|
func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} }
|
||||||
func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} }
|
func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} }
|
||||||
func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} }
|
func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} }
|
||||||
func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} }
|
func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} }
|
||||||
func NewScreenScheduleStore(pool *pgxpool.Pool) *ScreenScheduleStore {
|
|
||||||
return &ScreenScheduleStore{pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// TenantStore
|
// TenantStore
|
||||||
|
|
@ -197,12 +185,6 @@ func (s *ScreenStore) GetBySlug(ctx context.Context, slug string) (*Screen, erro
|
||||||
return scanScreen(row)
|
return scanScreen(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScreenStore) GetByID(ctx context.Context, id string) (*Screen, error) {
|
|
||||||
row := s.pool.QueryRow(ctx,
|
|
||||||
`select id, tenant_id, slug, name, orientation, created_at from screens where id=$1`, id)
|
|
||||||
return scanScreen(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`insert into screens(tenant_id, slug, name, orientation)
|
`insert into screens(tenant_id, slug, name, orientation)
|
||||||
|
|
@ -346,22 +328,6 @@ func (s *ScreenStore) UpsertDisplayState(ctx context.Context, screenID, displayS
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDisplayState gibt den zuletzt gemeldeten Display-Zustand zurück.
|
|
||||||
// Gibt "unknown" zurück wenn kein Eintrag vorhanden ist.
|
|
||||||
func (s *ScreenStore) GetDisplayState(ctx context.Context, screenID string) (string, error) {
|
|
||||||
var state string
|
|
||||||
err := s.pool.QueryRow(ctx,
|
|
||||||
`select coalesce(display_state,'unknown')
|
|
||||||
from screen_status where screen_id = $1`, screenID).Scan(&state)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "unknown", nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "unknown", err
|
|
||||||
}
|
|
||||||
return state, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// MediaStore
|
// MediaStore
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -644,58 +610,3 @@ func scanPlaylistItem(row interface {
|
||||||
}
|
}
|
||||||
return &it, nil
|
return &it, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// ScreenScheduleStore
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Get lädt den Zeitplan eines Screens. Gibt einen leeren ScreenSchedule zurück wenn keiner vorhanden.
|
|
||||||
func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) {
|
|
||||||
var sc ScreenSchedule
|
|
||||||
err := s.pool.QueryRow(ctx,
|
|
||||||
`select screen_id, schedule_enabled, power_on_time, power_off_time
|
|
||||||
from screen_schedules where screen_id = $1`, screenID).
|
|
||||||
Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return &ScreenSchedule{ScreenID: screenID}, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &sc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert speichert oder aktualisiert den Zeitplan eines Screens.
|
|
||||||
func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error {
|
|
||||||
_, err := s.pool.Exec(ctx,
|
|
||||||
`insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time)
|
|
||||||
values ($1, $2, $3, $4)
|
|
||||||
on conflict (screen_id) do update
|
|
||||||
set schedule_enabled = excluded.schedule_enabled,
|
|
||||||
power_on_time = excluded.power_on_time,
|
|
||||||
power_off_time = excluded.power_off_time`,
|
|
||||||
sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListEnabled gibt alle Screens mit aktivem Zeitplan zurück.
|
|
||||||
func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) {
|
|
||||||
rows, err := s.pool.Query(ctx,
|
|
||||||
`select screen_id, schedule_enabled, power_on_time, power_off_time
|
|
||||||
from screen_schedules
|
|
||||||
where schedule_enabled = true
|
|
||||||
and (power_on_time != '' or power_off_time != '')`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*ScreenSchedule
|
|
||||||
for rows.Next() {
|
|
||||||
var sc ScreenSchedule
|
|
||||||
if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, &sc)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue