Kopplung Agent↔Backend: Selbstregistrierung + Playlist-Rotation
Backend:
- ScreenStore.Upsert(): idempotentes INSERT ON CONFLICT für Self-Registration
- POST /api/v1/screens/register: Agent registriert sich beim Start (upsert)
- manage/register.go: neuer Handler, immer unter Tenant "morz"
Agent:
- config: screen_name + screen_orientation (mit Fallback auf screen_id / landscape)
- app.go: registerScreen() — POST /api/v1/screens/register beim Start (Retry 30s)
- app.go: pollPlaylist() — GET /api/v1/screens/{slug}/playlist alle 60s
- app.go: nowFn liefert Playlist statt statischer URL; PlayerContentURL als Fallback
- playerserver: PlaylistItem-Struct in NowPlaying; JS rotiert Items per duration_seconds
- JS: Playlist-Fingerprint verhindert Reset laufender Rotation bei unverändertem Stand
Ansible:
- config.json.j2: screen_name + screen_orientation ergänzt
- host_vars/info10: screen_name + screen_orientation
- host_vars/info01-dev: screen_name + screen_orientation
Kopplung per Konvention: screen_id (config.json) = slug (DB)
Beim ersten Neustart der Agents erscheinen die Bildschirme automatisch im Admin-UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d395804612
commit
e03948f25d
9 changed files with 325 additions and 44 deletions
|
|
@ -2,3 +2,5 @@
|
|||
ansible_host: 192.168.64.11
|
||||
ansible_user: admin
|
||||
screen_id: info01-dev
|
||||
screen_name: "Info01 Entwicklung"
|
||||
screen_orientation: landscape
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@
|
|||
ansible_host: 10.0.0.200
|
||||
ansible_user: morz
|
||||
screen_id: info10
|
||||
screen_name: "Info10 Schule"
|
||||
screen_orientation: landscape
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"screen_id": "{{ screen_id }}",
|
||||
"screen_name": "{{ screen_name | default(screen_id) }}",
|
||||
"screen_orientation": "{{ screen_orientation | default('landscape') }}",
|
||||
"server_base_url": "{{ morz_server_base_url }}",
|
||||
"mqtt_broker": "{{ morz_mqtt_broker }}",
|
||||
"mqtt_username": "{{ morz_mqtt_username }}",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -55,6 +58,10 @@ type App struct {
|
|||
consecutiveReportFailures int
|
||||
startedAt time.Time
|
||||
lastHeartbeatAt time.Time
|
||||
|
||||
// Playlist fetched from the backend (protected by playlistMu).
|
||||
playlistMu sync.RWMutex
|
||||
playlist []playerserver.PlaylistItem
|
||||
}
|
||||
|
||||
type statusSender interface {
|
||||
|
|
@ -140,21 +147,39 @@ func (a *App) Run(ctx context.Context) error {
|
|||
a.mu.Unlock()
|
||||
|
||||
a.logger.Printf(
|
||||
"event=agent_configured screen_id=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d player_addr=%s",
|
||||
"event=agent_configured screen_id=%s screen_name=%q orientation=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d player_addr=%s",
|
||||
a.Config.ScreenID,
|
||||
a.Config.ScreenName,
|
||||
a.Config.ScreenOrientation,
|
||||
a.Config.ServerBaseURL,
|
||||
a.Config.MQTTBroker,
|
||||
a.Config.HeartbeatEvery,
|
||||
a.Config.PlayerListenAddr,
|
||||
)
|
||||
|
||||
// Start the player HTTP server (serves Chromium UI).
|
||||
ps := playerserver.New(a.Config.PlayerListenAddr, func() playerserver.NowPlaying {
|
||||
snap := a.Snapshot()
|
||||
return playerserver.NowPlaying{
|
||||
URL: a.Config.PlayerContentURL,
|
||||
a.playlistMu.RLock()
|
||||
items := a.playlist
|
||||
a.playlistMu.RUnlock()
|
||||
|
||||
np := playerserver.NowPlaying{
|
||||
Status: string(snap.Status),
|
||||
Connectivity: string(snap.ServerConnectivity),
|
||||
}
|
||||
if len(items) > 0 {
|
||||
np.Playlist = items
|
||||
} else if a.Config.PlayerContentURL != "" {
|
||||
// Fallback: single static URL when no playlist is available yet.
|
||||
np.Playlist = []playerserver.PlaylistItem{{
|
||||
Src: a.Config.PlayerContentURL,
|
||||
Type: "web",
|
||||
Title: "",
|
||||
DurationSeconds: 30,
|
||||
}}
|
||||
}
|
||||
return np
|
||||
})
|
||||
go func() {
|
||||
if err := ps.Run(ctx); err != nil {
|
||||
|
|
@ -162,6 +187,12 @@ func (a *App) Run(ctx context.Context) error {
|
|||
}
|
||||
}()
|
||||
|
||||
// Self-register this screen in the backend (best-effort, non-blocking).
|
||||
go a.registerScreen(ctx)
|
||||
|
||||
// Start polling the backend for playlist updates.
|
||||
go a.pollPlaylist(ctx)
|
||||
|
||||
a.emitHeartbeat()
|
||||
a.mu.Lock()
|
||||
a.status = StatusRunning
|
||||
|
|
@ -193,6 +224,102 @@ func (a *App) Run(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// registerScreen upserts this screen in the backend so it appears in the admin UI.
|
||||
// Called once at startup in a goroutine — retries every 30s until successful.
|
||||
func (a *App) registerScreen(ctx context.Context) {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"slug": a.Config.ScreenID,
|
||||
"name": a.Config.ScreenName,
|
||||
"orientation": a.Config.ScreenOrientation,
|
||||
})
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx,
|
||||
http.MethodPost,
|
||||
a.Config.ServerBaseURL+"/api/v1/screens/register",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
a.logger.Printf("event=screen_registered screen_id=%s attempt=%d", a.Config.ScreenID, attempt)
|
||||
return
|
||||
}
|
||||
a.logger.Printf("event=screen_register_failed screen_id=%s status=%d attempt=%d",
|
||||
a.Config.ScreenID, resp.StatusCode, attempt)
|
||||
} else {
|
||||
a.logger.Printf("event=screen_register_failed screen_id=%s error=%v attempt=%d",
|
||||
a.Config.ScreenID, err, attempt)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollPlaylist fetches the active playlist from the backend periodically.
|
||||
func (a *App) pollPlaylist(ctx context.Context) {
|
||||
// Fetch immediately on startup, then every 60s.
|
||||
a.fetchPlaylist(ctx)
|
||||
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.fetchPlaylist(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remotePlaylistResponse is the JSON shape of GET /api/v1/screens/{slug}/playlist.
|
||||
type remotePlaylistResponse struct {
|
||||
Items []playerserver.PlaylistItem `json:"items"`
|
||||
}
|
||||
|
||||
func (a *App) fetchPlaylist(ctx context.Context) {
|
||||
url := a.Config.ServerBaseURL + "/api/v1/screens/" + a.Config.ScreenID + "/playlist"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
a.logger.Printf("event=playlist_fetch_failed screen_id=%s error=%v", a.Config.ScreenID, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
a.logger.Printf("event=playlist_fetch_failed screen_id=%s status=%d", a.Config.ScreenID, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var pr remotePlaylistResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
|
||||
a.logger.Printf("event=playlist_decode_failed screen_id=%s error=%v", a.Config.ScreenID, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.playlistMu.Lock()
|
||||
a.playlist = pr.Items
|
||||
a.playlistMu.Unlock()
|
||||
|
||||
a.logger.Printf("event=playlist_fetched screen_id=%s items=%d", a.Config.ScreenID, len(pr.Items))
|
||||
}
|
||||
|
||||
func (a *App) emitHeartbeat() {
|
||||
now := a.now()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
// ScreenID is the unique slug for this screen — used for self-registration and playlist fetching.
|
||||
ScreenID string `json:"screen_id"`
|
||||
// ScreenName is the human-readable display name shown in the admin UI. Falls back to ScreenID.
|
||||
ScreenName string `json:"screen_name"`
|
||||
// ScreenOrientation is "landscape" (default) or "portrait".
|
||||
ScreenOrientation string `json:"screen_orientation"`
|
||||
|
||||
ServerBaseURL string `json:"server_base_url"`
|
||||
MQTTBroker string `json:"mqtt_broker"`
|
||||
MQTTUsername string `json:"mqtt_username"`
|
||||
|
|
@ -15,6 +21,7 @@ type Config struct {
|
|||
HeartbeatEvery int `json:"heartbeat_every_seconds"`
|
||||
StatusReportEvery int `json:"status_report_every_seconds"`
|
||||
PlayerListenAddr string `json:"player_listen_addr"`
|
||||
// PlayerContentURL is a fallback URL shown when no playlist is available from the server.
|
||||
PlayerContentURL string `json:"player_content_url"`
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +43,10 @@ func Load() (Config, error) {
|
|||
return Config{}, fmt.Errorf("screen id is required")
|
||||
}
|
||||
|
||||
if cfg.ScreenName == "" {
|
||||
cfg.ScreenName = cfg.ScreenID
|
||||
}
|
||||
|
||||
if cfg.HeartbeatEvery <= 0 {
|
||||
cfg.HeartbeatEvery = defaultConfig().HeartbeatEvery
|
||||
}
|
||||
|
|
@ -50,6 +61,7 @@ func Load() (Config, error) {
|
|||
func defaultConfig() Config {
|
||||
return Config{
|
||||
ScreenID: "unset-screen",
|
||||
ScreenOrientation: "landscape",
|
||||
ServerBaseURL: "http://127.0.0.1:8080",
|
||||
MQTTBroker: "",
|
||||
HeartbeatEvery: 30,
|
||||
|
|
@ -75,6 +87,8 @@ func overrideFromEnv(cfg *Config) {
|
|||
cfg.MQTTUsername = getenv("MORZ_INFOBOARD_MQTT_USERNAME", cfg.MQTTUsername)
|
||||
cfg.MQTTPassword = getenv("MORZ_INFOBOARD_MQTT_PASSWORD", cfg.MQTTPassword)
|
||||
cfg.PlayerListenAddr = getenv("MORZ_INFOBOARD_PLAYER_ADDR", cfg.PlayerListenAddr)
|
||||
cfg.ScreenName = getenv("MORZ_INFOBOARD_SCREEN_NAME", cfg.ScreenName)
|
||||
cfg.ScreenOrientation = getenv("MORZ_INFOBOARD_SCREEN_ORIENTATION", cfg.ScreenOrientation)
|
||||
cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL)
|
||||
if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" {
|
||||
var parsed int
|
||||
|
|
|
|||
|
|
@ -17,15 +17,25 @@ import (
|
|||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// NowPlaying describes what the player should currently display.
|
||||
// PlaylistItem is a single displayable content item fetched from the backend.
|
||||
type PlaylistItem struct {
|
||||
Src string `json:"src"`
|
||||
Type string `json:"type"` // web | image | video | pdf
|
||||
Title string `json:"title,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// NowPlaying describes the current playback state returned to the browser.
|
||||
type NowPlaying struct {
|
||||
// Playlist holds the ordered items to display (primary).
|
||||
Playlist []PlaylistItem `json:"playlist,omitempty"`
|
||||
// URL is a legacy single-URL fallback (kept for backwards compatibility).
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Connectivity string `json:"connectivity"`
|
||||
}
|
||||
|
||||
// InfoItem is a single entry shown in the lower-third sysinfo overlay.
|
||||
// Add new items in collectSysInfo to extend the overlay.
|
||||
type InfoItem struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
|
|
@ -142,7 +152,7 @@ const playerHTML = `<!DOCTYPE html>
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||
|
||||
/* Splash-Hintergrund – Bild wird per JS orientierungsabhängig gesetzt */
|
||||
/* Splash-Hintergrund */
|
||||
#splash {
|
||||
position: fixed; inset: 0;
|
||||
background-size: cover;
|
||||
|
|
@ -166,41 +176,29 @@ const playerHTML = `<!DOCTYPE html>
|
|||
gap: 3rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.info-item { display: flex; flex-direction: column; }
|
||||
.info-item .label {
|
||||
font-family: sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-bottom: 0.25em;
|
||||
font-family: sans-serif; font-size: 0.7rem;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.5); margin-bottom: 0.25em;
|
||||
}
|
||||
.info-item .value {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
color: #fff;
|
||||
font-family: monospace; font-size: 1.1rem;
|
||||
font-weight: 500; letter-spacing: 0.03em; color: #fff;
|
||||
}
|
||||
|
||||
/* Inhalts-iframe – liegt über dem Splash, wenn eine URL gesetzt ist */
|
||||
/* Inhalts-iframe */
|
||||
#frame {
|
||||
position: fixed; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
border: none;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
border: none; display: none; z-index: 10;
|
||||
}
|
||||
|
||||
/* Verbindungsstatus-Punkt */
|
||||
#dot {
|
||||
position: fixed; bottom: 10px; right: 10px;
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #444; opacity: 0.6;
|
||||
z-index: 9999;
|
||||
background: #444; opacity: 0.6; z-index: 9999;
|
||||
transition: background 0.5s;
|
||||
}
|
||||
#dot.online { background: #4caf50; }
|
||||
|
|
@ -220,7 +218,7 @@ const playerHTML = `<!DOCTYPE html>
|
|||
var frame = document.getElementById('frame');
|
||||
var dot = document.getElementById('dot');
|
||||
|
||||
// Orientierungsgerechtes Splash-Bild wählen
|
||||
// ── Splash-Orientierung ───────────────────────────────────────────
|
||||
function updateSplash() {
|
||||
var portrait = window.innerHeight > window.innerWidth;
|
||||
splash.style.backgroundImage = portrait
|
||||
|
|
@ -230,7 +228,7 @@ const playerHTML = `<!DOCTYPE html>
|
|||
updateSplash();
|
||||
window.addEventListener('resize', updateSplash);
|
||||
|
||||
// Sysinfo-Overlay rendern
|
||||
// ── Sysinfo-Overlay ───────────────────────────────────────────────
|
||||
function renderSysInfo(items) {
|
||||
overlay.innerHTML = '';
|
||||
(items || []).forEach(function(item) {
|
||||
|
|
@ -245,22 +243,82 @@ const playerHTML = `<!DOCTYPE html>
|
|||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// Now-Playing anwenden: iframe zeigen wenn URL gesetzt, sonst Splash
|
||||
// ── Playlist-Rotation ─────────────────────────────────────────────
|
||||
var items = []; // current playlist
|
||||
var currentIdx = 0;
|
||||
var rotateTimer = null;
|
||||
|
||||
// Returns a fingerprint string for change detection.
|
||||
function playlistKey(pl) {
|
||||
return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|');
|
||||
}
|
||||
|
||||
function clearRotation() {
|
||||
if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
|
||||
}
|
||||
|
||||
function showItem(item) {
|
||||
if (!item) { showSplash(); return; }
|
||||
if (frame.src !== item.src) { frame.src = item.src; }
|
||||
frame.style.display = '';
|
||||
// Hide splash overlay while content is visible.
|
||||
overlay.style.display = 'none';
|
||||
|
||||
clearRotation();
|
||||
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
|
||||
rotateTimer = setTimeout(function() {
|
||||
currentIdx = (currentIdx + 1) % items.length;
|
||||
showItem(items[currentIdx]);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function showSplash() {
|
||||
clearRotation();
|
||||
frame.style.display = 'none';
|
||||
overlay.style.display = '';
|
||||
}
|
||||
|
||||
var lastPlaylistKey = '';
|
||||
|
||||
function applyNowPlaying(data) {
|
||||
dot.className = data.connectivity || '';
|
||||
if (data.url) {
|
||||
if (frame.src !== data.url) { frame.src = data.url; }
|
||||
frame.style.display = '';
|
||||
} else {
|
||||
frame.style.display = 'none';
|
||||
|
||||
// Legacy single-URL fallback.
|
||||
if (data.url && (!data.playlist || data.playlist.length === 0)) {
|
||||
var key = data.url + ':legacy';
|
||||
if (lastPlaylistKey !== key) {
|
||||
lastPlaylistKey = key;
|
||||
items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
|
||||
currentIdx = 0;
|
||||
showItem(items[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var playlist = data.playlist || [];
|
||||
if (playlist.length === 0) {
|
||||
showSplash();
|
||||
lastPlaylistKey = '';
|
||||
items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var key = playlistKey(playlist);
|
||||
if (key === lastPlaylistKey) {
|
||||
return; // unchanged — let current rotation continue
|
||||
}
|
||||
|
||||
// Playlist changed: start from beginning.
|
||||
lastPlaylistKey = key;
|
||||
items = playlist;
|
||||
currentIdx = 0;
|
||||
showItem(items[0]);
|
||||
}
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────
|
||||
function pollSysInfo() {
|
||||
fetch('/api/sysinfo')
|
||||
.then(function(r) { return r.json(); })
|
||||
|
|
@ -277,8 +335,8 @@ const playerHTML = `<!DOCTYPE html>
|
|||
|
||||
pollSysInfo();
|
||||
pollNowPlaying();
|
||||
setInterval(pollSysInfo, 30000);
|
||||
setInterval(pollNowPlaying, 15000);
|
||||
setInterval(pollSysInfo, 30000); // sysinfo alle 30s
|
||||
setInterval(pollNowPlaying, 30000); // playlist alle 30s
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
|
|||
59
server/backend/internal/httpapi/manage/register.go
Normal file
59
server/backend/internal/httpapi/manage/register.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package manage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
// HandleRegisterScreen is called by the player agent on startup.
|
||||
// It upserts the screen in the default tenant (morz) so that all
|
||||
// deployed screens appear automatically in the admin UI.
|
||||
//
|
||||
// POST /api/v1/screens/register
|
||||
// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"}
|
||||
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Orientation string `json:"orientation"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body.Slug = strings.TrimSpace(body.Slug)
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Slug == "" {
|
||||
http.Error(w, "slug required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
body.Name = body.Slug
|
||||
}
|
||||
if body.Orientation == "" {
|
||||
body.Orientation = "landscape"
|
||||
}
|
||||
|
||||
// v1: single tenant — always register under "morz".
|
||||
tenant, err := tenants.Get(r.Context(), "morz")
|
||||
if err != nil {
|
||||
http.Error(w, "default tenant not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
screen, err := screens.Upsert(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
|
||||
if err != nil {
|
||||
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK) // 200 whether created or updated
|
||||
json.NewEncoder(w).Encode(screen) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,9 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
||||
|
||||
// ── JSON API — screens ────────────────────────────────────────────────
|
||||
// Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes)
|
||||
mux.HandleFunc("POST /api/v1/screens/register",
|
||||
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore))
|
||||
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
|
||||
manage.HandleListScreens(d.TenantStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
|
||||
|
|
|
|||
|
|
@ -176,6 +176,20 @@ func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientat
|
|||
return scanScreen(row)
|
||||
}
|
||||
|
||||
// Upsert creates or updates a screen by slug (idempotent).
|
||||
// Used by agents on startup to self-register.
|
||||
func (s *ScreenStore) Upsert(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`insert into screens(tenant_id, slug, name, orientation)
|
||||
values($1,$2,$3,$4)
|
||||
on conflict (slug) do update
|
||||
set name = excluded.name,
|
||||
orientation = excluded.orientation
|
||||
returning id, tenant_id, slug, name, orientation, created_at`,
|
||||
tenantID, slug, name, orientation)
|
||||
return scanScreen(row)
|
||||
}
|
||||
|
||||
func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`update screens set name=$2, orientation=$3 where id=$1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue