Seed the Fly test server with demo data on deploy

Adds deploy/fly/seed.sql: a pruning, idempotent demo dataset (devices,
notifications, device events) spanning every workflow state for App Store
reviewers, with timestamps relative to now so it always looks fresh. The
flyImage entrypoint applies it on each boot when OOTT_SEED is set: it starts
the backend so migrations create the schema, waits for the tables, then prunes
and reloads. push_tokens is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-18 09:12:23 -04:00
co-authored by Claude Opus 4.8
parent 386e459c12
commit f0395b317e
4 changed files with 175 additions and 6 deletions
+9 -3
View File
@@ -81,9 +81,15 @@ fly apps destroy oott-test # removes the app, machine, and volume
few US dollars a month, billed by usage — well under a dollar for a few days.
To trim it further between reviewer sessions, flip `fly.toml` to scale-to-zero
(`auto_stop_machines = "stop"`, `min_machines_running = 0`) and redeploy.
- **Empty UI:** the reviewer sees a working but device-less app, since there is
no LAN to discover. If the review benefits from a populated screen, seed a few
sample devices via the API (`PUT /api/devices`) after deploying.
- **Demo data:** a cloud host has no LAN to discover, so the server ships with a
demo dataset (`deploy/fly/seed.sql`) covering registered/unknown devices,
devices in every "last seen" state, all device-event types and scanner
sources, and read/unread notifications of each type. It is pruned and reloaded
on every boot/deploy while `OOTT_SEED = "1"` is set in `fly.toml`; set it to
`0` (or remove it) to run with a clean database. Note that because it reloads
on every machine start, a reviewer's own changes are reset if the machine
restarts — fine for a review server. The `push_tokens` table is never touched,
so app push registrations survive a reseed.
- **Config knobs** are env vars in `fly.toml` (`OOTT_LOG_LEVEL`,
`OOTT_NOTIFICATIONS_METHOD`, `OOTT_PORT`, `OOTT_DATA_DIR`); the API key is the
`OOTT_API_KEY` secret.
+1
View File
@@ -18,6 +18,7 @@ primary_region = "ams" # change to a region near you / the reviewers
OOTT_NOTIFICATIONS_METHOD = "none" # no push/pushover on the test server
OOTT_PORT = "8080"
OOTT_DATA_DIR = "/data"
OOTT_SEED = "1" # reload deploy/fly/seed.sql demo data on every boot/deploy
# Persistent SQLite storage. Create the volume once with:
# fly volumes create oott_data --region ams --size 1
+124
View File
@@ -0,0 +1,124 @@
-- Demo data for the Fly.io App Store review test server.
--
-- A cloud host has no LAN to scan, so this script populates the database with a
-- realistic spread of devices, notifications and device events covering every
-- state a reviewer should be able to exercise: registered vs unknown devices,
-- devices seen just now / today / this week / long offline, every device-event
-- type (NewDevice, DeviceSeen, DeviceChanged, DeviceBackOnline) and every
-- scanner source (ARP, mDNS, SSDP, DHCP, SNMP), plus read/unread notifications
-- of each type.
--
-- It is idempotent: it prunes the demo tables first, then reinserts. It is
-- applied on every deployment by the Fly entrypoint (see flake.nix flyImage).
-- push_tokens is intentionally left untouched so a reviewer's app stays
-- registered for push across a reseed.
--
-- Timestamps are written relative to "now" (RFC3339, +00:00 offset) so the data
-- always looks fresh regardless of when the server is deployed. The offset
-- format matches what the backend itself writes, keeping the string-based date
-- comparisons in the device summary correct.
PRAGMA busy_timeout = 10000;
BEGIN;
DELETE FROM device_events;
DELETE FROM notifications;
DELETE FROM devices;
-- ---------------------------------------------------------------------------
-- Devices
-- (mac_address, ipv4_address, vendor, last_seen, is_registered, owner,
-- device_type, name)
-- ---------------------------------------------------------------------------
INSERT INTO devices (mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name) VALUES
-- Registered, seen moments ago — the "everything is fine" case.
('AA:BB:CC:00:00:01', '192.168.1.10', 'Samsung Electronics', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-5 minutes'), 1, 'Alice', 'TV', 'living-room-tv'),
('AA:BB:CC:00:00:02', '192.168.1.11', 'Apple, Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-45 minutes'), 1, 'Bob', 'Laptop', 'bobs-macbook'),
-- Unknown device that just appeared — the core "new device" alert scenario.
('AA:BB:CC:00:00:03', '192.168.1.50', 'Espressif Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-2 minutes'), 0, '', '', NULL),
-- Unknown phone, still seen today.
('AA:BB:CC:00:00:04', '192.168.1.51', 'Google, Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-6 hours'), 0, '', 'Phone', NULL),
-- Registered but long offline — exercises the "not seen for a while" state.
('AA:BB:CC:00:00:05', '192.168.1.20', 'HP Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-10 days'), 1, 'Office', 'Printer', 'hp-office-printer'),
-- Registered, seen this week but not today.
('AA:BB:CC:00:00:06', '192.168.1.21', 'Google, Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-3 days'), 1, 'Home', 'IoT', 'nest-thermostat'),
-- Unknown tablet, seen this week but not today.
('AA:BB:CC:00:00:07', '192.168.1.52', 'Amazon Technologies Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-4 days'), 0, '', 'Tablet', NULL),
-- Router discovered via SNMP.
('AA:BB:CC:00:00:08', '192.168.1.1', 'Ubiquiti Inc.', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-10 minutes'), 1, 'Network', 'Router', 'gateway'),
-- Host discovered via mDNS, so it carries a hostname.
('AA:BB:CC:00:00:09', '192.168.1.30', 'Raspberry Pi Foundation', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-30 minutes'), 1, 'Dev', 'Computer', 'raspberrypi'),
-- Unknown camera, seen today.
('AA:BB:CC:00:00:0A', '192.168.1.53', 'Hangzhou Hikvision', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-20 hours'), 0, '', 'Camera', NULL);
-- ---------------------------------------------------------------------------
-- Notifications
-- (created_on, notification_type, title, body, is_new, mac_address)
-- notification_type: NewDeviceFound | DeviceOnlineAfterTime | DeviceChanged | Other
-- is_new: 1 = unread/new, 0 = already read
-- ---------------------------------------------------------------------------
INSERT INTO notifications (created_on, notification_type, title, body, is_new, mac_address) VALUES
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-2 minutes'), 'NewDeviceFound', 'New device found', 'An unknown device (Espressif Inc.) joined the network at 192.168.1.50.', 1, 'AA:BB:CC:00:00:03'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-20 hours'), 'NewDeviceFound', 'New device found', 'An unknown device (Hangzhou Hikvision) joined the network at 192.168.1.53.', 1, 'AA:BB:CC:00:00:0A'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-6 hours'), 'NewDeviceFound', 'New device found', 'An unknown device (Google, Inc.) joined the network at 192.168.1.51.', 0, 'AA:BB:CC:00:00:04'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-10 minutes'), 'DeviceOnlineAfterTime', 'Device back online', 'gateway (192.168.1.1) is back online after 2 days offline.', 1, 'AA:BB:CC:00:00:08'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-3 days'), 'DeviceOnlineAfterTime', 'Device back online', 'nest-thermostat (192.168.1.21) is back online after a week offline.', 0, 'AA:BB:CC:00:00:06'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-45 minutes'), 'DeviceChanged', 'Device changed', 'bobs-macbook changed IP address from 192.168.1.99 to 192.168.1.11.', 0, 'AA:BB:CC:00:00:02'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-3 days'), 'DeviceChanged', 'Device changed', 'nest-thermostat changed IP address from 192.168.1.45 to 192.168.1.21.', 1, 'AA:BB:CC:00:00:06'),
(strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-5 days'), 'Other', 'Monitoring started', 'OOTT started monitoring your network.', 0, NULL);
-- ---------------------------------------------------------------------------
-- Device events (per-device timelines shown on the device detail screen)
-- (mac_address, created_on, event_type, ipv4_address, vendor, scanner)
-- event_type: NewDevice | DeviceSeen | DeviceChanged | DeviceBackOnline
-- scanner: ARP | mDNS | SSDP | DHCP | SNMP
-- ---------------------------------------------------------------------------
INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor, scanner) VALUES
-- New unknown device: discovered then seen again.
('AA:BB:CC:00:00:03', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-2 minutes'), 'NewDevice', '192.168.1.50', 'Espressif Inc.', 'ARP'),
('AA:BB:CC:00:00:03', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-1 minutes'), 'DeviceSeen', '192.168.1.50', 'Espressif Inc.', 'ARP'),
-- Living room TV: long history across ARP and mDNS.
('AA:BB:CC:00:00:01', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-30 days'), 'NewDevice', '192.168.1.10', 'Samsung Electronics', 'ARP'),
('AA:BB:CC:00:00:01', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-7 days'), 'DeviceSeen', '192.168.1.10', 'Samsung Electronics', 'ARP'),
('AA:BB:CC:00:00:01', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-1 days'), 'DeviceSeen', '192.168.1.10', 'Samsung Electronics', 'mDNS'),
('AA:BB:CC:00:00:01', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-5 minutes'), 'DeviceSeen', '192.168.1.10', 'Samsung Electronics', 'ARP'),
-- MacBook: changed its IP address.
('AA:BB:CC:00:00:02', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-60 days'), 'NewDevice', '192.168.1.99', 'Apple, Inc.', 'ARP'),
('AA:BB:CC:00:00:02', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-45 minutes'), 'DeviceChanged', '192.168.1.11', 'Apple, Inc.', 'ARP'),
('AA:BB:CC:00:00:02', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-44 minutes'), 'DeviceSeen', '192.168.1.11', 'Apple, Inc.', 'ARP'),
-- Unknown phone: seen via ARP and DHCP.
('AA:BB:CC:00:00:04', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-6 hours'), 'NewDevice', '192.168.1.51', 'Google, Inc.', 'ARP'),
('AA:BB:CC:00:00:04', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-6 hours'), 'DeviceSeen', '192.168.1.51', 'Google, Inc.', 'DHCP'),
-- Printer: registered but long offline (last event 11 days ago).
('AA:BB:CC:00:00:05', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-90 days'), 'NewDevice', '192.168.1.20', 'HP Inc.', 'ARP'),
('AA:BB:CC:00:00:05', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-11 days'), 'DeviceSeen', '192.168.1.20', 'HP Inc.', 'ARP'),
-- Thermostat: came back online and changed IP, seen via DHCP/SSDP/ARP.
('AA:BB:CC:00:00:06', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-40 days'), 'NewDevice', '192.168.1.45', 'Google, Inc.', 'DHCP'),
('AA:BB:CC:00:00:06', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-3 days'), 'DeviceBackOnline', '192.168.1.45', 'Google, Inc.', 'ARP'),
('AA:BB:CC:00:00:06', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-3 days'), 'DeviceChanged', '192.168.1.21', 'Google, Inc.', 'ARP'),
('AA:BB:CC:00:00:06', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-3 days'), 'DeviceSeen', '192.168.1.21', 'Google, Inc.', 'SSDP'),
-- Unknown tablet, seen this week.
('AA:BB:CC:00:00:07', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-4 days'), 'NewDevice', '192.168.1.52', 'Amazon Technologies Inc.', 'ARP'),
('AA:BB:CC:00:00:07', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-4 days'), 'DeviceSeen', '192.168.1.52', 'Amazon Technologies Inc.', 'ARP'),
-- Gateway: discovered and polled via SNMP, recently back online.
('AA:BB:CC:00:00:08', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-200 days'), 'NewDevice', '192.168.1.1', 'Ubiquiti Inc.', 'SNMP'),
('AA:BB:CC:00:00:08', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-10 minutes'), 'DeviceBackOnline', '192.168.1.1', 'Ubiquiti Inc.', 'SNMP'),
('AA:BB:CC:00:00:08', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-9 minutes'), 'DeviceSeen', '192.168.1.1', 'Ubiquiti Inc.', 'SNMP'),
-- Raspberry Pi: discovered and seen via mDNS.
('AA:BB:CC:00:00:09', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-15 days'), 'NewDevice', '192.168.1.30', 'Raspberry Pi Foundation', 'mDNS'),
('AA:BB:CC:00:00:09', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-30 minutes'), 'DeviceSeen', '192.168.1.30', 'Raspberry Pi Foundation', 'mDNS'),
-- Unknown camera: discovered via ARP, seen via SSDP.
('AA:BB:CC:00:00:0A', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-20 hours'), 'NewDevice', '192.168.1.53', 'Hangzhou Hikvision', 'ARP'),
('AA:BB:CC:00:00:0A', strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now', '-20 hours'), 'DeviceSeen', '192.168.1.53', 'Hangzhou Hikvision', 'SSDP');
COMMIT;
+41 -3
View File
@@ -155,6 +155,9 @@
packages = forEachSystem (system: let
pkgs = pkgsBySystem.${system};
# The demo dataset applied to the Fly test server on each deployment.
seedSql = ./deploy/fly/seed.sql;
# Startup wrapper for the Fly.io image. The backend only reads its config
# from a TOML file, but on Fly we want the (secret) API key to come from a
# Fly secret and the rest of the deployment knobs from plain env vars, with
@@ -162,16 +165,23 @@
# the environment on every boot and then exec the backend against it. The
# scanners are forced off: a cloud host has no LAN to scan, this is purely
# an API/UI server for App Store review.
#
# When OOTT_SEED is set, the wrapper also (re)loads the demo dataset: it
# starts the backend (which runs the migrations), waits for the schema to
# exist, then prunes and reinserts the demo rows. The backend stays as the
# foreground process and we forward signals to it so Fly can stop it
# cleanly.
flyEntrypoint = pkgs.writeShellApplication {
name = "oott-fly-entrypoint";
runtimeInputs = [pkgs.coreutils pkgs.oott];
runtimeInputs = [pkgs.coreutils pkgs.oott pkgs.sqlite];
text = ''
: "''${OOTT_API_KEY:?OOTT_API_KEY must be set (fly secrets set OOTT_API_KEY=...)}"
data_dir="''${OOTT_DATA_DIR:-/data}"
db_file="$data_dir/oott.db"
mkdir -p "$data_dir"
cat > "$data_dir/oott.toml" <<EOF
[database]
path = "$data_dir/oott.db"
path = "$db_file"
[log]
level = "''${OOTT_LOG_LEVEL:-info}"
@@ -196,7 +206,35 @@
[dhcp_scanner]
enabled = false
EOF
exec oott --config "$data_dir/oott.toml"
# Without seeding, just hand the process over to the backend.
if [ -z "''${OOTT_SEED:-}" ] || [ "''${OOTT_SEED}" = "0" ]; then
exec oott --config "$data_dir/oott.toml"
fi
# Seeding path: run the backend in the background so it creates and
# migrates the schema, then load the demo data once the tables exist.
oott --config "$data_dir/oott.toml" &
oott_pid=$!
trap 'kill -TERM "$oott_pid" 2>/dev/null || true' TERM INT
schema_ready=false
for _ in $(seq 1 60); do
if sqlite3 "$db_file" "SELECT 1 FROM devices LIMIT 1;" >/dev/null 2>&1; then
schema_ready=true
break
fi
sleep 0.5
done
if [ "$schema_ready" = true ]; then
echo "Seeding demo data into $db_file"
sqlite3 "$db_file" < ${seedSql} || echo "WARNING: demo data seeding failed"
else
echo "WARNING: schema not ready in time; skipping demo data seeding"
fi
wait "$oott_pid"
'';
};
in {