-
+
diff --git a/backend/package.json b/backend/package.json
index 5e91f8b..1218b4d 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.22-beta",
+ "version": "0.23.23-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index 40a0409..d1e0303 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -812,6 +812,56 @@ CREATE TABLE IF NOT EXISTS device_poll_stats (
PRIMARY KEY (device_id, kind)
);
CREATE INDEX IF NOT EXISTS idx_poll_stats_attempt ON device_poll_stats(last_attempt_at DESC);
+
+-- Cellular data-cap SMS (discussion #85).
+--
+-- Several EU carriers throttle "unlimited" plans past a daily allowance and lift
+-- the throttle when the subscriber texts a short code. period_bytes is our own
+-- reconstruction of usage, accumulated from poll to poll, because the interface
+-- byte counters do not survive a reboot; last_rx/last_tx hold the previous
+-- sample so a counter that goes backwards is read as a reboot rather than as
+-- negative usage.
+--
+-- period_key is a local date string rather than a timestamp. Comparing keys is
+-- how a reset is detected without converting local wall-clock back to UTC, which
+-- is where daylight saving usually goes wrong.
+CREATE TABLE IF NOT EXISTS lte_data_cap_rules (
+ id SERIAL PRIMARY KEY,
+ device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ interface_name VARCHAR(64) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ phone_number VARCHAR(32) NOT NULL,
+ message TEXT NOT NULL DEFAULT '',
+ threshold_bytes BIGINT NOT NULL,
+ -- We fire below the configured allowance on purpose; our count undercounts.
+ margin_pct INTEGER NOT NULL DEFAULT 5,
+ reset_hour INTEGER NOT NULL DEFAULT 0,
+ reset_minute INTEGER NOT NULL DEFAULT 0,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
+ cooldown_minutes INTEGER NOT NULL DEFAULT 60,
+ period_key VARCHAR(10),
+ period_bytes BIGINT NOT NULL DEFAULT 0,
+ last_rx BIGINT,
+ last_tx BIGINT,
+ last_sent_at TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(device_id, interface_name)
+);
+
+-- Every send, with the counter reading at the time, so a misfire is explainable
+-- rather than mysterious.
+CREATE TABLE IF NOT EXISTS lte_data_cap_sends (
+ id SERIAL PRIMARY KEY,
+ device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ interface_name VARCHAR(64) NOT NULL,
+ at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ trigger VARCHAR(16) NOT NULL,
+ phone_number VARCHAR(32),
+ period_bytes BIGINT,
+ ok BOOLEAN NOT NULL,
+ error TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_data_cap_sends ON lte_data_cap_sends(device_id, at DESC);
`;
const DEFAULT_SETTINGS = [
diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts
index 705d1c1..877f5ed 100644
--- a/backend/src/routes/devices.ts
+++ b/backend/src/routes/devices.ts
@@ -2315,6 +2315,135 @@ router.get('/:id/lte', async (req: Request, res: Response) => {
}
});
+// ─── Cellular data-cap SMS (discussion #85) ──────────────────────────────────
+
+// GET /api/devices/:id/lte/data-cap — rules and recent sends
+router.get('/:id/lte/data-cap', async (req: Request, res: Response) => {
+ try {
+ const rules = await query(
+ `SELECT id, interface_name, enabled, phone_number, message, threshold_bytes,
+ margin_pct, reset_hour, reset_minute, timezone, cooldown_minutes,
+ period_key, period_bytes, last_sent_at, updated_at
+ FROM lte_data_cap_rules WHERE device_id = $1 ORDER BY interface_name`,
+ [req.params.id]
+ );
+ const sends = await query(
+ `SELECT interface_name, at, trigger, phone_number, period_bytes, ok, error
+ FROM lte_data_cap_sends WHERE device_id = $1 ORDER BY at DESC LIMIT 20`,
+ [req.params.id]
+ );
+ res.json({ rules, sends });
+ } catch (error) {
+ console.error('Error fetching data-cap rules:', error);
+ res.status(500).json({ error: 'Failed to fetch data-cap rules' });
+ }
+});
+
+// PUT /api/devices/:id/lte/data-cap/:iface — create or update a rule
+router.put('/:id/lte/data-cap/:iface', requireWrite, async (req: Request, res: Response) => {
+ const b = req.body as Record;
+ const phone = String(b.phone_number ?? '').trim();
+ if (!phone) return res.status(400).json({ error: 'phone_number is required' });
+
+ const threshold = Number(b.threshold_bytes);
+ if (!Number.isFinite(threshold) || threshold <= 0) {
+ return res.status(400).json({ error: 'threshold_bytes must be a positive number' });
+ }
+ // An unknown zone would silently park the counter in the wrong day, so reject
+ // it here rather than discovering it at midnight.
+ const tz = String(b.timezone ?? 'UTC');
+ try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); }
+ catch { return res.status(400).json({ error: `Unknown timezone "${tz}"` }); }
+
+ const clamp = (v: unknown, lo: number, hi: number, dflt: number) => {
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.floor(n))) : dflt;
+ };
+
+ try {
+ await query(
+ `INSERT INTO lte_data_cap_rules
+ (device_id, interface_name, enabled, phone_number, message, threshold_bytes,
+ margin_pct, reset_hour, reset_minute, timezone, cooldown_minutes)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
+ ON CONFLICT (device_id, interface_name) DO UPDATE SET
+ enabled = EXCLUDED.enabled, phone_number = EXCLUDED.phone_number,
+ message = EXCLUDED.message, threshold_bytes = EXCLUDED.threshold_bytes,
+ margin_pct = EXCLUDED.margin_pct, reset_hour = EXCLUDED.reset_hour,
+ reset_minute = EXCLUDED.reset_minute, timezone = EXCLUDED.timezone,
+ cooldown_minutes = EXCLUDED.cooldown_minutes, updated_at = NOW()`,
+ [req.params.id, req.params.iface, b.enabled === true, phone,
+ String(b.message ?? ''), Math.floor(threshold),
+ clamp(b.margin_pct, 0, 50, 5), clamp(b.reset_hour, 0, 23, 0),
+ clamp(b.reset_minute, 0, 59, 0), tz, clamp(b.cooldown_minutes, 1, 1440, 60)]
+ );
+ res.json({ message: 'Data-cap rule saved' });
+ } catch (error) {
+ console.error('Error saving data-cap rule:', error);
+ res.status(500).json({ error: 'Failed to save rule' });
+ }
+});
+
+/**
+ * POST /api/devices/:id/lte/data-cap/:iface/send — send the SMS now.
+ *
+ * Deliberately independent of the rule being enabled, or of any threshold. This
+ * is the half of the feature that is trustworthy: the automatic trigger depends
+ * on a usage figure we reconstruct and therefore undercount, whereas pressing
+ * the button does exactly one thing for a reason the operator already knows.
+ */
+router.post('/:id/lte/data-cap/:iface/send', requireWrite, async (req: Request, res: Response) => {
+ const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
+ if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
+
+ const rule = await queryOne<{ phone_number: string; message: string; period_bytes: string }>(
+ `SELECT phone_number, message, period_bytes FROM lte_data_cap_rules
+ WHERE device_id = $1 AND interface_name = $2`,
+ [req.params.id, req.params.iface]
+ );
+ const phone = String((req.body as { phone_number?: string })?.phone_number || rule?.phone_number || '').trim();
+ if (!phone) return res.status(400).json({ error: 'No phone number configured for this interface' });
+
+ // Construction is inside the try on purpose: DeviceCollector decrypts the
+ // stored credential in its constructor, so a corrupted or missing one throws
+ // before a single line of the body runs. Left outside, that surfaced as a raw
+ // 500 with an internal message and no record of the attempt.
+ let collector: DeviceCollector | null = null;
+ try {
+ collector = new DeviceCollector(deviceRow);
+ await collector.connect();
+ await collector.sendSms(req.params.iface, phone, rule?.message ?? '');
+ await query(
+ `INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, period_bytes, ok)
+ VALUES ($1,$2,'manual',$3,$4,TRUE)`,
+ [deviceRow.id, req.params.iface, phone, rule ? Number(rule.period_bytes) : null]
+ ).catch(() => {});
+ await query(
+ `UPDATE lte_data_cap_rules SET last_sent_at = NOW()
+ WHERE device_id = $1 AND interface_name = $2`,
+ [deviceRow.id, req.params.iface]
+ ).catch(() => {});
+ res.json({ message: `SMS sent to ${phone}.` });
+ } catch (error) {
+ const raw = (error as Error).message;
+ // Credential problems are ours, not the modem's, and saying "could not send
+ // SMS: invalid encrypted text format" would send someone hunting the wrong
+ // fault entirely.
+ const msg = /encrypt|decrypt/i.test(raw)
+ ? 'stored credentials for this device could not be read'
+ : raw;
+ await query(
+ `INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, ok, error)
+ VALUES ($1,$2,'manual',$3,FALSE,$4)`,
+ [deviceRow.id, req.params.iface, phone, msg]
+ ).catch(() => {});
+ console.error('Error sending data-cap SMS:', raw);
+ res.status(502).json({ error: `Could not send SMS: ${msg}` });
+ } finally {
+ collector?.disconnect();
+ }
+});
+
// GET /api/devices/:id/lte/history — handovers, re-registrations and band changes
router.get('/:id/lte/history', async (req: Request, res: Response) => {
const { range = '24h', limit = '200' } = req.query as { range?: string; limit?: string };
diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts
index 4cd9df1..0192bdf 100644
--- a/backend/src/services/mikrotik/DeviceCollector.ts
+++ b/backend/src/services/mikrotik/DeviceCollector.ts
@@ -16,6 +16,7 @@ import {
import {
parseLteMonitor, overallQuality, detectChanges, type LteStatus,
} from '../../utils/lte';
+import { bytesSince, periodKey, shouldSend } from '../../utils/dataCap';
import { BackupService } from '../BackupService';
import { alertService } from '../AlertService';
@@ -2850,6 +2851,33 @@ export class DeviceCollector {
}
}
+ /**
+ * Send an SMS through the modem.
+ *
+ * Used for carrier data-cap resets, where texting a short code lifts a daily
+ * throttle. The message is deliberately allowed to be empty — that is exactly
+ * what several carriers expect, and an API that refused it would be useless
+ * for the case it exists to serve.
+ */
+ async sendSms(port: string, phoneNumber: string, message = ''): Promise {
+ await this.client.execute('/tool/sms/send', {
+ port, 'phone-number': phoneNumber, message,
+ });
+ }
+
+ /** Current byte counters for one interface, for data-cap accounting. */
+ async getInterfaceCounters(name: string): Promise<{ rxBytes: number; txBytes: number } | null> {
+ const rows = await this.client
+ .execute('/interface/print', { stats: '', '?name': name })
+ .catch(() => [] as Record[]);
+ const row = rows.find((r) => r['name'] === name) || rows[0];
+ if (!row) return null;
+ return {
+ rxBytes: parseInt(row['rx-byte'] || '0', 10),
+ txBytes: parseInt(row['tx-byte'] || '0', 10),
+ };
+ }
+
// ─── LTE / cellular → Postgres + InfluxDB ─────────────────────────────────
/**
@@ -2945,6 +2973,7 @@ export class DeviceCollector {
);
await this.recordObservedBands(name, status);
+ await this.trackDataCap(name);
// Signal series. Fields are written only when the modem reported them —
// a zero here would read as a signal no transmitter produces.
@@ -2980,6 +3009,93 @@ export class DeviceCollector {
}
}
+ /**
+ * Accumulate cellular usage and fire the carrier's reset SMS when due.
+ *
+ * Runs on every LTE poll rather than on a timer of its own, because the byte
+ * delta is only meaningful between consecutive samples — a separate schedule
+ * would either double-count or miss traffic depending on how the two drifted.
+ *
+ * Failures here are logged and swallowed. A data-cap rule is a convenience;
+ * it must never be able to break the collection it rides on.
+ */
+ private async trackDataCap(interfaceName: string): Promise {
+ try {
+ const rule = await queryOne<{
+ id: number; enabled: boolean; phone_number: string; message: string;
+ threshold_bytes: string; margin_pct: number; reset_hour: number; reset_minute: number;
+ timezone: string; cooldown_minutes: number; period_key: string | null;
+ period_bytes: string; last_rx: string | null; last_tx: string | null;
+ last_sent_at: string | null;
+ }>(
+ `SELECT * FROM lte_data_cap_rules WHERE device_id = $1 AND interface_name = $2`,
+ [this.device.id, interfaceName]
+ );
+ if (!rule) return;
+
+ const counters = await this.getInterfaceCounters(interfaceName);
+ if (!counters) return;
+
+ const now = new Date();
+ const key = periodKey(now, rule.reset_hour, rule.reset_minute, rule.timezone);
+
+ // A new period starts fresh, but the counter baseline carries over —
+ // otherwise the first sample after midnight would be discarded and the
+ // traffic between it and the previous poll would vanish.
+ const rolled = rule.period_key !== key;
+ const prev = rule.last_rx == null || rule.last_tx == null
+ ? null
+ : { rxBytes: Number(rule.last_rx), txBytes: Number(rule.last_tx) };
+
+ const delta = bytesSince(prev, counters);
+ const periodBytes = (rolled ? 0 : Number(rule.period_bytes)) + delta;
+
+ const decision = shouldSend({
+ periodBytes,
+ thresholdBytes: Number(rule.threshold_bytes),
+ marginPct: rule.margin_pct,
+ lastSentAt: rolled ? null : (rule.last_sent_at ? new Date(rule.last_sent_at) : null),
+ cooldownMinutes: rule.cooldown_minutes,
+ enabled: rule.enabled,
+ }, now);
+
+ let sentAt: Date | null = rolled ? null : (rule.last_sent_at ? new Date(rule.last_sent_at) : null);
+ if (decision.send) {
+ try {
+ await this.sendSms(interfaceName, rule.phone_number, rule.message);
+ sentAt = now;
+ await this.logDataCapSend(interfaceName, 'threshold', rule.phone_number, periodBytes, true, null);
+ console.log(`[DataCap] ${this.device.name}/${interfaceName}: reset SMS sent to ${rule.phone_number}`);
+ } catch (e) {
+ await this.logDataCapSend(interfaceName, 'threshold', rule.phone_number, periodBytes, false, (e as Error).message);
+ console.error(`[DataCap] ${this.device.name}/${interfaceName}: SMS failed:`, (e as Error).message);
+ }
+ }
+
+ await query(
+ `UPDATE lte_data_cap_rules
+ SET period_key = $2, period_bytes = $3, last_rx = $4, last_tx = $5,
+ last_sent_at = $6, updated_at = NOW()
+ WHERE id = $1`,
+ [rule.id, key, periodBytes, counters.rxBytes, counters.txBytes, sentAt]
+ );
+ } catch (e) {
+ console.error(`[DataCap] ${this.device.name}: tracking failed:`, (e as Error).message);
+ }
+ }
+
+ private async logDataCapSend(
+ interfaceName: string, trigger: string, phone: string,
+ periodBytes: number, ok: boolean, error: string | null,
+ ): Promise {
+ await query(
+ `INSERT INTO lte_data_cap_sends
+ (device_id, interface_name, trigger, phone_number, period_bytes, ok, error)
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`,
+ [this.device.id, interfaceName, trigger, phone, periodBytes, ok, error]
+ ).catch(() => {});
+ }
+
/** Last stored state for an interface, for comparing consecutive polls. */
private async previousLteStatus(name: string): Promise {
const row = await queryOne<{
diff --git a/backend/src/utils/__tests__/dataCap.test.ts b/backend/src/utils/__tests__/dataCap.test.ts
new file mode 100644
index 0000000..664dbe7
--- /dev/null
+++ b/backend/src/utils/__tests__/dataCap.test.ts
@@ -0,0 +1,99 @@
+import { bytesSince, periodKey, effectiveThreshold, shouldSend } from '../dataCap';
+
+const GB = 1024 ** 3;
+
+describe('bytesSince', () => {
+ it('establishes a baseline on first sight rather than counting everything', () => {
+ expect(bytesSince(null, { rxBytes: 5_000, txBytes: 1_000 })).toBe(0);
+ });
+
+ it('sums both directions, as the carrier does', () => {
+ expect(bytesSince({ rxBytes: 100, txBytes: 50 }, { rxBytes: 400, txBytes: 150 })).toBe(400);
+ });
+
+ it('treats a counter that went backwards as a reboot, not negative usage', () => {
+ // Confirmed on the target hardware: LTE byte counters do not survive a reboot.
+ expect(bytesSince({ rxBytes: 9_000, txBytes: 9_000 }, { rxBytes: 10, txBytes: 5 })).toBe(15);
+ });
+
+ it('handles one direction resetting while the other does not', () => {
+ expect(bytesSince({ rxBytes: 9_000, txBytes: 100 }, { rxBytes: 10, txBytes: 400 })).toBe(310);
+ });
+
+ it('ignores nonsense counters instead of poisoning the total', () => {
+ expect(bytesSince({ rxBytes: 100, txBytes: 100 }, { rxBytes: NaN, txBytes: -5 })).toBe(0);
+ });
+});
+
+describe('periodKey', () => {
+ const SOFIA = 'Europe/Sofia'; // UTC+3 in September
+
+ it('groups an instant into the local day', () => {
+ expect(periodKey(new Date('2026-09-02T09:00:00Z'), 0, 0, SOFIA)).toBe('2026-09-02');
+ });
+
+ it('puts the hour before local midnight in the previous period', () => {
+ // 21:30 UTC is 00:30 on the 3rd in Sofia — a new period.
+ expect(periodKey(new Date('2026-09-02T21:30:00Z'), 0, 0, SOFIA)).toBe('2026-09-03');
+ // 20:30 UTC is 23:30 on the 2nd — still the old one.
+ expect(periodKey(new Date('2026-09-02T20:30:00Z'), 0, 0, SOFIA)).toBe('2026-09-02');
+ });
+
+ it('respects a non-midnight reset time', () => {
+ // Reset at 04:00 local: 02:00 local still belongs to the previous day.
+ expect(periodKey(new Date('2026-09-02T23:00:00Z'), 4, 0, SOFIA)).toBe('2026-09-02');
+ expect(periodKey(new Date('2026-09-03T01:30:00Z'), 4, 0, SOFIA)).toBe('2026-09-03');
+ });
+
+ it('rolls the month and year correctly', () => {
+ expect(periodKey(new Date('2027-01-01T00:30:00Z'), 4, 0, 'UTC')).toBe('2026-12-31');
+ });
+
+ it('falls back to UTC for an unknown zone rather than throwing', () => {
+ expect(periodKey(new Date('2026-09-02T09:00:00Z'), 0, 0, 'Mars/Base')).toBe('2026-09-02');
+ });
+});
+
+describe('effectiveThreshold', () => {
+ it('fires below the configured allowance, because our count undercounts', () => {
+ expect(effectiveThreshold(10 * GB, 5)).toBe(Math.floor(10 * GB * 0.95));
+ });
+ it('allows no margin when asked', () => {
+ expect(effectiveThreshold(10 * GB, 0)).toBe(10 * GB);
+ });
+ it('refuses an absurd margin', () => {
+ expect(effectiveThreshold(10 * GB, 90)).toBe(effectiveThreshold(10 * GB, 50));
+ });
+});
+
+describe('shouldSend', () => {
+ const base = {
+ thresholdBytes: 10 * GB, marginPct: 5, cooldownMinutes: 60,
+ lastSentAt: null as Date | null, enabled: true,
+ };
+ const NOW = new Date('2026-09-02T12:00:00Z');
+
+ it('stays quiet below the threshold', () => {
+ expect(shouldSend({ ...base, periodBytes: 5 * GB }, NOW).reason).toBe('below-threshold');
+ });
+
+ it('fires at the margin-adjusted threshold, not the raw one', () => {
+ expect(shouldSend({ ...base, periodBytes: 9.6 * GB }, NOW).send).toBe(true);
+ });
+
+ it('will not fire again inside the cooldown', () => {
+ // Usage sits *at* the cap once reached; without this it would text on every poll.
+ const justSent = new Date(NOW.getTime() - 10 * 60_000);
+ const d = shouldSend({ ...base, periodBytes: 11 * GB, lastSentAt: justSent }, NOW);
+ expect(d).toEqual({ send: false, reason: 'cooling-down' });
+ });
+
+ it('fires again once the cooldown has passed', () => {
+ const old = new Date(NOW.getTime() - 90 * 60_000);
+ expect(shouldSend({ ...base, periodBytes: 11 * GB, lastSentAt: old }, NOW).send).toBe(true);
+ });
+
+ it('does nothing when disabled, whatever the usage', () => {
+ expect(shouldSend({ ...base, periodBytes: 99 * GB, enabled: false }, NOW).reason).toBe('disabled');
+ });
+});
diff --git a/backend/src/utils/dataCap.ts b/backend/src/utils/dataCap.ts
new file mode 100644
index 0000000..7d315e9
--- /dev/null
+++ b/backend/src/utils/dataCap.ts
@@ -0,0 +1,118 @@
+/**
+ * Daily data-cap tracking for cellular links, and when to trigger the SMS that
+ * lifts an operator's throttle.
+ *
+ * Several European carriers sell "unlimited" mobile data that is throttled hard
+ * once a daily allowance is used, and lift the throttle when the subscriber
+ * texts a short code. The counter that matters lives on the carrier's side and
+ * resets at local midnight; ours is a reconstruction, and the gap between the
+ * two is what this module has to be honest about.
+ *
+ * **Our figure is always an undercount.** Interface byte counters do not survive
+ * a reboot — confirmed on the hardware this was built for — so anything used
+ * before the device was adopted, during a reboot, or between a missed poll and
+ * the next one is invisible to us. Undercounting means triggering *late*, and
+ * late means the user is already throttled. So thresholds are meant to be set
+ * below the real allowance, and the default margin below does that for them.
+ *
+ * The saving grace is that the carrier resets daily too, so the error cannot
+ * accumulate past one period.
+ */
+
+/** Percentage of the configured threshold at which we actually fire. */
+export const DEFAULT_SAFETY_MARGIN_PCT = 5;
+
+export interface CounterSample {
+ rxBytes: number;
+ txBytes: number;
+}
+
+/**
+ * Bytes used since the previous sample.
+ *
+ * A counter that has gone *backwards* means the device rebooted and zeroed it.
+ * The correct reading then is the new counter itself — everything before the
+ * reboot is unrecoverable, and treating the drop as negative usage would corrupt
+ * the running total for the rest of the day.
+ */
+export function bytesSince(prev: CounterSample | null, cur: CounterSample): number {
+ const safe = (n: number) => (Number.isFinite(n) && n >= 0 ? n : 0);
+ const rx = safe(cur.rxBytes);
+ const tx = safe(cur.txBytes);
+ if (!prev) return 0; // first sight establishes a baseline only
+ const dRx = rx < safe(prev.rxBytes) ? rx : rx - safe(prev.rxBytes);
+ const dTx = tx < safe(prev.txBytes) ? tx : tx - safe(prev.txBytes);
+ return dRx + dTx;
+}
+
+/**
+ * Which accounting period an instant falls in, as a local date string.
+ *
+ * Comparing period keys is how a reset is detected, and it sidesteps converting
+ * a local wall-clock time back into UTC — which is where naive implementations
+ * get daylight saving wrong. Before the reset hour, the instant still belongs to
+ * the previous day's period.
+ */
+export function periodKey(now: Date, resetHour: number, resetMinute: number, timeZone: string): string {
+ let y: number, m: number, d: number, hh: number, mm: number;
+ try {
+ const parts = Object.fromEntries(
+ new Intl.DateTimeFormat('en-CA', {
+ timeZone, hour12: false,
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
+ }).formatToParts(now).map((p) => [p.type, p.value])
+ );
+ y = Number(parts.year); m = Number(parts.month); d = Number(parts.day);
+ hh = Number(parts.hour) % 24; mm = Number(parts.minute);
+ } catch {
+ y = now.getUTCFullYear(); m = now.getUTCMonth() + 1; d = now.getUTCDate();
+ hh = now.getUTCHours(); mm = now.getUTCMinutes();
+ }
+
+ // Before the reset time, we are still in yesterday's period.
+ if (hh * 60 + mm < resetHour * 60 + resetMinute) {
+ const shifted = new Date(Date.UTC(y, m - 1, d));
+ shifted.setUTCDate(shifted.getUTCDate() - 1);
+ y = shifted.getUTCFullYear(); m = shifted.getUTCMonth() + 1; d = shifted.getUTCDate();
+ }
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
+}
+
+/** Bytes at which we actually fire, given a configured allowance. */
+export function effectiveThreshold(thresholdBytes: number, marginPct = DEFAULT_SAFETY_MARGIN_PCT): number {
+ const pct = Math.min(50, Math.max(0, marginPct));
+ return Math.max(0, Math.floor(thresholdBytes * (1 - pct / 100)));
+}
+
+export interface TriggerState {
+ periodBytes: number;
+ thresholdBytes: number;
+ marginPct?: number;
+ lastSentAt: Date | null;
+ cooldownMinutes: number;
+ enabled: boolean;
+}
+
+export interface TriggerDecision {
+ send: boolean;
+ reason: 'below-threshold' | 'cooling-down' | 'disabled' | 'threshold-reached';
+}
+
+/**
+ * Should an automatic SMS go out right now?
+ *
+ * The cooldown exists because usage sits *at* the cap once reached, not above
+ * it — without one, a link parked on its limit would text the carrier on every
+ * poll. Sending twice is a nuisance; sending forty times is an incident.
+ */
+export function shouldSend(state: TriggerState, now: Date): TriggerDecision {
+ if (!state.enabled) return { send: false, reason: 'disabled' };
+ if (state.periodBytes < effectiveThreshold(state.thresholdBytes, state.marginPct)) {
+ return { send: false, reason: 'below-threshold' };
+ }
+ if (state.lastSentAt) {
+ const elapsedMin = (now.getTime() - state.lastSentAt.getTime()) / 60_000;
+ if (elapsedMin < state.cooldownMinutes) return { send: false, reason: 'cooling-down' };
+ }
+ return { send: true, reason: 'threshold-reached' };
+}
diff --git a/frontend/package.json b/frontend/package.json
index 9edc3c1..f3e8636 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.22-beta",
+ "version": "0.23.23-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/device-detail/DataCapCard.tsx b/frontend/src/components/device-detail/DataCapCard.tsx
new file mode 100644
index 0000000..db01eb9
--- /dev/null
+++ b/frontend/src/components/device-detail/DataCapCard.tsx
@@ -0,0 +1,227 @@
+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';
+
+/**
+ * Daily data-cap SMS for carriers that throttle "unlimited" plans.
+ *
+ * The manual button is the trustworthy half and is therefore always available,
+ * independent of the rule being enabled: pressing it does one known thing for a
+ * reason the operator already has. The automatic trigger rests on a usage figure
+ * we reconstruct from polling, which cannot see traffic during a reboot or
+ * before the device was adopted — so it always reads low, and the margin exists
+ * to compensate (discussion #85).
+ */
+
+const GB = 1024 ** 3;
+const fmtGB = (bytes: number) => (bytes / GB).toFixed(2);
+
+export default function DataCapCard({ deviceId, interfaceName }: { deviceId: number; interfaceName: string }) {
+ const queryClient = useQueryClient();
+ const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
+
+ const { data } = useQuery({
+ queryKey: ['data-cap', deviceId],
+ queryFn: () => dataCapApi.get(deviceId).then(r => r.data),
+ refetchInterval: 60_000,
+ });
+
+ const rule = data?.rules.find(r => r.interface_name === interfaceName);
+ const sends = (data?.sends ?? []).filter(s => s.interface_name === interfaceName);
+
+ const [form, setForm] = useState({
+ enabled: false, phone_number: '', thresholdGB: '10', margin_pct: 5,
+ reset_hour: 0, reset_minute: 0,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ cooldown_minutes: 60,
+ });
+
+ useEffect(() => {
+ if (!rule) return;
+ setForm({
+ enabled: rule.enabled,
+ phone_number: rule.phone_number,
+ thresholdGB: (Number(rule.threshold_bytes) / GB).toString(),
+ margin_pct: rule.margin_pct,
+ reset_hour: rule.reset_hour,
+ reset_minute: rule.reset_minute,
+ timezone: rule.timezone,
+ cooldown_minutes: rule.cooldown_minutes,
+ });
+ }, [rule?.id, rule?.updated_at]);
+
+ const save = useMutation({
+ mutationFn: () => dataCapApi.save(deviceId, interfaceName, {
+ enabled: form.enabled,
+ phone_number: form.phone_number,
+ message: '',
+ threshold_bytes: Math.round(Number(form.thresholdGB) * GB),
+ margin_pct: form.margin_pct,
+ reset_hour: form.reset_hour,
+ reset_minute: form.reset_minute,
+ timezone: form.timezone,
+ cooldown_minutes: form.cooldown_minutes,
+ }),
+ onSuccess: (r) => {
+ setResult({ ok: true, text: r.data.message });
+ queryClient.invalidateQueries({ queryKey: ['data-cap', deviceId] });
+ },
+ onError: (e: unknown) => setResult({
+ ok: false,
+ text: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Could not save',
+ }),
+ });
+
+ const sendNow = useMutation({
+ mutationFn: () => dataCapApi.sendNow(deviceId, interfaceName),
+ onSuccess: (r) => {
+ setResult({ ok: true, text: r.data.message });
+ queryClient.invalidateQueries({ queryKey: ['data-cap', deviceId] });
+ },
+ onError: (e: unknown) => setResult({
+ ok: false,
+ text: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Could not send',
+ }),
+ });
+
+ const used = rule ? Number(rule.period_bytes) : 0;
+ const cap = Number(form.thresholdGB) * GB || 0;
+ const firesAt = cap * (1 - form.margin_pct / 100);
+ const pct = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
+
+ return (
+
+
+
+
+
Data-cap reset SMS
+
+ Some carriers throttle an “unlimited” plan past a daily allowance and lift it when you
+ text a short code. This tracks usage on {interfaceName} and
+ can send that message for you.
+
+
+
+
+ {/* Usage so far this period */}
+ {rule && (
+
+ 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.
+
+
+ )}
+
+ {/* Settings */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Always available: this half does not depend on our usage estimate. */}
+
+
+
+ {result && (
+
+ {result.text}
+
+ )}
+
+ {!rule && (
+
+
+
+ The modem needs SMS enabled — check /tool/sms/print reports
+ receive-enabled: yes. If sent messages are kept on the SIM they
+ will eventually fill it, and sending then fails quietly.
+
+