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
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MessageSquare, Send, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { dataCapApi, type LteDataCapRule } from '../../services/api';
|
||||
import { dataCapApi } from '../../services/api';
|
||||
|
||||
/**
|
||||
* Daily data-cap SMS for carriers that throttle "unlimited" plans.
|
||||
@@ -123,7 +123,8 @@ export default function DataCapCard({ deviceId, interfaceName }: { deviceId: num
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-400 dark:text-slate-500">
|
||||
Counted from polling, so it reads low — traffic during a reboot or before this device was
|
||||
added is invisible to us. Sends at {fmtGB(firesAt)} GB ({form.margin_pct}% early) for that reason.
|
||||
added is invisible to us. Sends at {fmtGB(firesAt)} GB ({form.margin_pct}% early) for that reason,
|
||||
and restarts at zero once sent, because that is what the message does to the carrier’s counter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Terminal, AlertTriangle, Play, ShieldCheck, ChevronDown, ChevronRight, Ban } from 'lucide-react';
|
||||
import { Terminal, AlertTriangle, Play, ShieldCheck, ChevronDown, ChevronRight, Ban, Download } from 'lucide-react';
|
||||
import { commandsApi, devicesApi, type CommandRunDetail } from '../services/api';
|
||||
|
||||
/**
|
||||
@@ -12,6 +12,24 @@ import { commandsApi, devicesApi, type CommandRunDetail } from '../services/api'
|
||||
* the guards default on — with a single deliberate switch to turn them off,
|
||||
* because a tool that refuses to cut is not a sharp tool (#118).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pull the CSV through the authenticated client and hand it to the browser.
|
||||
*
|
||||
* A plain download link cannot carry the Bearer header, so it would 401.
|
||||
*/
|
||||
async function downloadCsv(runId: number): Promise<void> {
|
||||
const res = await commandsApi.exportCsv(runId);
|
||||
const url = URL.createObjectURL(res.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `command-run-${runId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function CommandsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [command, setCommand] = useState('');
|
||||
@@ -219,12 +237,19 @@ export default function CommandsPage() {
|
||||
|
||||
{openRun === r.id && detail && (
|
||||
<div className="px-5 pb-3 space-y-1">
|
||||
{detail.status === 'running' && (
|
||||
<button className="btn-secondary text-xs flex items-center gap-1.5 mb-2"
|
||||
onClick={() => commandsApi.cancelRun(r.id)}>
|
||||
<Ban className="w-3.5 h-3.5" /> Stop before next wave
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{detail.status === 'running' && (
|
||||
<button className="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onClick={() => commandsApi.cancelRun(r.id)}>
|
||||
<Ban className="w-3.5 h-3.5" /> Stop before next wave
|
||||
</button>
|
||||
)}
|
||||
{/* Requested on #118 for archiving and audit. */}
|
||||
<button className="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onClick={() => downloadCsv(r.id)}>
|
||||
<Download className="w-3.5 h-3.5" /> Export CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{detail.devices.map(d => (
|
||||
<div key={d.id} className="text-xs border-b border-gray-100 dark:border-slate-800 last:border-0 py-1.5">
|
||||
<button className="flex items-center gap-2 w-full text-left"
|
||||
|
||||
@@ -417,6 +417,12 @@ export const commandsApi = {
|
||||
listRuns: () => api.get<CommandRunSummary[]>('/commands/runs'),
|
||||
getRun: (id: number) => api.get<CommandRunDetail>(`/commands/runs/${id}`),
|
||||
cancelRun: (id: number) => api.post<{ message: string }>(`/commands/runs/${id}/cancel`),
|
||||
/**
|
||||
* Fetched through the authenticated client, not a bare link. Auth is a Bearer
|
||||
* header, which an <a download> cannot send — the link would simply 401.
|
||||
*/
|
||||
exportCsv: (id: number) =>
|
||||
api.get(`/commands/runs/${id}/export`, { responseType: 'blob' }),
|
||||
};
|
||||
|
||||
export const devicesApi = {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.28 Beta';
|
||||
export const APP_VERSION = 'v0.23.29 Beta';
|
||||
|
||||
Reference in New Issue
Block a user