Write

-- Recorder schedules
--
-- Lets operators schedule a recorder to start at a future time and stop
-- after a duration. The scheduler tick loop in mam-api (src/scheduler.js)
-- watches this table every 15s and triggers the existing /recorders/:id
-- start + stop endpoints when each schedule's window opens or closes.
--
-- recurrence: 'none' (one-shot) or 'daily' for the MVP. When a 'daily'
-- schedule completes, the tick loop clones it forward by 24h.

CREATE TABLE IF NOT EXISTS recorder_schedules (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  name            TEXT        NOT NULL,
  recorder_id     UUID        NOT NULL REFERENCES recorders(id) ON DELETE CASCADE,
  start_at        TIMESTAMPTZ NOT NULL,
  end_at          TIMESTAMPTZ NOT NULL,
  recurrence      TEXT        NOT NULL DEFAULT 'none',
  status          TEXT        NOT NULL DEFAULT 'pending',
  last_asset_id   UUID,
  error_message   TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (end_at > start_at),
  CHECK (recurrence IN ('none','daily','weekly')),
  CHECK (status IN ('pending','running','completed','failed','cancelled'))
);

CREATE INDEX IF NOT EXISTS idx_recorder_schedules_status_start
  ON recorder_schedules (status, start_at);

CREATE INDEX IF NOT EXISTS idx_recorder_schedules_recorder
  ON recorder_schedules (recorder_id);


File created successfully at: /tmp/wd/wild-dragon/services/mam-api/src/db/migrations/009-recorder-schedules.sql (file state is current in your context — no need to Read it back)

-- Recorder schedules
--
-- Lets operators schedule a recorder to start at a future time and stop
-- after a duration. The scheduler tick loop in mam-api (src/scheduler.js)
-- watches this table every 15s and triggers the existing /recorders/:id
-- start + stop endpoints when each schedule's window opens or closes.
--
-- recurrence: 'none' (one-shot) or 'daily' for the MVP. When a 'daily'
-- schedule completes, the tick loop clones it forward by 24h.

CREATE TABLE IF NOT EXISTS recorder_schedules (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  name            TEXT        NOT NULL,
  recorder_id     UUID        NOT NULL REFERENCES recorders(id) ON DELETE CASCADE,
  start_at        TIMESTAMPTZ NOT NULL,
  end_at          TIMESTAMPTZ NOT NULL,
  recurrence      TEXT        NOT NULL DEFAULT 'none',
  status          TEXT        NOT NULL DEFAULT 'pending',
  last_asset_id   UUID,
  error_message   TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (end_at > start_at),
  CHECK (recurrence IN ('none','daily','weekly')),
  CHECK (status IN ('pending','running','completed','failed','cancelled'))
);

CREATE INDEX IF NOT EXISTS idx_recorder_schedules_status_start
  ON recorder_schedules (status, start_at);

CREATE INDEX IF NOT EXISTS idx_recorder_schedules_recorder
  ON recorder_schedules (recorder_id);


I'd like to add a scheduler feature to the recorder feature. Lets add and ship that.


Debug ingest, monitors, buttons and prototype file editing


TodoWrite

Find Sidebar component to add Schedule nav item

Migration 009: recorder_schedules table

Backend route: GET/POST/PUT/DELETE /schedules

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Find Sidebar component to add Schedule nav item

Migration 009: recorder_schedules table

Backend route: GET/POST/PUT/DELETE /schedules

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test

Migration 009: recorder_schedules table

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test


### Schedules CRUD route + tick loop


Write

// Recorder scheduler — CRUD for upcoming + historic recording windows.
//
// The actual start/stop transitions happen in src/scheduler.js; this route
// just owns the recorder_schedules rows.

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';

const router = express.Router();
router.use(requireAuth);

const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);

function rowToJson(r) {
  return {
    id:             r.id,
    name:           r.name,
    recorder_id:    r.recorder_id,
    recorder_name:  r.recorder_name || null,
    start_at:       r.start_at,
    end_at:         r.end_at,
    recurrence:     r.recurrence,
    status:         r.status,
    last_asset_id:  r.last_asset_id,
    error_message:  r.error_message,
    created_at:     r.created_at,
    updated_at:     r.updated_at,
  };
}

// GET /api/v1/schedules?status=upcoming|past|all
router.get('/', async (req, res, next) => {
  try {
    const status = (req.query.status || 'all').toLowerCase();
    let where = 'TRUE';
    if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`;
    else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`;

    const result = await pool.query(
      `SELECT s.*, r.name AS recorder_name
       FROM recorder_schedules s
       LEFT JOIN recorders r ON r.id = s.recorder_id
       WHERE ${where}
       ORDER BY s.start_at ASC
       LIMIT 200`
    );
    res.json({ schedules: result.rows.map(rowToJson) });
  } catch (err) { next(err); }
});

// POST /api/v1/schedules
router.post('/', async (req, res, next) => {
  try {
    const { name, recorder_id, start_at, end_at, recurrence } = req.body || {};
    if (!name || !recorder_id || !start_at || !end_at) {
      return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' });
    }
    const rec = (recurrence || 'none').toLowerCase();
    if (!ALLOWED_RECURRENCE.has(rec)) {
      return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` });
    }
    if (new Date(end_at) <= new Date(start_at)) {
      return res.status(400).json({ error: 'end_at must be after start_at' });
    }
    // Make sure the recorder exists before binding to it.
    const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]);
    if (rExists.rows.length === 0) {
      return res.status(400).json({ error: 'Unknown recorder_id' });
    }

    const ins = await pool.query(
      `INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status)
       VALUES ($1, $2, $3, $4, $5, 'pending')
       RETURNING *`,
      [name.trim(), recorder_id, start_at, end_at, rec]
    );
    res.status(201).json(rowToJson(ins.rows[0]));
  } catch (err) { next(err); }
});

// PUT /api/v1/schedules/:id — edit a not-yet-started schedule
router.put('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
    if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
    if (current.rows[0].status === 'running') {
      return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' });
    }

    const fields = ['name','start_at','end_at','recurrence'];
    const updates = [];
    const values  = [];
    let i = 1;
    for (const f of fields) {
      if (req.body[f] !== undefined) {
        if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) {
          return res.status(400).json({ error: 'invalid recurrence' });
        }
        updates.push(`${f} = $${i++}`);
        values.push(req.body[f]);
      }
    }
    if (updates.length === 0) return res.json(rowToJson(current.rows[0]));
    updates.push('updated_at = NOW()');
    values.push(id);

    const result = await pool.query(
      `UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`,
      values
    );
    res.json(rowToJson(result.rows[0]));
  } catch (err) { next(err); }
});

// POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule
router.post('/:id/cancel', async (req, res, next) => {
  try {
    const { id } = req.params;
    const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
    if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
    if (TERMINAL.has(cur.rows[0].status)) {
      return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` });
    }
    // Just mark as cancelled — the tick loop will stop the recorder if it's
    // currently running and the schedule has just been cancelled.
    const result = await pool.query(
      `UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [id]
    );
    res.json(rowToJson(result.rows[0]));
  } catch (err) { next(err); }
});

// DELETE /api/v1/schedules/:id — hard delete (terminal schedules only)
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]);
    if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
    if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') {
      return res.status(400).json({ error: 'Cancel a running schedule before deleting' });
    }
    await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]);
    res.json({ message: 'Schedule deleted' });
  } catch (err) { next(err); }
});

export default router;


File created successfully at: /tmp/wd/wild-dragon/services/mam-api/src/routes/schedules.js (file state is current in your context — no need to Read it back)

// Recorder scheduler — CRUD for upcoming + historic recording windows.
//
// The actual start/stop transitions happen in src/scheduler.js; this route
// just owns the recorder_schedules rows.

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';

const router = express.Router();
router.use(requireAuth);

const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);

function rowToJson(r) {
  return {
    id:             r.id,
    name:           r.name,
    recorder_id:    r.recorder_id,
    recorder_name:  r.recorder_name || null,
    start_at:       r.start_at,
    end_at:         r.end_at,
    recurrence:     r.recurrence,
    status:         r.status,
    last_asset_id:  r.last_asset_id,
    error_message:  r.error_message,
    created_at:     r.created_at,
    updated_at:     r.updated_at,
  };
}

// GET /api/v1/schedules?status=upcoming|past|all
router.get('/', async (req, res, next) => {
  try {
    const status = (req.query.status || 'all').toLowerCase();
    let where = 'TRUE';
    if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`;
    else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`;

    const result = await pool.query(
      `SELECT s.*, r.name AS recorder_name
       FROM recorder_schedules s
       LEFT JOIN recorders r ON r.id = s.recorder_id
       WHERE ${where}
       ORDER BY s.start_at ASC
       LIMIT 200`
    );
    res.json({ schedules: result.rows.map(rowToJson) });
  } catch (err) { next(err); }
});

// POST /api/v1/schedules
router.post('/', async (req, res, next) => {
  try {
    const { name, recorder_id, start_at, end_at, recurrence } = req.body || {};
    if (!name || !recorder_id || !start_at || !end_at) {
      return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' });
    }
    const rec = (recurrence || 'none').toLowerCase();
    if (!ALLOWED_RECURRENCE.has(rec)) {
      return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` });
    }
    if (new Date(end_at) <= new Date(start_at)) {
      return res.status(400).json({ error: 'end_at must be after start_at' });
    }
    // Make sure the recorder exists before binding to it.
    const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]);
    if (rExists.rows.length === 0) {
      return res.status(400).json({ error: 'Unknown recorder_id' });
    }

    const ins = await pool.query(
      `INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status)
       VALUES ($1, $2, $3, $4, $5, 'pending')
       RETURNING *`,
      [name.trim(), recorder_id, start_at, end_at, rec]
    );
    res.status(201).json(rowToJson(ins.rows[0]));
  } catch (err) { next(err); }
});

// PUT /api/v1/schedules/:id — edit a not-yet-started schedule
router.put('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
    if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
    if (current.rows[0].status === 'running') {
      return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' });
    }

    const fields = ['name','start_at','end_at','recurrence'];
    const updates = [];
    const values  = [];
    let i = 1;
    for (const f of fields) {
      if (req.body[f] !== undefined) {
        if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) {
          return res.status(400).json({ error: 'invalid recurrence' });
        }
        updates.push(`${f} = $${i++}`);
        values.push(req.body[f]);
      }
    }
    if (updates.length === 0) return res.json(rowToJson(current.rows[0]));
    updates.push('updated_at = NOW()');
    values.push(id);

    const result = await pool.query(
      `UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`,
      values
    );
    res.json(rowToJson(result.rows[0]));
  } catch (err) { next(err); }
});

// POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule
router.post('/:id/cancel', async (req, res, next) => {
  try {
    const { id } = req.params;
    const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
    if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
    if (TERMINAL.has(cur.rows[0].status)) {
      return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` });
    }
    // Just mark as cancelled — the tick loop will stop the recorder if it's
    // currently running and the schedule has just been cancelled.
    const result = await pool.query(
      `UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [id]
    );
    res.json(rowToJson(result.rows[0]));
  } catch (err) { next(err); }
});

// DELETE /api/v1/schedules/:id — hard delete (terminal schedules only)
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]);
    if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
    if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') {
      return res.status(400).json({ error: 'Cancel a running schedule before deleting' });
    }
    await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]);
    res.json({ message: 'Schedule deleted' });
  } catch (err) { next(err); }
});

export default router;


Now the tick loop:


// Scheduler tick — every TICK_INTERVAL_MS scan the recorder_schedules table
// and transition any rows whose window opens or closes. The actual recorder
// start/stop is delegated to the existing /recorders/:id/start|stop routes
// via an in-process HTTP call, so we reuse all of the existing container
// orchestration, growing-files handling, asset row creation, etc.
//
// On schedule completion: a 'daily' or 'weekly' recurring schedule is cloned
// forward by 1 day / 7 days into a new 'pending' row.

import pool from './db/pool.js';

const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;

let _tickRunning = false;
let _interval = null;

async function callSelf(path, method = 'POST') {
  const res = await fetch(`${SELF_URL}${path}`, {
    method,
    headers: { 'Content-Type': 'application/json' },
    signal: AbortSignal.timeout(30000),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
  }
  return res.json().catch(() => ({}));
}

async function tick() {
  if (_tickRunning) return;
  _tickRunning = true;

  try {
    // 1) Start any pending schedules whose window has opened
    const dueStart = await pool.query(
      `SELECT * FROM recorder_schedules
       WHERE status = 'pending' AND start_at <= NOW() AND end_at > NOW()
       ORDER BY start_at ASC`
    );
    for (const s of dueStart.rows) {
      try {
        const result = await callSelf(`/api/v1/recorders/${s.recorder_id}/start`);
        await pool.query(
          `UPDATE recorder_schedules
           SET status = 'running', last_asset_id = NULL, updated_at = NOW()
           WHERE id = $1`,
          [s.id]
        );
        console.log(`[scheduler] started schedule "${s.name}" on recorder ${s.recorder_id} (session=${result.current_session_id || '?'})`);
      } catch (err) {
        await pool.query(
          `UPDATE recorder_schedules
           SET status = 'failed', error_message = $2, updated_at = NOW()
           WHERE id = $1`,
          [s.id, err.message.slice(0, 500)]
        );
        console.error(`[scheduler] start failed for schedule ${s.id}: ${err.message}`);
      }
    }

    // 2) Stop any running schedules whose window has closed
    const dueStop = await pool.query(
      `SELECT * FROM recorder_schedules
       WHERE status = 'running' AND end_at <= NOW()
       ORDER BY end_at ASC`
    );
    for (const s of dueStop.rows) {
      try {
        await callSelf(`/api/v1/recorders/${s.recorder_id}/stop`);
        await pool.query(
          `UPDATE recorder_schedules SET status = 'completed', updated_at = NOW()
           WHERE id = $1`,
          [s.id]
        );
        console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
        await enqueueNextOccurrence(s);
      } catch (err) {
        // Stop failed — flag as failed but don't keep trying forever.
        await pool.query(
          `UPDATE recorder_schedules
           SET status = 'failed', error_message = $2, updated_at = NOW()
           WHERE id = $1`,
          [s.id, ('stop: ' + err.message).slice(0, 500)]
        );
        console.error(`[scheduler] stop failed for schedule ${s.id}: ${err.message}`);
      }
    }

    // 3) If a schedule was cancelled while running, stop the recorder.
    const cancelledRunning = await pool.query(
      `SELECT s.* FROM recorder_schedules s
       JOIN recorders r ON r.id = s.recorder_id
       WHERE s.status = 'cancelled' AND r.status = 'recording'
         AND s.updated_at > NOW() - INTERVAL '5 minutes'`
    );
    for (const s of cancelledRunning.rows) {
      try {
        await callSelf(`/api/v1/recorders/${s.recorder_id}/stop`);
        console.log(`[scheduler] cancelled schedule "${s.name}" — stopped recorder ${s.recorder_id}`);
      } catch (err) {
        console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
      }
    }
  } catch (err) {
    console.error('[scheduler] tick error:', err);
  } finally {
    _tickRunning = false;
  }
}

async function enqueueNextOccurrence(schedule) {
  if (schedule.recurrence === 'none') return;
  const days = schedule.recurrence === 'weekly' ? 7 : 1;
  const start = new Date(schedule.start_at);
  const end   = new Date(schedule.end_at);
  start.setUTCDate(start.getUTCDate() + days);
  end.setUTCDate(end.getUTCDate() + days);
  await pool.query(
    `INSERT INTO recorder_schedules
       (name, recorder_id, start_at, end_at, recurrence, status)
     VALUES ($1, $2, $3, $4, $5, 'pending')`,
    [schedule.name, schedule.recorder_id, start.toISOString(), end.toISOString(), schedule.recurrence]
  );
  console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
}

export function startSchedulerLoop() {
  if (_interval) return;
  console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
  // Fire once on startup so a window that opened while the API was down
  // doesn't have to wait a full interval.
  setTimeout(() => tick().catch(() => {}), 2000);
  _interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
}

export function stopSchedulerLoop() {
  if (_interval) { clearInterval(_interval); _interval = null; }
}


File created successfully at: /tmp/wd/wild-dragon/services/mam-api/src/scheduler.js (file state is current in your context — no need to Read it back)

// Scheduler tick — every TICK_INTERVAL_MS scan the recorder_schedules table
// and transition any rows whose window opens or closes. The actual recorder
// start/stop is delegated to the existing /recorders/:id/start|stop routes
// via an in-process HTTP call, so we reuse all of the existing container
// orchestration, growing-files handling, asset row creation, etc.
//
// On schedule completion: a 'daily' or 'weekly' recurring schedule is cloned
// forward by 1 day / 7 days into a new 'pending' row.

import pool from './db/pool.js';

const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;

let _tickRunning = false;
let _interval = null;

async function callSelf(path, method = 'POST') {
  const res = await fetch(`${SELF_URL}${path}`, {
    method,
    headers: { 'Content-Type': 'application/json' },
    signal: AbortSignal.timeout(30000),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
  }
  return res.json().catch(() => ({}));
}

async function tick() {
  if (_tickRunning) return;
  _tickRunning = true;

  try {
    // 1) Start any pending schedules whose window has opened
    const dueStart = await pool.query(
      `SELECT * FROM recorder_schedules
       WHERE status = 'pending' AND start_at <= NOW() AND end_at > NOW()
       ORDER BY start_at ASC`
    );
    for (const s of dueStart.rows) {
      try {
        const result = await callSelf(`/api/v1/recorders/${s.recorder_id}/start`);
        await pool.query(
          `UPDATE recorder_schedules
           SET status = 'running', last_asset_id = NULL, updated_at = NOW()
           WHERE id = $1`,
          [s.id]
        );
        console.log(`[scheduler] started schedule "${s.name}" on recorder ${s.recorder_id} (session=${result.current_session_id || '?'})`);
      } catch (err) {
        await pool.query(
          `UPDATE recorder_schedules
           SET status = 'failed', error_message = $2, updated_at = NOW()
           WHERE id = $1`,
          [s.id, err.message.slice(0, 500)]
        );
        console.error(`[scheduler] start failed for schedule ${s.id}: ${err.message}`);
      }
    }

    // 2) Stop any running schedules whose window has closed
    const dueStop = await pool.query(
      `SELECT * FROM recorder_schedules
       WHERE status = 'running' AND end_at <= NOW()
       ORDER BY end_at ASC`
    );
    for (const s of dueStop.rows) {
      try {
        await callSelf(`/api/v1/recorders/${s.recorder_id}/stop`);
        await pool.query(
          `UPDATE recorder_schedules SET status = 'completed', updated_at = NOW()
           WHERE id = $1`,
          [s.id]
        );
        console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
        await enqueueNextOccurrence(s);
      } catch (err) {
        // Stop failed — flag as failed but don't keep trying forever.
        await pool.query(
          `UPDATE recorder_schedules
           SET status = 'failed', error_message = $2, updated_at = NOW()
           WHERE id = $1`,
          [s.id, ('stop: ' + err.message).slice(0, 500)]
        );
        console.error(`[scheduler] stop failed for schedule ${s.id}: ${err.message}`);
      }
    }

    // 3) If a schedule was cancelled while running, stop the recorder.
    const cancelledRunning = await pool.query(
      `SELECT s.* FROM recorder_schedules s
       JOIN recorders r ON r.id = s.recorder_id
       WHERE s.status = 'cancelled' AND r.status = 'recording'
         AND s.updated_at > NOW() - INTERVAL '5 minutes'`
    );
    for (const s of cancelledRunning.rows) {
      try {
        await callSelf(`/api/v1/recorders/${s.recorder_id}/stop`);
        console.log(`[scheduler] cancelled schedule "${s.name}" — stopped recorder ${s.recorder_id}`);
      } catch (err) {
        console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
      }
    }
  } catch (err) {
    console.error('[scheduler] tick error:', err);
  } finally {
    _tickRunning = false;
  }
}

async function enqueueNextOccurrence(schedule) {
  if (schedule.recurrence === 'none') return;
  const days = schedule.recurrence === 'weekly' ? 7 : 1;
  const start = new Date(schedule.start_at);
  const end   = new Date(schedule.end_at);
  start.setUTCDate(start.getUTCDate() + days);
  end.setUTCDate(end.getUTCDate() + days);
  await pool.query(
    `INSERT INTO recorder_schedules
       (name, recorder_id, start_at, end_at, recurrence, status)
     VALUES ($1, $2, $3, $4, $5, 'pending')`,
    [schedule.name, schedule.recorder_id, start.toISOString(), end.toISOString(), schedule.recurrence]
  );
  console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
}

export function startSchedulerLoop() {
  if (_interval) return;
  console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
  // Fire once on startup so a window that opened while the API was down
  // doesn't have to wait a full interval.
  setTimeout(() => tick().catch(() => {}), 2000);
  _interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
}

export function stopSchedulerLoop() {
  if (_interval) { clearInterval(_interval); _interval = null; }
}


Mount the route and start the loop in `index.js`:


Edit

import sdkRouter     from './routes/sdk.js';

import sdkRouter     from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import { startSchedulerLoop } from './scheduler.js';


The file /tmp/wd/wild-dragon/services/mam-api/src/index.js has been updated successfully. (file state is current in your context — no need to Read it back)

import sdkRouter     from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import { startSchedulerLoop } from './scheduler.js';

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { loadS3ConfigFromDb } from './s3/client.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter  from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter     from './routes/sdk.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24,
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth',      authRouter);
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system',    systemRouter);
app.use('/api/v1/cluster',   clusterRouter);
app.use('/api/v1/sdk',       sdkRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();

// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
  // Prefer an explicit override — useful when running inside Docker where
  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
  if (process.env.NODE_IP) return process.env.NODE_IP;

  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const iface of (ifaces[name] || [])) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
  return new Promise(resolve => {
    exec(
      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) return resolve([]);
        const gpus = stdout.trim().split('\n').map(line => {
          const parts = line.split(',').map(s => s.trim());
          return {
            index:     parseInt(parts[0], 10),
            name:      parts[1] || 'Unknown GPU',
            memory_mb: parseInt(parts[2], 10) || 0,
          };
        }).filter(g => !isNaN(g.index));
        resolve(gpus);
      }
    );
  });
}

async function selfHeartbeat() {
  const load  = os.loadavg()[0];
  const total = os.totalmem();
  const used  = total - os.freemem();
  const gpus  = await detectGpus();

  const capabilities = { gpus, blackmagic: [] };

  pool.query(
    `INSERT INTO cluster_nodes
       (hostname, ip_address, role, version, api_url,
        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
     ON CONFLICT (hostname) DO UPDATE SET
       ip_address   = EXCLUDED.ip_address,
       cpu_usage    = EXCLUDED.cpu_usage,
       mem_used_mb  = EXCLUDED.mem_used_mb,
       mem_total_mb = EXCLUDED.mem_total_mb,
       capabilities = EXCLUDED.capabilities,
       last_seen    = NOW()`,
    [
      process.env.NODE_HOSTNAME || os.hostname(),
      getLocalIp(),
      process.env.npm_package_version || null,
      `http://${getLocalIp()}:${PORT}`,
      parseFloat(load.toFixed(2)),
      Math.round(used  / 1024 / 1024),
      Math.round(total / 1024 / 1024),
      JSON.stringify(capabilities),
    ]
  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});

+import schedulesRouter from './routes/schedules.js';

+import { startSchedulerLoop } from './scheduler.js';


app.use('/api/v1/sdk',       sdkRouter);

app.use('/api/v1/sdk',       sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);


app.use('/api/v1/sdk',       sdkRouter);

app.use('/api/v1/sdk',       sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { loadS3ConfigFromDb } from './s3/client.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter  from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter     from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import { startSchedulerLoop } from './scheduler.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24,
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth',      authRouter);
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system',    systemRouter);
app.use('/api/v1/cluster',   clusterRouter);
app.use('/api/v1/sdk',       sdkRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();

// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
  // Prefer an explicit override — useful when running inside Docker where
  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
  if (process.env.NODE_IP) return process.env.NODE_IP;

  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const iface of (ifaces[name] || [])) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
  return new Promise(resolve => {
    exec(
      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) return resolve([]);
        const gpus = stdout.trim().split('\n').map(line => {
          const parts = line.split(',').map(s => s.trim());
          return {
            index:     parseInt(parts[0], 10),
            name:      parts[1] || 'Unknown GPU',
            memory_mb: parseInt(parts[2], 10) || 0,
          };
        }).filter(g => !isNaN(g.index));
        resolve(gpus);
      }
    );
  });
}

async function selfHeartbeat() {
  const load  = os.loadavg()[0];
  const total = os.totalmem();
  const used  = total - os.freemem();
  const gpus  = await detectGpus();

  const capabilities = { gpus, blackmagic: [] };

  pool.query(
    `INSERT INTO cluster_nodes
       (hostname, ip_address, role, version, api_url,
        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
     ON CONFLICT (hostname) DO UPDATE SET
       ip_address   = EXCLUDED.ip_address,
       cpu_usage    = EXCLUDED.cpu_usage,
       mem_used_mb  = EXCLUDED.mem_used_mb,
       mem_total_mb = EXCLUDED.mem_total_mb,
       capabilities = EXCLUDED.capabilities,
       last_seen    = NOW()`,
    [
      process.env.NODE_HOSTNAME || os.hostname(),
      getLocalIp(),
      process.env.npm_package_version || null,
      `http://${getLocalIp()}:${PORT}`,
      parseFloat(load.toFixed(2)),
      Math.round(used  / 1024 / 1024),
      Math.round(total / 1024 / 1024),
      JSON.stringify(capabilities),
    ]
  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});

app.use('/api/v1/sdk',       sdkRouter);


Now find where the server starts listening so I can hook the loop start there:


Bash

cd /tmp/wd/wild-dragon && grep -n "app.listen\|listening on" services/mam-api/src/index.js

Find listen call


186:app.listen(PORT, () => {
188:  console.log(`MAM API listening on port ${PORT}`);
Shell cwd was reset to /home/node


183	setInterval(selfHeartbeat, 30_000);
184	selfHeartbeat();
185
186	app.listen(PORT, () => {
187	  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
188	  console.log(`MAM API listening on port ${PORT}`);
189	  console.log(`Authentication: ${authMode}`);
190	});
191

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});


Migration 009: recorder_schedules table

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test


Edit

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
  // Boot the recorder scheduler tick loop after the HTTP server is live so
  // the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
  startSchedulerLoop();
});


app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
  // Boot the recorder scheduler tick loop after the HTTP server is live so
  // the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
  startSchedulerLoop();
});

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { loadS3ConfigFromDb } from './s3/client.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter  from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter     from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import { startSchedulerLoop } from './scheduler.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24,
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth',      authRouter);
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system',    systemRouter);
app.use('/api/v1/cluster',   clusterRouter);
app.use('/api/v1/sdk',       sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();

// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
  // Prefer an explicit override — useful when running inside Docker where
  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
  if (process.env.NODE_IP) return process.env.NODE_IP;

  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const iface of (ifaces[name] || [])) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
  return new Promise(resolve => {
    exec(
      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) return resolve([]);
        const gpus = stdout.trim().split('\n').map(line => {
          const parts = line.split(',').map(s => s.trim());
          return {
            index:     parseInt(parts[0], 10),
            name:      parts[1] || 'Unknown GPU',
            memory_mb: parseInt(parts[2], 10) || 0,
          };
        }).filter(g => !isNaN(g.index));
        resolve(gpus);
      }
    );
  });
}

async function selfHeartbeat() {
  const load  = os.loadavg()[0];
  const total = os.totalmem();
  const used  = total - os.freemem();
  const gpus  = await detectGpus();

  const capabilities = { gpus, blackmagic: [] };

  pool.query(
    `INSERT INTO cluster_nodes
       (hostname, ip_address, role, version, api_url,
        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
     ON CONFLICT (hostname) DO UPDATE SET
       ip_address   = EXCLUDED.ip_address,
       cpu_usage    = EXCLUDED.cpu_usage,
       mem_used_mb  = EXCLUDED.mem_used_mb,
       mem_total_mb = EXCLUDED.mem_total_mb,
       capabilities = EXCLUDED.capabilities,
       last_seen    = NOW()`,
    [
      process.env.NODE_HOSTNAME || os.hostname(),
      getLocalIp(),
      process.env.npm_package_version || null,
      `http://${getLocalIp()}:${PORT}`,
      parseFloat(load.toFixed(2)),
      Math.round(used  / 1024 / 1024),
      Math.round(total / 1024 / 1024),
      JSON.stringify(capabilities),
    ]
  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});

+  // Boot the recorder scheduler tick loop after the HTTP server is live so

+  // the loop's self-calls to /recorders/:id/start|stop reach a ready socket.


TodoWrite

Find Sidebar component to add Schedule nav item

Migration 009: recorder_schedules table

Backend route: GET/POST/PUT/DELETE /schedules

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test


Migration 009: recorder_schedules table

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test

Migration 009: recorder_schedules table

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test


### Schedule screen + modal in screens-ingest.jsx


595

596	      )}
597

598	        {feed.name}
599	        {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}
600

601

602	  );
603	}
604
605	Object.assign(window, { Upload, Recorders, Capture, Monitors });
606

      )}

        {feed.name}
        {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}

  );
}

Object.assign(window, { Upload, Recorders, Capture, Monitors });


Edit

Object.assign(window, { Upload, Recorders, Capture, Monitors });

/* ===== Schedule ===== */

const _STATUS_BADGE = {
  pending:   { cls: 'neutral',  label: 'pending' },
  running:   { cls: 'success',  label: 'recording' },
  completed: { cls: 'accent',   label: 'completed' },
  cancelled: { cls: 'warning',  label: 'cancelled' },
  failed:    { cls: 'danger',   label: 'failed' },
};

function _fmtWhen(iso) {
  if (!iso) return '—';
  const d = new Date(iso);
  // Local-time, short, human; e.g. "May 22 · 7:30 PM"
  return d.toLocaleString(undefined, {
    month: 'short', day: 'numeric',
    hour: 'numeric', minute: '2-digit',
  });
}

function _durationMin(startISO, endISO) {
  return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}

function Schedule({ navigate }) {
  const [schedules, setSchedules]   = React.useState(null);
  const [recorders, setRecorders]   = React.useState([]);
  const [showNew, setShowNew]       = React.useState(false);
  const [filter, setFilter]         = React.useState('upcoming');

  const load = React.useCallback(() => {
    window.ZAMPP_API.fetch('/schedules?status=' + filter)
      .then(d => setSchedules(d.schedules || []))
      .catch(() => setSchedules([]));
  }, [filter]);

  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);

  // Auto-refresh every 10s so the list reflects the tick loop's transitions
  React.useEffect(() => {
    const t = setInterval(load, 10_000);
    return () => clearInterval(t);
  }, [load]);

  const cancel = (s) => {
    if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
      .then(load)
      .catch(e => alert('Cancel failed: ' + e.message));
  };
  const remove = (s) => {
    if (!confirm(`Delete schedule "${s.name}"?`)) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })
      .then(load)
      .catch(e => alert('Delete failed: ' + e.message));
  };

  return (

Schedule

        Plan recorder windows · {schedules?.length || 0} {filter}

          <button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>
          <button className={filter === 'past' ? 'active' : ''}     onClick={() => setFilter('past')}>Past</button>
          <button className={filter === 'all' ? 'active' : ''}      onClick={() => setFilter('all')}>All</button>

        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={() => setShowNew(true)} disabled={recorders.length === 0}>
          <Icon name="plus" />New schedule
        </button>

        {schedules === null && (

Loading…

        )}
        {schedules !== null && schedules.length === 0 && (

📅

No {filter} recordings

            {filter === 'upcoming' && recorders.length === 0 && (

Create a recorder first, then schedule it here.

            )}
            {filter === 'upcoming' && recorders.length > 0 && (

Click New schedule to plan a future recording.

            )}

        )}
        {schedules !== null && schedules.length > 0 && (

Name

Recorder

Starts

Duration

Recurrence

Status

            {schedules.map(s => {
              const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
              return (

                    {s.name}
                    {s.error_message && (

{s.error_message}

                    )}

{s.recorder_name || s.recorder_id.slice(0, 8)}

{_fmtWhen(s.start_at)}

{_durationMin(s.start_at, s.end_at)} min

{s.recurrence === 'none' ? 'one-shot' : s.recurrence}

<span className={`badge ${badge.cls}`}>{badge.label}

                    {(s.status === 'pending' || s.status === 'running') && (
                      <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
                    )}
                    {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
                      <button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
                    )}

              );
            })}

        )}

      {showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}

  );
}

function NewScheduleModal({ recorders, onClose, onCreated }) {
  // Default: start in 5 minutes, run for 30 min — gives the operator something
  // sensible the moment the modal opens.
  const now = new Date();
  now.setSeconds(0, 0);
  const startDefault = new Date(now.getTime() + 5 * 60 * 1000);
  const endDefault   = new Date(now.getTime() + 35 * 60 * 1000);
  const toLocalInput = (d) => {
    const tz = d.getTimezoneOffset() * 60_000;
    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
  };

  const [form, setForm] = React.useState({
    name: '',
    recorder_id: recorders[0]?.id || '',
    start_at: toLocalInput(startDefault),
    end_at:   toLocalInput(endDefault),
    recurrence: 'none',
  });
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));

  const submit = () => {
    setErr(null);
    if (!form.name.trim())   return setErr('Name is required');
    if (!form.recorder_id)   return setErr('Pick a recorder');
    if (!form.start_at)      return setErr('Start time is required');
    if (!form.end_at)        return setErr('End time is required');
    if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');

    // Datetime-local inputs are in the browser's local zone; ship as ISO so
    // Postgres stores them as TIMESTAMPTZ properly.
    const body = {
      name:        form.name.trim(),
      recorder_id: form.recorder_id,
      start_at:    new Date(form.start_at).toISOString(),
      end_at:      new Date(form.end_at).toISOString(),
      recurrence:  form.recurrence,
    };

    setSaving(true);
    window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
      .then(() => onCreated())
      .catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
  };

  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };

  return (

 e.stopPropagation()}>

New scheduled recording

          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>

            Name
            <input className="field-input" autoFocus value={form.name}
                   onChange={e => set('name', e.target.value)}
                   onKeyDown={onKey} placeholder="Morning service stream" />

            Recorder
            <select className="field-input" value={form.recorder_id}
                    onChange={e => set('recorder_id', e.target.value)}
                    style={{ appearance: 'auto' }}>
              {recorders.length === 0 && <option value="">— No recorders defined —</option>}
              {recorders.map(r => (
                <option key={r.id} value={r.id}>
                  {r.name} · {r.source_type?.toUpperCase() || '?'}
                </option>
              ))}
            </select>

              Start
              <input className="field-input mono" type="datetime-local"
                     value={form.start_at}
                     onChange={e => set('start_at', e.target.value)} />

              End
              <input className="field-input mono" type="datetime-local"
                     value={form.end_at}
                     onChange={e => set('end_at', e.target.value)} />

            Recurrence
            <select className="field-input" value={form.recurrence}
                    onChange={e => set('recurrence', e.target.value)}
                    style={{ appearance: 'auto' }}>
              <option value="none">One-shot (no repeat)</option>
              <option value="daily">Daily</option>
              <option value="weekly">Weekly</option>
            </select>

              Recurring schedules queue the next occurrence as soon as the current one completes.

          {err &&
{err}
}

          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving}>
            {saving ? 'Scheduling…' : 'Schedule recording'}
          </button>

  );
}

Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });


The file /tmp/wd/wild-dragon/services/web-ui/public/screens-ingest.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

/* ===== Schedule ===== */

const _STATUS_BADGE = {
  pending:   { cls: 'neutral',  label: 'pending' },
  running:   { cls: 'success',  label: 'recording' },
  completed: { cls: 'accent',   label: 'completed' },
  cancelled: { cls: 'warning',  label: 'cancelled' },
  failed:    { cls: 'danger',   label: 'failed' },
};

function _fmtWhen(iso) {
  if (!iso) return '—';
  const d = new Date(iso);
  // Local-time, short, human; e.g. "May 22 · 7:30 PM"
  return d.toLocaleString(undefined, {
    month: 'short', day: 'numeric',
    hour: 'numeric', minute: '2-digit',
  });
}

function _durationMin(startISO, endISO) {
  return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}

function Schedule({ navigate }) {
  const [schedules, setSchedules]   = React.useState(null);
  const [recorders, setRecorders]   = React.useState([]);
  const [showNew, setShowNew]       = React.useState(false);
  const [filter, setFilter]         = React.useState('upcoming');

  const load = React.useCallback(() => {
    window.ZAMPP_API.fetch('/schedules?status=' + filter)
      .then(d => setSchedules(d.schedules || []))
      .catch(() => setSchedules([]));
  }, [filter]);

  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);

  // Auto-refresh every 10s so the list reflects the tick loop's transitions
  React.useEffect(() => {
    const t = setInterval(load, 10_000);
    return () => clearInterval(t);
  }, [load]);

  const cancel = (s) => {
    if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
      .then(load)
      .catch(e => alert('Cancel failed: ' + e.message));
  };
  const remove = (s) => {
    if (!confirm(`Delete schedule "${s.name}"?`)) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })
      .then(load)
      .catch(e => alert('Delete failed: ' + e.message));
  };

  return (

Schedule

        Plan recorder windows · {schedules?.length || 0} {filter}

          <button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>
          <button className={filter === 'past' ? 'active' : ''}     onClick={() => setFilter('past')}>Past</button>
          <button className={filter === 'all' ? 'active' : ''}      onClick={() => setFilter('all')}>All</button>

        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={() => setShowNew(true)} disabled={recorders.length === 0}>
          <Icon name="plus" />New schedule
        </button>

        {schedules === null && (

Loading…

        )}
        {schedules !== null && schedules.length === 0 && (

📅

No {filter} recordings

            {filter === 'upcoming' && recorders.length === 0 && (

Create a recorder first, then schedule it here.

            )}
            {filter === 'upcoming' && recorders.length > 0 && (

Click New schedule to plan a future recording.

            )}

        )}
        {schedules !== null && schedules.length > 0 && (

Name

Recorder

Starts

Duration

Recurrence

Status

            {schedules.map(s => {
              const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
              return (

                    {s.name}
                    {s.error_message && (

{s.error_message}

                    )}

{s.recorder_name || s.recorder_id.slice(0, 8)}

{_fmtWhen(s.start_at)}

{_durationMin(s.start_at, s.end_at)} min

{s.recurrence === 'none' ? 'one-shot' : s.recurrence}

<span className={`badge ${badge.cls}`}>{badge.label}

                    {(s.status === 'pending' || s.status === 'running') && (
                      <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
                    )}
                    {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
                      <button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
                    )}

              );
            })}

        )}

      {showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}

  );
}

function NewScheduleModal({ recorders, onClose, onCreated }) {
  // Default: start in 5 minutes, run for 30 min — gives the operator something
  // sensible the moment the modal opens.
  const now = new Date();
  now.setSeconds(0, 0);
  const startDefault = new Date(now.getTime() + 5 * 60 * 1000);
  const endDefault   = new Date(now.getTime() + 35 * 60 * 1000);
  const toLocalInput = (d) => {
    const tz = d.getTimezoneOffset() * 60_000;
    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
  };

  const [form, setForm] = React.useState({
    name: '',
    recorder_id: recorders[0]?.id || '',
    start_at: toLocalInput(startDefault),
    end_at:   toLocalInput(endDefault),
    recurrence: 'none',
  });
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));

  const submit = () => {
    setErr(null);
    if (!form.name.trim())   return setErr('Name is required');
    if (!form.recorder_id)   return setErr('Pick a recorder');
    if (!form.start_at)      return setErr('Start time is required');
    if (!form.end_at)        return setErr('End time is required');
    if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');

    // Datetime-local inputs are in the browser's local zone; ship as ISO so
    // Postgres stores them as TIMESTAMPTZ properly.
    const body = {
      name:        form.name.trim(),
      recorder_id: form.recorder_id,
      start_at:    new Date(form.start_at).toISOString(),
      end_at:      new Date(form.end_at).toISOString(),
      recurrence:  form.recurrence,
    };

    setSaving(true);
    window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
      .then(() => onCreated())
      .catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
  };

  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };

  return (

 e.stopPropagation()}>

New scheduled recording

          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>

            Name
            <input className="field-input" autoFocus value={form.name}
                   onChange={e => set('name', e.target.value)}
                   onKeyDown={onKey} placeholder="Morning service stream" />

            Recorder
            <select className="field-input" value={form.recorder_id}
                    onChange={e => set('recorder_id', e.target.value)}
                    style={{ appearance: 'auto' }}>
              {recorders.length === 0 && <option value="">— No recorders defined —</option>}
              {recorders.map(r => (
                <option key={r.id} value={r.id}>
                  {r.name} · {r.source_type?.toUpperCase() || '?'}
                </option>
              ))}
            </select>

              Start
              <input className="field-input mono" type="datetime-local"
                     value={form.start_at}
                     onChange={e => set('start_at', e.target.value)} />

              End
              <input className="field-input mono" type="datetime-local"
                     value={form.end_at}
                     onChange={e => set('end_at', e.target.value)} />

            Recurrence
            <select className="field-input" value={form.recurrence}
                    onChange={e => set('recurrence', e.target.value)}
                    style={{ appearance: 'auto' }}>
              <option value="none">One-shot (no repeat)</option>
              <option value="daily">Daily</option>
              <option value="weekly">Weekly</option>
            </select>

              Recurring schedules queue the next occurrence as soon as the current one completes.

          {err &&
{err}
}

          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving}>
            {saving ? 'Scheduling…' : 'Schedule recording'}
          </button>

  );
}

Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });

-Object.assign(window, { Upload, Recorders, Capture, Monitors });

+  pending:   { cls: 'neutral',  label: 'pending' },

+  running:   { cls: 'success',  label: 'recording' },

+  completed: { cls: 'accent',   label: 'completed' },

+  cancelled: { cls: 'warning',  label: 'cancelled' },

+  failed:    { cls: 'danger',   label: 'failed' },

+  // Local-time, short, human; e.g. "May 22 · 7:30 PM"

+    hour: 'numeric', minute: '2-digit',

+function _durationMin(startISO, endISO) {

+  return Math.round((new Date(endISO) - new Date(startISO)) / 60000);

+  const [schedules, setSchedules]   = React.useState(null);

+  const [recorders, setRecorders]   = React.useState([]);

+  const [showNew, setShowNew]       = React.useState(false);

+  const [filter, setFilter]         = React.useState('upcoming');

+  const load = React.useCallback(() => {

+    window.ZAMPP_API.fetch('/schedules?status=' + filter)

+      .then(d => setSchedules(d.schedules || []))

+  React.useEffect(() => { load(); }, [load]);

+  React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);

+  // Auto-refresh every 10s so the list reflects the tick loop's transitions

+    const t = setInterval(load, 10_000);

+    if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;

+    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })

+      .catch(e => alert('Cancel failed: ' + e.message));

+    if (!confirm(`Delete schedule "${s.name}"?`)) return;

+    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })

+      .catch(e => alert('Delete failed: ' + e.message));

+        Plan recorder windows · {schedules?.length || 0} {filter}

+        <div className="tab-group" style={{ marginRight: 8 }}>

+          <button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>

+          <button className={filter === 'past' ? 'active' : ''}     onClick={() => setFilter('past')}>Past</button>

+          <button className={filter === 'all' ? 'active' : ''}      onClick={() => setFilter('all')}>All</button>

+        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>

+        <button className="btn primary" onClick={() => setShowNew(true)} disabled={recorders.length === 0}>

+          <Icon name="plus" />New schedule

+        {schedules !== null && schedules.length === 0 && (

+          <div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>

+            {filter === 'upcoming' && recorders.length === 0 && (

+
Create a recorder first, then schedule it here.

+            {filter === 'upcoming' && recorders.length > 0 && (

+
Click New schedule to plan a future recording.

+        {schedules !== null && schedules.length > 0 && (

+            <div className="schedule-row head">

+              const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;

+                <div key={s.id} className="schedule-row">

+                  <div style={{ fontWeight: 500, fontSize: 13 }}>

+                    {s.error_message && (

+
{s.recorder_name || s.recorder_id.slice(0, 8)}

+
{_durationMin(s.start_at, s.end_at)} min

+
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}

+
<span className={`badge ${badge.cls}`}>{badge.label}

+                  <div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>

+                    {(s.status === 'pending' || s.status === 'running') && (

+                      <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>

+                    {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (

+                      <button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>

+      {showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}

+function NewScheduleModal({ recorders, onClose, onCreated }) {

+  // Default: start in 5 minutes, run for 30 min — gives the operator something

+  // sensible the moment the modal opens.

+  const startDefault = new Date(now.getTime() + 5 * 60 * 1000);

+  const endDefault   = new Date(now.getTime() + 35 * 60 * 1000);

+    const tz = d.getTimezoneOffset() * 60_000;

+    return new Date(d.getTime() - tz).toISOString().slice(0, 16);

+  const [form, setForm] = React.useState({

+    recorder_id: recorders[0]?.id || '',

+    start_at: toLocalInput(startDefault),

+    end_at:   toLocalInput(endDefault),

+  const [saving, setSaving] = React.useState(false);

+  const [err, setErr] = React.useState(null);

+    if (!form.name.trim())   return setErr('Name is required');

+    if (!form.recorder_id)   return setErr('Pick a recorder');

+    if (!form.start_at)      return setErr('Start time is required');

+    if (!form.end_at)        return setErr('End time is required');

+    if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');

+    // Datetime-local inputs are in the browser's local zone; ship as ISO so

+    // Postgres stores them as TIMESTAMPTZ properly.

+      start_at:    new Date(form.start_at).toISOString(),

+      end_at:      new Date(form.end_at).toISOString(),

+    window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })

+      .catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });

+  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };

+    <div className="modal-backdrop" onClick={onClose}>

+      <div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>

+          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>

+            <label className="field-label">Name</label>

+            <input className="field-input" autoFocus value={form.name}

+                   onChange={e => set('name', e.target.value)}

+                   onKeyDown={onKey} placeholder="Morning service stream" />

+            <label className="field-label">Recorder</label>

+            <select className="field-input" value={form.recorder_id}

+                    onChange={e => set('recorder_id', e.target.value)}

+                    style={{ appearance: 'auto' }}>

+              {recorders.length === 0 && <option value="">— No recorders defined —</option>}

+                <option key={r.id} value={r.id}>

+                  {r.name} · {r.source_type?.toUpperCase() || '?'}

+          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>

+              <label className="field-label">Start</label>

+              <input className="field-input mono" type="datetime-local"

+                     value={form.start_at}

+                     onChange={e => set('start_at', e.target.value)} />

+              <label className="field-label">End</label>

+                     value={form.end_at}

+                     onChange={e => set('end_at', e.target.value)} />

+            <label className="field-label">Recurrence</label>

+            <select className="field-input" value={form.recurrence}

+                    onChange={e => set('recurrence', e.target.value)}

+              <option value="none">One-shot (no repeat)</option>

+              <option value="daily">Daily</option>

+              <option value="weekly">Weekly</option>

+            <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>

+              Recurring schedules queue the next occurrence as soon as the current one completes.

+          <button className="btn ghost sm" onClick={onClose}>Cancel</button>

+          <button className="btn primary sm" onClick={submit} disabled={saving}>

+            {saving ? 'Scheduling…' : 'Schedule recording'}

+Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });


Bash

cd /tmp/wd/wild-dragon && find services/web-ui -name "*.css" | head; echo ---; grep -n "container-row\|user-row" services/web-ui/src/css/*.css 2>/dev/null | head -10

Locate CSS for row styles


services/web-ui/public/styles-screens.css
services/web-ui/public/styles-fixes.css
services/web-ui/public/styles.css
services/web-ui/public/styles-asset.css
services/web-ui/public/styles-modal.css
services/web-ui/public/styles-rest.css
services/web-ui/public/css/common.css
services/web-ui/src/css/app.css
services/web-ui/src/css/components/card-operational.css
services/web-ui/src/css/components/tokens.css
---
Shell cwd was reset to /home/node


cd /tmp/wd/wild-dragon && grep -rn "container-row\|user-row" services/web-ui/public/ 2>/dev/null | head -8

Find existing row styles


services/web-ui/public/screens-admin.jsx:119:

services/web-ui/public/screens-admin.jsx:130:

services/web-ui/public/screens-admin.jsx:463:

services/web-ui/public/screens-admin.jsx:473:

services/web-ui/public/styles-rest.css:567:.user-row, .token-row, .container-row {
services/web-ui/public/styles-rest.css:575:.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
services/web-ui/public/styles-rest.css:577:.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
services/web-ui/public/styles-rest.css:578:.user-row.head, .token-row.head, .container-row.head {
Shell cwd was reset to /home/node


565
566	/* ========== Admin tables ========== */
567	.user-row, .token-row, .container-row {
568	  display: grid;
569	  align-items: center;
570	  gap: 12px;
571	  padding: 12px 16px;
572	  border-bottom: 1px solid var(--border);
573	  font-size: 12.5px;
574	}
575	.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
576	.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
577	.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
578	.user-row.head, .token-row.head, .container-row.head {
579	  font-size: 10.5px;
580	  font-weight: 600;
581	  text-transform: uppercase;
582	  letter-spacing: 0.06em;
583	  color: var(--text-4);
584	}
585	.user-row:last-child, .token-row:last-child, .container-row:last-child { border-bottom: 0; }
586	.token-row.revoked { opacity: 0.5; }
587
588	/* ========== Cluster ========== */
589	.cluster-canvas {

/* ========== Admin tables ========== */
.user-row, .token-row, .container-row {
  display: grid;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
.user-row.head, .token-row.head, .container-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.user-row:last-child, .token-row:last-child, .container-row:last-child { border-bottom: 0; }
.token-row.revoked { opacity: 0.5; }

/* ========== Cluster ========== */
.cluster-canvas {


Edit

.user-row, .token-row, .container-row {
  display: grid;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
.user-row.head, .token-row.head, .container-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.user-row:last-child, .token-row:last-child, .container-row:last-child { border-bottom: 0; }

.user-row, .token-row, .container-row, .schedule-row {
  display: grid;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }
.user-row.head, .token-row.head, .container-row.head, .schedule-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }


The file /tmp/wd/wild-dragon/services/web-ui/public/styles-rest.css has been updated successfully. (file state is current in your context — no need to Read it back)

.user-row, .token-row, .container-row {
  display: grid;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
.user-row.head, .token-row.head, .container-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.user-row:last-child, .token-row:last-child, .container-row:last-child { border-bottom: 0; }

.user-row, .token-row, .container-row, .schedule-row {
  display: grid;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }
.user-row.head, .token-row.head, .container-row.head, .schedule-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }

-.user-row, .token-row, .container-row {

+.user-row, .token-row, .container-row, .schedule-row {

-.user-row.head, .token-row.head, .container-row.head {

+.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }

+.user-row.head, .token-row.head, .container-row.head, .schedule-row.head {

-.user-row:last-child, .token-row:last-child, .container-row:last-child { border-bottom: 0; }

+.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }


Now wire the sidebar nav and route:


    id: "ingest", label: "Ingest", icon: "upload", group: true,
    children: [
      { id: "upload", label: "Upload", icon: "upload" },
      { id: "recorders", label: "Recorders", icon: "record" },
      { id: "capture", label: "Capture", icon: "capture" },
      { id: "monitors", label: "Monitors", icon: "monitor" },
    ],
  },

    id: "ingest", label: "Ingest", icon: "upload", group: true,
    children: [
      { id: "upload", label: "Upload", icon: "upload" },
      { id: "recorders", label: "Recorders", icon: "record" },
      { id: "schedule", label: "Schedule", icon: "jobs" },
      { id: "capture", label: "Capture", icon: "capture" },
      { id: "monitors", label: "Monitors", icon: "monitor" },
    ],
  },


The file /tmp/wd/wild-dragon/services/web-ui/public/shell.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

    id: "ingest", label: "Ingest", icon: "upload", group: true,
    children: [
      { id: "upload", label: "Upload", icon: "upload" },
      { id: "recorders", label: "Recorders", icon: "record" },
      { id: "schedule", label: "Schedule", icon: "jobs" },
      { id: "capture", label: "Capture", icon: "capture" },
      { id: "monitors", label: "Monitors", icon: "monitor" },
    ],
  },

// shell.jsx - app shell: sidebar nav, topbar, route container

const NAV_TREE = [
  { id: "home", label: "Home", icon: "home" },
  { id: "library", label: "Library", icon: "library" },
  { id: "projects", label: "Projects", icon: "folder" },
  {
    id: "ingest", label: "Ingest", icon: "upload", group: true,
    children: [
      { id: "upload", label: "Upload", icon: "upload" },
      { id: "recorders", label: "Recorders", icon: "record" },
      { id: "capture", label: "Capture", icon: "capture" },
      { id: "monitors", label: "Monitors", icon: "monitor" },
    ],
  },
  { id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
  { id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
];

const ADMIN_TREE = [
  { id: "users", label: "Users", icon: "users" },
  { id: "tokens", label: "Tokens", icon: "token" },
  { id: "containers", label: "Containers", icon: "container" },
  { id: "cluster", label: "Cluster", icon: "cluster" },
  { id: "settings", label: "Settings", icon: "settings" },
];

function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
  const isGroup = item.group && item.children;
  const isOpen = isGroup && openGroups.has(item.id);
  const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));

  return (
    <>
      <div
        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
        onClick={() => {
          if (isGroup) toggleGroup(item.id);
          else onSelect(item.id);
        }}
      >
        <Icon name={item.icon} size={15} className="nav-icon" />
        {item.label}
        {item.badge && (
          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
        )}
        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}

      {isGroup && isOpen && (

          {item.children.map(c => (
            <div
              key={c.id}
              className={`nav-item ${active === c.id ? "active" : ""}`}
              onClick={() => onSelect(c.id)}
            >
              <Icon name={c.icon} size={14} className="nav-icon" />
              {c.label}
              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}}

          ))}

      )}
    </>
  );
}

function Sidebar({ active, onNavigate }) {
  const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
  const toggleGroup = (id) => {
    setOpenGroups(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  React.useEffect(() => {
    const ingestChildren = ["upload", "recorders", "capture", "monitors"];
    if (ingestChildren.includes(active)) {
      setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
    }
  }, [active]);

  return (

D

Dragonflight

v1.2.0

        {NAV_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

Admin

        {ADMIN_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

ZG

Zach Gaetano

admin · broadcast

        <button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>

  );
}

function Topbar({ crumbs, onNavigate, right }) {
  return (

        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
            <span
              className={i === crumbs.length - 1 ? "current" : ""}
              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
              onClick={() => c.to && onNavigate && onNavigate(c.to)}
            >
              {c.label}

          </React.Fragment>
        ))}

        <Icon name="search" className="search-icon" />
        <input placeholder="Search assets, projects, comments…" />
        ⌘K

        cluster healthy

      <button className="icon-btn" data-tip="Notifications">
        <Icon name="bell" />
      </button>
      {right}

  );
}

// General-purpose read-only form field used by recorder modal and other forms.
// Renders as a labeled select (when select=true) or text input.
function Field({ label, value, select, children }) {
  return (

      {label}
      {children || (select
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.Field = Field;

+      { id: "schedule", label: "Schedule", icon: "jobs" },


const ingestChildren = ["upload", "recorders", "capture", "monitors"];

const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];


// shell.jsx - app shell: sidebar nav, topbar, route container

const NAV_TREE = [
  { id: "home", label: "Home", icon: "home" },
  { id: "library", label: "Library", icon: "library" },
  { id: "projects", label: "Projects", icon: "folder" },
  {
    id: "ingest", label: "Ingest", icon: "upload", group: true,
    children: [
      { id: "upload", label: "Upload", icon: "upload" },
      { id: "recorders", label: "Recorders", icon: "record" },
      { id: "schedule", label: "Schedule", icon: "jobs" },
      { id: "capture", label: "Capture", icon: "capture" },
      { id: "monitors", label: "Monitors", icon: "monitor" },
    ],
  },
  { id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
  { id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
];

const ADMIN_TREE = [
  { id: "users", label: "Users", icon: "users" },
  { id: "tokens", label: "Tokens", icon: "token" },
  { id: "containers", label: "Containers", icon: "container" },
  { id: "cluster", label: "Cluster", icon: "cluster" },
  { id: "settings", label: "Settings", icon: "settings" },
];

function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
  const isGroup = item.group && item.children;
  const isOpen = isGroup && openGroups.has(item.id);
  const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));

  return (
    <>
      <div
        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
        onClick={() => {
          if (isGroup) toggleGroup(item.id);
          else onSelect(item.id);
        }}
      >
        <Icon name={item.icon} size={15} className="nav-icon" />
        {item.label}
        {item.badge && (
          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
        )}
        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}

      {isGroup && isOpen && (

          {item.children.map(c => (
            <div
              key={c.id}
              className={`nav-item ${active === c.id ? "active" : ""}`}
              onClick={() => onSelect(c.id)}
            >
              <Icon name={c.icon} size={14} className="nav-icon" />
              {c.label}
              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}}

          ))}

      )}
    </>
  );
}

function Sidebar({ active, onNavigate }) {
  const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
  const toggleGroup = (id) => {
    setOpenGroups(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  React.useEffect(() => {
    const ingestChildren = ["upload", "recorders", "capture", "monitors"];
    if (ingestChildren.includes(active)) {
      setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
    }
  }, [active]);

  return (

D

Dragonflight

v1.2.0

        {NAV_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

Admin

        {ADMIN_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

ZG

Zach Gaetano

admin · broadcast

        <button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>

  );
}

function Topbar({ crumbs, onNavigate, right }) {
  return (

        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
            <span
              className={i === crumbs.length - 1 ? "current" : ""}
              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
              onClick={() => c.to && onNavigate && onNavigate(c.to)}
            >
              {c.label}

          </React.Fragment>
        ))}

        <Icon name="search" className="search-icon" />
        <input placeholder="Search assets, projects, comments…" />
        ⌘K

        cluster healthy

      <button className="icon-btn" data-tip="Notifications">
        <Icon name="bell" />
      </button>
      {right}

  );
}

// General-purpose read-only form field used by recorder modal and other forms.
// Renders as a labeled select (when select=true) or text input.
function Field({ label, value, select, children }) {
  return (

      {label}
      {children || (select
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.Field = Field;

-    const ingestChildren = ["upload", "recorders", "capture", "monitors"];

+    const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];


Find Sidebar component to add Schedule nav item

Migration 009: recorder_schedules table

Backend route: GET/POST/PUT/DELETE /schedules

Scheduler tick loop (poll + start/stop)

Schedule screen + New Schedule modal

Wire Sidebar + app.jsx route

Commit, push, redeploy, smoke test


      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],

      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      schedule: ['Ingest', 'Schedule'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],