diff --git a/README.md b/README.md index 35f458d..9b939c4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript 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 this period{rule.period_key ? ` (${rule.period_key})` : ''} + + + {fmtGB(used)} / {form.thresholdGB} GB + +
+
+
= firesAt ? 'bg-amber-500' : 'bg-blue-500'}`} + style={{ width: `${Math.max(1, pct)}%` }} + /> +
+

+ 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. + +
+ )} + + {sends.length > 0 && ( +
+

Recent sends

+
    + {sends.slice(0, 5).map((s, i) => ( +
  • + + {new Date(s.at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + + {s.trigger} + {s.period_bytes != null && ( + at {fmtGB(Number(s.period_bytes))} GB + )} + {s.ok + ? sent + : failed: {s.error}} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/device-detail/LteTab.tsx b/frontend/src/components/device-detail/LteTab.tsx index 84dd903..43ef5d8 100644 --- a/frontend/src/components/device-detail/LteTab.tsx +++ b/frontend/src/components/device-detail/LteTab.tsx @@ -5,6 +5,7 @@ import { } from 'recharts'; import { Signal, RadioTower, ArrowLeftRight, RotateCcw, Radio, ArrowUpFromLine } from 'lucide-react'; import { lteApi, type LteInterface, type LteHistoryEvent } from '../../services/api'; +import DataCapCard from './DataCapCard'; interface Props { deviceId: number; @@ -326,6 +327,11 @@ export default function LteTab({ deviceId }: Props) { ); })} + {/* Carrier data-cap SMS, per interface (discussion #85) */} + {interfaces.map(iface => ( + + ))} + {/* Signal over time */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d3ae180..9012733 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -340,6 +340,32 @@ export const pollerApi = { '/system/poller/drain'), }; + +export interface LteDataCapRule { + id: number; interface_name: string; 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_sent_at: string | null; updated_at: string; +} + +export interface LteDataCapSend { + interface_name: string; at: string; trigger: 'manual' | 'threshold'; + phone_number: string | null; period_bytes: string | null; + ok: boolean; error: string | null; +} + +export const dataCapApi = { + get: (deviceId: number) => + api.get<{ rules: LteDataCapRule[]; sends: LteDataCapSend[] }>(`/devices/${deviceId}/lte/data-cap`), + save: (deviceId: number, iface: string, body: Record) => + api.put<{ message: string }>(`/devices/${deviceId}/lte/data-cap/${encodeURIComponent(iface)}`, body), + sendNow: (deviceId: number, iface: string) => + api.post<{ message: string }>(`/devices/${deviceId}/lte/data-cap/${encodeURIComponent(iface)}/send`), +}; + export const devicesApi = { list: () => api.get('/devices'), discovered: () => api.get('/devices/discovered'), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index b691f1d..1b028cb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.22 Beta'; +export const APP_VERSION = 'v0.23.23 Beta';