Administration
ezfd-admin.sh is an interactive console for managing events, recovering
data, and updating the app. Run it on the server:
# bash ezfd-admin.sh
It connects to the local ezfd database as postgres and refuses to start if
PostgreSQL isn't reachable. On a Docker Compose install it runs on the host
and reaches the database through the db container instead — see
On a Docker install.
Command line
$ bash ezfd-admin.sh --help
EzFD — Interactive admin console
Usage:
sudo bash ezfd-admin.sh interactive menu
sudo bash ezfd-admin.sh --json dump all events as JSON (stdout, pipeable)
sudo bash ezfd-admin.sh --help this message
| Exit code | Means |
|---|---|
0 |
Success |
1 |
The database is unreachable |
2 |
Unrecognised option |
An unrecognised option is an error, not a request for the menu — so a typo in a cron line fails loudly instead of hanging on a prompt no one is there to answer:
$ bash ezfd-admin.sh --dump-json
[✗] Unknown option: --dump-json
EzFD — Interactive admin console
Usage:
sudo bash ezfd-admin.sh interactive menu
sudo bash ezfd-admin.sh --json dump all events as JSON (stdout, pipeable)
sudo bash ezfd-admin.sh --help this message
$ echo $?
2
On a Docker install
Run it from the checkout that holds compose.yaml, on the host:
$ cd ezfd && sudo bash ezfd-admin.sh
It recognises the install by the running db container and sends its queries
through docker compose exec. Everything in the menu works, with two
differences in how:
- Update application builds a new image and replaces the container rather
than rsyncing into
/opt/ezfd. See Updating the application. - Server time / clock reads and sets the host's clock, which is the one
the containers use, so it behaves exactly as on a
deploy.shinstall.
Files it writes — CSV exports and JSON backups — land in /tmp on the host,
not inside a container. If it picks the wrong install, which should only
happen with a stopped stack, set EZFD_INSTALL=docker or
EZFD_INSTALL=systemd.
Main menu
| Action | What it does |
|---|---|
| List / manage events | Browse events, open one for detail and per-event actions |
| Server statistics | Totals across all events, QSOs by event type |
| Server time / clock | Show the system, database and NTP state; set the clock by hand |
| Full JSON backup | Every event with its QSOs, SES roster and checkout history, to one file |
| Restore from JSON backup | Recreate events from a backup file |
| Update application | git pull, rebuild and restart |
Server time
QSOs are timestamped by the database, so the server's clock is the log's clock. That is fine on a hosted instance with NTP and a problem on a field server: a Raspberry Pi has no battery-backed real-time clock, so with no internet it comes up holding the time of its last shutdown, or an epoch date. Every contact then gets a plausible-looking but wrong time, which corrupts the log's chronology, the Cabrillo output, and the ±2-minute window ADIF import uses to skip contacts it has already seen. None of that is repairable afterwards.
Server time / clock shows the system clock, the database's clock and
whether NTP is synchronised, then offers to set the clock by hand (UTC, read
off a phone or GPS) or to re-enable NTP. Setting by hand disables NTP first —
systemd-timesyncd refuses the set otherwise — and writes the result to the
hardware clock if one is fitted.
Operators see this from the other side: the logging page shows a standing banner when the server's clock and their device's disagree by more than a minute.
Setting the clock only fixes contacts logged from that point on. QSOs already in the log keep the timestamps they were given — they aren't rewritten. So check the clock before an event starts, not after.
For anything more than a casual activation, give the machine a clock of its
own — a GPS receiver or an RTC module. This screen reports which of those is
keeping time, so a field server with an RTC fitted is no longer told its clock
is unsynchronised, and fake-hwclock is called out for what it is rather than
counted as a time source. See
Offline field servers → The clock is the part that bites.
The event list
List / manage events shows one line per event, newest first, numbered where it is printed:
# Code Club Type Pwr Class QSOs Dupes Ops
───────────────────────────────────────────────────────────────────────────────
1 K7F2A6 Lakeside Radio Club FD LOW 3A 284 13 4
2 P4M9XZ Cedar Valley ARC FD LOW 2A 179 8 4
3 B8N3QT Northern Lights ARS WFD QRP 1O 287 13 4
4 Z2H7RK Harbor City Radio Amateu FD HIGH 4A 196 8 4
5 Q6D1VN Prairie Winds ARC FD LOW 2A 219 9 4
6 T3J8WC Bayside Amateur Radio So SES - - 73 3 4
7 M9K4YB Granite State Radio Club FD QRP 1B 136 6 4
8 R7L2FD Cedar Valley ARC WFD LOW 2H 186 8 4
9 X4P6GH Delta Amateur Radio Club FD HIGH 5A 88 3 4
10 C1S5MJ Mesa Verde Radio Group SES - - 222 10 4
11 V8T3NP Lakeside Radio Club WFD LOW 1I 246 11 4
───────────────────────────────────────────────────────────────────────────────
Showing 1–11 of 22 · page 1/2
1-11 open n/p page /text filter CODE open by join code b back
>
Counts exclude deleted contacts. A special event has no contest class or power
category, so those columns show -.
At the prompt:
| Input | Does |
|---|---|
1–11 |
Open that row. Numbering restarts at 1 on each page |
n / p |
Next / previous page |
/text |
Filter by join code, club name or club call |
/ |
Clear the filter |
K7F2A6 |
Open by join code, from any page |
b |
Back to the main menu |
The page holds as many rows as the terminal has, so the header stays on screen. A short window pages sooner; a tall one shows more.
Filtering
Type / and any part of a code, club name or call. The match is
case-insensitive and literal — _ and % match themselves, not wildcards:
Filter: cedar (3 matching) · Enter / to clear
# Code Club Type Pwr Class QSOs Dupes Ops
───────────────────────────────────────────────────────────────────────────────
1 P4M9XZ Cedar Valley ARC FD LOW 2A 179 8 4
2 R7L2FD Cedar Valley ARC WFD LOW 2H 186 8 4
3 S6L1ZN Cedar Valley ARC SES - - 92 4 4
───────────────────────────────────────────────────────────────────────────────
3 event(s)
1-3 open /text filter CODE open by join code b back
The filter survives opening an event and coming back, so you can work through one club's events without retyping it.
Opening by join code
Typing the code is usually faster than finding its row, and it is the only option that doesn't care which page you're on — useful when an operator reads you a code over the air:
> K7F2A6
Anything that isn't a number, a command letter or a /filter is treated as a
join code. If no event has it, the console says so and suggests the search:
[!] No event with join code 'CEDAR'. Use /CEDAR to search names.
Per-event actions
Opening an event shows its configuration, QSO and duplicate counts, operators, and — for a special event — the operator roster and who's currently on the air.
| Action | Notes |
|---|---|
| Export QSOs to CSV | /tmp/qsos_CODE.csv |
| Export full backup | /tmp/ezfd_CODE_backup.json, including SES roster and checkouts |
| Change join code | 4–8 alphanumeric characters, must be unique |
| Edit event settings | Contest events: power, class, section. Special events: enforcement, slot length, dupe rule, approval |
| Manage operator roster | Special events only — approve, revoke, fix grid or state, remove |
| Clear all dupes | Marks every live duplicate as non-duplicate |
| Delete ALL QSOs | Keeps the event, empties the log — including deleted contacts |
| Delete this event | Everything, including QSOs, roster and checkouts |
Destructive actions require typing YES in full.
Deleted contacts and these two actions
QSOs are soft-deleted: DELETE /api/qso/{id} marks the row rather than
removing it, so the log can still answer "what happened to that contact?".
Two of the actions above have to be explicit about which rows they mean.
Clear all dupes works on live contacts only. A contact an operator deleted stays deleted and keeps its duplicate flag — clearing duplicates is about what you are going to submit, and a deleted contact isn't part of that. The count in the prompt is the number of rows that will change:
⚠ This will mark 13 dupe QSOs as non-dupe for K7F2A6.
Delete ALL QSOs means all of them, deleted ones included. That matters because a soft-deleted row is still carried in a backup and still comes back from a restore — leaving them behind would empty the log on screen and then repopulate it from the next round trip. The prompt says so when there are any:
⚠ This will permanently delete ALL 297 QSOs for event K7F2A6. 6 previously deleted QSO(s) go too.
This one is a real DELETE, not a soft delete. It is the one place in EzFD
where contacts leave the database for good.
The operator roster
Special events only. Shows each operator with their grid, state and approval status, then lets you act on one:
- Toggle approval — flips between approved and pending. Only matters if the event requires approval; see Special event stations.
- Set grid square / Set state — these feed the ADIF
MY_*fields for that operator's QSOs, so fixing a typo here fixes their exported log. - Remove from roster — revokes access. Their logged QSOs are deliberately kept; contacts they made were still real contacts.
Backups
Event owners can also take a backup without shell access: Full event backup
in the dashboard's ☰ menu downloads the same JSON this console writes, and the
home page restores one. Both call the same ezfd_export_events() /
ezfd_restore_events() functions in db/schema.sql, so the two paths cannot
produce or accept different shapes.
Per-event
Export full backup writes one JSON file containing the event, all its QSOs, and — for a special event — the operator roster and checkout history.
The roster matters more than it looks. It holds each operator's grid and
state, which is the only source for the ADIF MY_* fields. A backup without
it produces a restored log that no longer uploads correctly to LoTW.
Everything
Full JSON backup from the menu, or non-interactively:
# bash ezfd-admin.sh --json > /backup/ezfd-$(date +%F).json
The --json form prints to stdout and takes no input, so it's the one to put
in cron. It sets ON_ERROR_STOP, so a failed dump exits non-zero — a cron job
wrapping it can tell a real failure from an empty server:
# bash ezfd-admin.sh --json > /backup/ezfd-$(date +%F).json \
# || echo "EzFD backup FAILED" | mail -s "backup" admin@example.org
Database-level
# sudo -u postgres pg_dump ezfd | gzip > /backup/ezfd-$(date +%F).sql.gz
Restore with:
# sudo -u postgres psql -c "DROP DATABASE ezfd;"
# sudo -u postgres psql -c "CREATE DATABASE ezfd;"
# gunzip -c /backup/ezfd-2026-06-28.sql.gz | sudo -u postgres psql -d ezfd
When an export fails
Every export path — CSV, per-event JSON, Full JSON backup and --json —
reports a failure instead of announcing success over a file that isn't there:
[✗] Backup failed — the file is incomplete and has been removed:
ERROR: could not write to file: No space left on device
The partial file is deleted rather than left behind, because a truncated backup that looks like a backup is worse than no backup. These paths used to log success unconditionally, so a full disk still printed "Backup complete" next to a file size of zero.
Restoring from JSON
Restore from JSON backup reads a file and recreates the events in it.
Restored events get new join codes, so a restore never collides with anything already in the database. The console prints the mapping from old code to new.
What comes back: the event and its settings, every QSO with its full field set, and for a special event the operator roster with approval state and the checkout history.
Two details that were wrong once and are worth not re-breaking:
- Contest class and section restore as NULL when they were NULL, not as empty
strings. An empty string there puts a blank
MY_ARRL_SECTinto every exported record. - Every list is type-checked before iterating, because
json_aggover zero rows serialises as JSONnull— a scalar, whichCOALESCEdoesn't catch. Before this was fixed, restoring an event with no QSOs failed outright.
Both are covered by scripts/test-restore.sh; see
Development.
When a restore fails
A restore that hits a constraint violation stops and says so, leaving the database as it was:
[✗] Restore failed:
ERROR: new row for relation "qsos" violates check constraint "qsos_mode_check"
DETAIL: Failing row contains (…, K1XYZ, 20m, SSTV, …).
This is worth knowing because it did not always behave that way. psql fed a
script on stdin exits 0 even when a statement failed, unless
ON_ERROR_STOP=1 is set — so a failed restore used to print "Restore
complete" and then render the error text through the results table. If you
have a backup you restored before this was fixed, check the event actually
came back rather than trusting the message.
The event count shown before you confirm is read from this machine's
disk, not the database server's. That matters when DATABASE_URL points at
another host, as Deployment describes: the count used to go
through pg_read_file(), which reads the server's filesystem, and so failed
on a file the restore beneath it would have read perfectly well.
Merging two instances of one event
Restoring recreates an event. That is right when a copy is a copy, and wrong when the same weekend ran in two places at once — a field server at the site and the hosted instance — and both hold real contacts that have to become one log. Restoring there gives you a third event and two logs to reconcile by hand.
Merging is for that case, and only that case. It is a one-shot reconciliation after the event, not a sync: nothing runs continuously, nothing goes both ways, and nothing you might disagree with is decided for you.
Take a JSON export from the instance you are merging from, then post it at the event you are merging into:
# on the field server
curl -s "http://pi.local:3000/api/export/AB12CD?format=json" > site.json
# against the hosted instance, into the event that already exists there
curl -s -X POST "https://ezfd.example.org/api/import/event?merge_into=AB12CD" \
-H 'Content-Type: application/json' \
-d "{\"payload\": $(cat site.json)}" | python3 -m json.tool
What it does
- Adds contacts this instance doesn't have. A contact is recognised as already present either by its own id — copies made by restoring carry the original ids — or, when the two instances logged it independently, by matching callsign, band, mode and a ±2 minute window. That is the same rule the ADIF import uses, and it is here for the same reason: two servers stamp one contact seconds apart.
- Recomputes duplicate flags across the whole log. Each instance worked out its flags against a different subset, so both are wrong for the union — a contact that was first in one copy may be second in the merged whole. This is the step most easily forgotten, so it is not optional.
- Adds roster entries that are missing and never overwrites ones that are
here. The grid and state in this instance are the ones somebody has been
correcting, and they are the only source for the ADIF
MY_*fields. - Files the incoming checkout history as released. Two instances can each legitimately have held 20m phone at the same moment, because the exclusion constraint only ever guaranteed that couldn't happen inside one database. Keeping it as released history preserves the record without the constraint rejecting the merge.
What it deliberately doesn't do
- Resolve a contact edited on both sides. It reports the disagreement, names the fields, and leaves your copy alone. Picking a winner silently is how somebody's correction disappears with nothing on screen to say so.
- Change this event's settings. If the bonuses or the class differ, that is reported and the target's values stand — you merged into this one.
- Undo a deletion. A contact you deleted here that is still live in the
other copy stays deleted, and the collision is counted in
skipped_deleted_here. (Importing an ADIF file goes the other way, because choosing a file is a deliberate act; a merge is bulk and automatic.)
Reading the report
Nothing succeeds silently — the report is the point:
{ "merged": {
"qsos_added": 12, "already_present_by_id": 40, "already_present_by_time": 3,
"skipped_deleted_here": 1, "conflicts": [], "dupe_flags_changed": 4,
"roster_added": 2, "reservations_added": 6, "settings_differ": ["bonuses"]
} }
conflicts and settings_differ are the two to read: both mean something was
edited in two places and nothing was decided for you.
Running the same merge twice is safe — the second run adds nothing, because contact ids are preserved on insert.
When it refuses
An export has to be provably the same activation, or the merge is refused
with 400. Every event carries an identity that survives export and restore,
so a copy made by restoring is recognised automatically. An export taken
before this existed still carries its own event id, which is the same value,
so old backups work too.
If two copies of one weekend were genuinely created separately — set up by
hand on both machines, never restored from each other — nothing links them,
and merging really does combine two different events. Pass
"allow_different_origin": true in the body to say you mean it.
Field reference in API → POST /api/import/event.
Updating the application
Update application runs git pull in EZFD_REPO_DIR, rebuilds, and
restarts the service — the same work deploy.sh does, without re-checking the
system packages. That includes Node.js: if the server runs an older major than
the repository's .nvmrc, the action warns and builds anyway, and re-running
deploy.sh is what upgrades it.
If EZFD_REPO_DIR isn't set in /opt/ezfd/.env, the action can't find the
source. Add it, or re-run deploy.sh, which writes it.
The steps run in this order, and each one stops the update if it fails:
git pull— nothing else runs if the working tree or the network is bad. If the commit hasn't moved, the update stops here rather than rebuilding.npm ci, thennpm run build.- Check
/opt/ezfd/.envexists, before anything is copied over the running install. - Apply
db/schema.sql, while the old build is still the one on disk. rsyncthe build into/opt/ezfd.systemctl restart ezfd, then confirm the service came back.
Steps 3 and 4 are deliberately ahead of step 5. The schema is additive and idempotent — CI applies it twice to prove it — so the running build carries on against the new schema quite happily, and a schema error aborts with nothing deployed:
[✗] Database migration failed — nothing has been deployed.
ERROR: syntax error at or near "…"
The running install is untouched. Fix the schema error and re-run.
The old order rsynced first and sent the migration's errors to /dev/null
behind a || true, then restarted regardless — so a new build met an old
schema and failed on its first query, with nothing on screen to say why.
Updating a Docker install
The order is the same, by a different mechanism:
git pullin the checkout besidecompose.yaml, which keepscompose.yamland the console itself current.docker compose build, while the old container keeps serving. This fetches the latest commit onEZFD_REFfrom GitHub, so it runs even when the pull found nothing new.docker compose run --rm init, which appliesdb/schema.sql.docker compose up -d, which replaces the app.
Step 3 runs on its own on purpose. Left to up, compose stops the old app
before the schema step it depends on has finished, so a schema error left the
site returning 502. Run separately, a failure stops the update with the old
app still serving:
[✗] Database migration failed — nothing has been deployed.
psql:/schema/schema.sql:980: ERROR: division by zero
The running app is untouched. Fix the schema error and re-run.
Notes on the script itself
If you extend it, AGENTS.md documents the conventions. The important one:
it deliberately uses set -uo pipefail without -e, because -e
terminates interactive menus on the first non-zero return — and a menu that
exits when you pick the wrong option is worse than useless. Related: use
[[ ]] rather than (( )) for comparisons, since (( )) returns exit 1 on a
false result.
Three more that are easy to get wrong:
Query through PG() or PGS(), not a bare psql. Both go through
psql_su(), which is the one place the two installs differ — sudo -u postgres psql on a deploy.sh install, docker compose exec on a Docker
one — so a bare psql also works on only one of them. Both set the field
separator to an ASCII unit separator (\x1f) rather than psql's default pipe,
and every row reader splits on $FS to match:
local row=""; row=$(PG -c "SELECT club_name, location FROM events WHERE id='$uuid';")
IFS="$FS" read -r club_name location <<< "$row"
psql -A does not escape its separator inside a value, and club names,
locations and SES descriptions are free text an operator types. With the
default pipe, a club called Pipe|Name Club split into two fields and shifted
every following column of that row one place right — the event table printed
the created date under "Class". scripts/test-restore.sh fails if an
IFS='|' read reappears.
Use PGS() when a failure must not be reported as success. It adds
ON_ERROR_STOP=1. This is not optional dressing: psql -c reports a SQL
error in its exit status, but a script fed on stdin exits 0 regardless,
which is exactly how the restore came to print "Restore complete" over a
constraint violation.
Pad with ASCII in aligned tables. printf "%-4s" pads by byte, not by
display width, so a multi-byte character in a column knocks every following
column of that row out of line. The event table's placeholder for a special
event's absent class is -, not —, for that reason.