fix: place log events in real time, reset the data cap on send, export runs (v0.23.29-beta)

Three reports from this morning.

**Event timestamps were not the times the events happened.** RouterOS writes log
times in the device's own timezone and never says so, and it uses more than one
format. The parser handled `sep/03 08:37:38` and `08:37:38` but labelled both
UTC, shifting every event by the device's offset — reported as an event log
seven hours out from the device page. It did not handle
`2026-09-03 08:37:38` at all, which is what current RouterOS actually returns
over the API, so those fell through to the collection time instead: plausible on
a live poll, badly wrong for a backlog, where a thousand historical lines all
arrive stamped "now".

Devices now report their clock, and timestamps are converted from device-local
to UTC through it, with the numeric gmt-offset as a fallback and daylight saving
handled by the runtime rather than a fixed number. Where the zone is unknown the
parser returns null and the caller falls back to collection time, deliberately —
a confidently wrong timestamp is worse than an openly approximate one.

Verified on hardware: 18 of 18 events in a steady-state window now store exactly
seven hours ahead of what the device printed, which is the offset the device
itself reports. Existing rows are left alone; they were written by three
different code paths and cannot be corrected uniformly.

**The data-cap counter kept climbing after the SMS was sent.** Reported from the
field, and correct: the message is what zeroes the carrier's allowance — it is
the alternative to waiting for midnight — so ours has to follow. Left as it was,
two things went wrong: the bar counted against an allowance that no longer
applied, and usage stayed above the threshold, so the rule would re-fire on
every cooldown for the rest of the day. Both send paths now restart the count.

**Command runs export to CSV**, asked for on #118 for archiving and audit.
Output is quoted so multi-line device responses survive, and timestamps are ISO
rather than the driver's default string form, which no spreadsheet parses. The
download goes through the authenticated client rather than a plain link, because
a link cannot carry a Bearer header and would simply 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-09-03 08:50:01 -07:00
co-authored by Claude Opus 5
parent cce1f88a45
commit 324ceba676
13 changed files with 374 additions and 49 deletions
+50
View File
@@ -149,6 +149,56 @@ router.get('/runs/:id', async (req: Request, res: Response) => {
res.json({ ...run, devices });
});
/**
* GET /api/commands/runs/:id/export — the run as a downloadable record.
*
* Asked for on #118 for archiving and audit. CSV rather than JSON because the
* audience is a spreadsheet or a ticket attachment, and output is quoted so a
* multi-line device response survives the round trip intact.
*/
router.get('/runs/:id/export', async (req: Request, res: Response) => {
const run = await queryOne<{ id: number; name: string; command: string; status: string; created_at: string }>(
`SELECT id, name, command, status, created_at FROM command_runs WHERE id = $1`, [req.params.id]
);
if (!run) return res.status(404).json({ error: 'Run not found' });
const rows = await query<{
device_name: string; ip_address: string; wave: number; status: string;
output: string | null; error: string | null; guarded: boolean;
started_at: string | null; finished_at: string | null;
}>(
`SELECT d.name AS device_name, d.ip_address, rd.wave, rd.status, rd.output, rd.error,
rd.guarded, rd.started_at, rd.finished_at
FROM command_run_devices rd JOIN devices d ON d.id = rd.device_id
WHERE rd.run_id = $1 ORDER BY rd.wave, d.name`,
[req.params.id]
);
// Timestamps arrive from the driver as Date objects, whose default string form
// ("Thu Sep 03 2026 15:45:29 GMT+0000 (Coordinated Universal Time)") no
// spreadsheet will parse. ISO is both sortable and machine-readable.
const esc = (v: unknown) => {
const t = v == null ? '' : v instanceof Date ? v.toISOString() : String(v);
// Always quote: output routinely contains commas, quotes and newlines, and
// a record that cannot be reopened is not an archive.
return `"${t.replace(/"/g, '""')}"`;
};
const lines = [
['run_id', 'run_name', 'command', 'run_status', 'device', 'ip_address', 'wave',
'status', 'guarded', 'started_at', 'finished_at', 'output', 'error'].join(','),
...rows.map((r) => [
run.id, run.name, run.command, run.status, r.device_name.trim(), r.ip_address, r.wave,
r.status, r.guarded, r.started_at ?? '', r.finished_at ?? '', r.output ?? '', r.error ?? '',
].map(esc).join(',')),
];
const stamp = new Date(run.created_at).toISOString().slice(0, 19).replace(/[:T]/g, '-');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="command-run-${run.id}-${stamp}.csv"`);
res.send(lines.join('\n'));
});
// POST /api/commands/runs/:id/start
router.post('/runs/:id/start', requireWrite, async (req: Request, res: Response) => {
try {