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:
co-authored by
Claude Opus 5
parent
cce1f88a45
commit
324ceba676
@@ -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 {
|
||||
|
||||
@@ -2545,8 +2545,10 @@ router.post('/:id/lte/data-cap/:iface/send', requireWrite, async (req: Request,
|
||||
VALUES ($1,$2,'manual',$3,$4,TRUE)`,
|
||||
[deviceRow.id, req.params.iface, phone, rule ? Number(rule.period_bytes) : null]
|
||||
).catch(() => {});
|
||||
// The counter restarts with the carrier's, for the same reason the automatic
|
||||
// path resets it: the message is what zeroes their allowance.
|
||||
await query(
|
||||
`UPDATE lte_data_cap_rules SET last_sent_at = NOW()
|
||||
`UPDATE lte_data_cap_rules SET last_sent_at = NOW(), period_bytes = 0
|
||||
WHERE device_id = $1 AND interface_name = $2`,
|
||||
[deviceRow.id, req.params.iface]
|
||||
).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user