23 — Graphs¶
Table of Contents¶
- Overview
- Graph Types
- GraphController
- graph_targets Table
- PollTraffic Command
- customer_traffic_readings Table
- RecordSysStats Command
- sys_stats_history Table
- signal_readings Table and SignalReading Model
- Graphs/Index.jsx
- Time Range Options
- How to Add a New Graph Target
- Data Retention and Pruning
Overview¶
The Graphs module provides time-series visualisations of network and system metrics. It is built on a polling architecture: background artisan commands periodically query RouterOS devices (or read system stats) and store readings into PostgreSQL tables. The frontend reads these historical records and renders them using the Recharts library.
[RouterOS NAS] ←── API query ── PollTraffic (every 1 min)
│
▼
customer_traffic_readings
[Linux Server] ──── sys_info ── RecordSysStats (every 5 min)
│
▼
sys_stats_history
[RouterOS NAS] ←── API query ── PollSignal (per-customer)
│
▼
signal_readings
All three tables ──► GraphController ──► Graphs/Index.jsx (Recharts)
Graph Types¶
1. Bandwidth Usage Over Time¶
Shows upload and download traffic for a specific customer or NAS interface over a selected time range. Rendered as a dual-line or area chart.
Source table: customer_traffic_readings
X-axis: Timestamp
Y-axis: Mbps (or KB/s — converted from raw bytes)
Series: Upload (tx), Download (rx)
2. Signal Strength Over Time (RX Power)¶
Shows the optical or wireless signal strength (RX power in dBm) for a customer's CPE device over time. Used to diagnose physical layer issues.
Source table: signal_readings
X-axis: Timestamp
Y-axis: dBm
Series: RX Power
3. System Stats — CPU / RAM / Disk¶
Shows the PyroRadius application server's own resource utilisation over time.
Source table: sys_stats_history
X-axis: Timestamp
Y-axis: Percentage (0–100) or bytes (for disk/RAM)
Series: CPU %, RAM Used, Disk Used, Queue Depth
GraphController¶
Class: App\Http\Controllers\GraphController
Route prefix: /admin/graphs
index(): Response¶
Renders the Graphs/Index.jsx page. Passes minimal bootstrapping props (list of NAS devices, list of customers for the customer selector) without loading any time-series data upfront.
public function index(): Response
{
return Inertia::render('Graphs/Index', [
'nas_list' => Nas::select('id', 'name', 'host')->where('enabled', true)->get(),
'dealers' => auth()->user()->isSuperAdmin()
? Dealer::select('id', 'name')->orderBy('name')->get()
: null,
]);
}
trafficData(Request $request): JsonResponse¶
Returns traffic readings for a given customer and time range. Called via router.get() from the frontend when the user selects a customer and time range.
public function trafficData(Request $request): JsonResponse
{
$request->validate([
'customer_id' => 'required|exists:customers,id',
'range' => 'required|in:1h,6h,24h,7d,30d',
]);
$from = $this->rangeToTimestamp($request->range);
$readings = CustomerTrafficReading::where('customer_id', $request->customer_id)
->where('recorded_at', '>=', $from)
->orderBy('recorded_at')
->get(['recorded_at', 'rx_bytes', 'tx_bytes']);
return response()->json([
'labels' => $readings->pluck('recorded_at')->map(fn($t) => $t->toIso8601String()),
'rx' => $readings->pluck('rx_bytes')->map(fn($b) => round($b / 1_000_000, 2)), // MB
'tx' => $readings->pluck('tx_bytes')->map(fn($b) => round($b / 1_000_000, 2)), // MB
]);
}
sysStats(Request $request): JsonResponse¶
Returns system stat readings for a given time range.
public function sysStats(Request $request): JsonResponse
{
$from = $this->rangeToTimestamp($request->input('range', '24h'));
$readings = SysStatsHistory::where('recorded_at', '>=', $from)
->orderBy('recorded_at')
->get(['recorded_at', 'cpu_percent', 'ram_used_mb', 'ram_total_mb', 'disk_used_gb', 'disk_total_gb', 'queue_depth']);
return response()->json([
'labels' => $readings->pluck('recorded_at')->map(fn($t) => $t->toIso8601String()),
'cpu' => $readings->pluck('cpu_percent'),
'ram_percent' => $readings->map(fn($r) => $r->ram_total_mb > 0
? round($r->ram_used_mb / $r->ram_total_mb * 100, 1) : 0),
'disk_percent'=> $readings->map(fn($r) => $r->disk_total_gb > 0
? round($r->disk_used_gb / $r->disk_total_gb * 100, 1) : 0),
'queue_depth' => $readings->pluck('queue_depth'),
]);
}
signalData(Request $request): JsonResponse¶
Returns RX power readings for a given customer.
public function signalData(Request $request): JsonResponse
{
$from = $this->rangeToTimestamp($request->input('range', '24h'));
$readings = SignalReading::where('customer_id', $request->customer_id)
->where('recorded_at', '>=', $from)
->orderBy('recorded_at')
->get(['recorded_at', 'rx_power']);
return response()->json([
'labels' => $readings->pluck('recorded_at')->map(fn($t) => $t->toIso8601String()),
'rx_power' => $readings->pluck('rx_power'),
]);
}
rangeToTimestamp() Helper¶
private function rangeToTimestamp(string $range): Carbon
{
return match ($range) {
'1h' => now()->subHour(),
'6h' => now()->subHours(6),
'24h' => now()->subDay(),
'7d' => now()->subWeek(),
'30d' => now()->subMonth(),
default => now()->subDay(),
};
}
graph_targets Table¶
Controls which NAS devices and metrics are actively polled. Allows granular on/off control without disabling entire NAS devices.
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
nas_id |
bigint (FK) | The NAS this target relates to |
metric |
varchar | The metric being tracked (e.g., pppoe_traffic, interface_traffic, signal) |
interface |
varchar nullable | For interface-level metrics, the interface name (e.g., ether1) |
enabled |
boolean | Whether polling is active for this target |
poll_interval_seconds |
integer | How often to poll (default 60) — currently informational; actual interval is set by cron |
created_at |
timestamp | — |
updated_at |
timestamp | — |
Metric Values¶
| Metric | What Is Polled |
|---|---|
pppoe_traffic |
Per-customer PPPoE session traffic counters |
interface_traffic |
NAS interface byte counters (aggregate) |
signal |
OLT/wireless signal readings per customer |
system_stats |
Server CPU/RAM/disk (NAS-independent) |
CRUD Management¶
Graph targets are managed via the Health page under Health → Graph Targets (or a dedicated settings area depending on deployment). CronJobController manages scheduled tasks; graph target CRUD uses a dedicated inline form on the Graphs settings page.
PollTraffic Command¶
Class: App\Console\Commands\PollTraffic
Schedule: Every 1 minute (registered in app/Console/Kernel.php)
handle() Method Flow¶
1. Load all enabled graph_targets where metric = 'pppoe_traffic'
2. For each target's NAS:
a. Connect via RouterOS API (RouterOsService)
b. Run /ppp/active/print to get active PPPoE sessions
c. For each active session, match to a Customer by username
d. Read tx-byte and rx-byte counters from the session record
e. Insert or update customer_traffic_readings
3. Load all enabled graph_targets where metric = 'interface_traffic'
4. For each target:
a. Run /interface/print stats once=yes on the target NAS
b. Read the specified interface's tx-byte and rx-byte
c. Store in a separate NAS-level traffic readings table (if implemented)
5. Update Redis:
a. Cache::put('online_now_nas_{id}', count, 5 min)
b. Cache::put('online_now_total', total, 5 min)
c. Cache::put('online_now_updated_at', now(), 5 min)
Counter Delta Calculation¶
RouterOS reports cumulative byte counters that reset when a PPPoE session reconnects. To get per-interval throughput:
$prevReading = CustomerTrafficReading::where('customer_id', $customerId)
->orderByDesc('recorded_at')
->first();
if ($prevReading && $currentRx >= $prevReading->rx_bytes) {
$deltaRx = $currentRx - $prevReading->rx_bytes;
$deltaTx = $currentTx - $prevReading->tx_bytes;
} else {
// Session reconnected — counter reset; just store the new absolute value
$deltaRx = $currentRx;
$deltaTx = $currentTx;
}
customer_traffic_readings Table¶
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
customer_id |
bigint (FK) | Owning customer |
nas_id |
bigint (FK) | NAS where the session was active |
rx_bytes |
bigint | Download bytes in this interval |
tx_bytes |
bigint | Upload bytes in this interval |
rx_cumulative |
bigint | Raw cumulative counter from RouterOS |
tx_cumulative |
bigint | Raw cumulative counter from RouterOS |
session_uptime |
integer nullable | PPPoE session uptime in seconds |
recorded_at |
timestamp | When this reading was taken |
created_at |
timestamp | — |
Index Strategy¶
CREATE INDEX idx_traffic_customer_recorded ON customer_traffic_readings (customer_id, recorded_at DESC);
CREATE INDEX idx_traffic_nas_recorded ON customer_traffic_readings (nas_id, recorded_at DESC);
These indexes ensure the time-range queries used by GraphController::trafficData() are fast even with millions of rows.
RecordSysStats Command¶
Class: App\Console\Commands\RecordSysStats
Schedule: Every 5 minutes
handle() Method¶
Reads the application server's own resource usage using PHP built-in functions and system calls:
// CPU usage — read /proc/stat twice 100ms apart and calculate delta
$cpu = $this->readCpuPercent();
// RAM — read /proc/meminfo
$ram = $this->readRamUsage(); // returns ['used_mb' => ..., 'total_mb' => ...]
// Disk
$disk = [
'used_gb' => round((disk_total_space('/') - disk_free_space('/')) / 1e9, 2),
'total_gb' => round(disk_total_space('/') / 1e9, 2),
];
// Queue depth — count pending jobs in jobs table
$queueDepth = DB::table('jobs')->count();
SysStatsHistory::create([
'cpu_percent' => $cpu,
'ram_used_mb' => $ram['used_mb'],
'ram_total_mb' => $ram['total_mb'],
'disk_used_gb' => $disk['used_gb'],
'disk_total_gb'=> $disk['total_gb'],
'queue_depth' => $queueDepth,
'recorded_at' => now(),
]);
sys_stats_history Table¶
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
cpu_percent |
decimal(5,2) | CPU usage percentage (0.00–100.00) |
ram_used_mb |
integer | RAM used in megabytes |
ram_total_mb |
integer | Total RAM in megabytes |
disk_used_gb |
decimal(8,2) | Disk space used in gigabytes |
disk_total_gb |
decimal(8,2) | Total disk space in gigabytes |
queue_depth |
integer | Number of jobs in the jobs table at time of recording |
recorded_at |
timestamp | When the reading was taken |
created_at |
timestamp | — |
signal_readings Table and SignalReading Model¶
signal_readings Table¶
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
customer_id |
bigint (FK) | Customer whose CPE was read |
nas_id |
bigint (FK) | OLT or NAS device queried |
rx_power |
decimal(6,2) | Received optical/wireless power in dBm |
tx_power |
decimal(6,2) nullable | Transmitted power (if available) |
onu_id |
varchar nullable | ONU identifier on the OLT (for GPON deployments) |
interface |
varchar nullable | Interface or port identifier |
recorded_at |
timestamp | When the reading was taken |
created_at |
timestamp | — |
SignalReading Model¶
Class: App\Models\SignalReading
class SignalReading extends Model
{
protected $fillable = [
'customer_id', 'nas_id', 'rx_power', 'tx_power',
'onu_id', 'interface', 'recorded_at',
];
protected $casts = [
'recorded_at' => 'datetime',
'rx_power' => 'decimal:2',
'tx_power' => 'decimal:2',
];
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function nas(): BelongsTo
{
return $this->belongsTo(Nas::class);
}
// Scope: readings in a specific time range
public function scopeInRange(Builder $query, Carbon $from, Carbon $to): Builder
{
return $query->whereBetween('recorded_at', [$from, $to]);
}
// Scope: signal within acceptable GPON range
public function scopeGoodSignal(Builder $query): Builder
{
return $query->whereBetween('rx_power', [-27.00, -8.00]);
}
}
Signal Quality Thresholds (GPON Reference)¶
| Range (dBm) | Status | Colour in UI |
|---|---|---|
| -8 to 0 | Too Strong | Orange |
| -8 to -27 | Good | Green |
| -27 to -30 | Weak | Yellow |
| Below -30 | Critical | Red |
These thresholds are used in Graphs/Index.jsx to colour-code the signal graph's reference bands.
Graphs/Index.jsx¶
Path: resources/js/Pages/Graphs/Index.jsx
Component Structure¶
<GraphsIndex>
├── <PageHeader title="Network Graphs" />
│
├── <GraphTypeSelector>
│ ├── Customer Traffic
│ ├── Signal Strength
│ └── System Stats
│
├── <TimeRangeSelector> {/* 1h | 6h | 24h | 7d | 30d */}
│
├── <CustomerSelector> {/* search input, only for Traffic + Signal */}
│
├── <GraphCanvas>
│ ├── (Traffic) <ResponsiveContainer> → <AreaChart>
│ │ ├── <Area dataKey="rx" name="Download" fill="blue" />
│ │ └── <Area dataKey="tx" name="Upload" fill="green" />
│ │
│ ├── (Signal) <ResponsiveContainer> → <LineChart>
│ │ ├── <ReferenceLine y={-8} stroke="orange" label="Max" />
│ │ ├── <ReferenceLine y={-27} stroke="yellow" label="Min" />
│ │ └── <Line dataKey="rx_power" name="RX Power" stroke="blue" />
│ │
│ └── (SysStats) <ResponsiveContainer> → <LineChart>
│ ├── <Line dataKey="cpu" name="CPU %" stroke="red" />
│ ├── <Line dataKey="ram_percent" name="RAM %" stroke="purple" />
│ └── <Line dataKey="disk_percent" name="Disk %" stroke="gray" />
│
└── <GraphLegend />
Data Fetching¶
When the user selects a customer and time range, the component fires an Inertia router.get() with partial reload:
router.get(route('graphs.traffic-data'), {
customer_id: selectedCustomer,
range: selectedRange,
}, {
preserveState: true,
only: ['graphData'],
onSuccess: () => setLoading(false),
});
Chart Library¶
All charts use Recharts (recharts npm package). Key components used:
| Recharts Component | Used For |
|---|---|
ResponsiveContainer |
Makes chart fill its parent div |
AreaChart |
Traffic bandwidth (fills area under line) |
LineChart |
Signal strength and system stats |
XAxis |
Time axis with formatted tick labels |
YAxis |
Value axis with unit suffix |
Tooltip |
On-hover data popup |
Legend |
Series colour key |
ReferenceLine |
Signal threshold bands |
CartesianGrid |
Background grid |
Time Range Options¶
| Option | Label | Data Points (approx.) |
|---|---|---|
1h |
Last 1 Hour | ~60 (1/min traffic) or ~12 (5/min sys) |
6h |
Last 6 Hours | ~360 or ~72 |
24h |
Last 24 Hours | ~1,440 or ~288 |
7d |
Last 7 Days | ~10,080 or ~2,016 |
30d |
Last 30 Days | ~43,200 or ~8,640 |
For the 7d and 30d ranges, the backend applies time-bucket averaging to reduce data points and improve chart rendering performance:
// For 7d range, average to 15-minute buckets
if ($range === '7d') {
$readings = DB::select("
SELECT
DATE_TRUNC('hour', recorded_at) +
INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM recorded_at) / 15) AS bucket,
AVG(rx_bytes) AS rx_bytes,
AVG(tx_bytes) AS tx_bytes
FROM customer_traffic_readings
WHERE customer_id = :customer_id
AND recorded_at >= :from
GROUP BY bucket
ORDER BY bucket
", ['customer_id' => $customerId, 'from' => $from]);
}
How to Add a New Graph Target¶
Step 1 — Decide What to Poll¶
Determine the RouterOS API command or data source. For example, to poll per-interface CPU on a CHR (Cloud Hosted Router):
Step 2 — Create a graph_targets Record¶
Via the admin UI or a migration:
INSERT INTO graph_targets (nas_id, metric, interface, enabled, poll_interval_seconds, created_at, updated_at)
VALUES (1, 'chr_cpu', NULL, true, 60, NOW(), NOW());
Step 3 — Add Polling Logic to PollTraffic (or a New Command)¶
In PollTraffic::handle(), add a case for the new metric:
$chrTargets = GraphTarget::where('metric', 'chr_cpu')->where('enabled', true)->get();
foreach ($chrTargets as $target) {
$nas = $target->nas;
$result = $this->routerOs->command($nas, '/system/resource/print');
$cpu = $result[0]['cpu-load'] ?? null;
if ($cpu !== null) {
// Store in a new table or reuse sys_stats_history with a nas_id column
NasCpuReading::create([
'nas_id' => $nas->id,
'cpu_percent' => (float) $cpu,
'recorded_at' => now(),
]);
}
}
Step 4 — Add a GraphController Method¶
public function nasCpuData(Request $request): JsonResponse
{
$from = $this->rangeToTimestamp($request->input('range', '24h'));
$readings = NasCpuReading::where('nas_id', $request->nas_id)
->where('recorded_at', '>=', $from)
->orderBy('recorded_at')
->get(['recorded_at', 'cpu_percent']);
return response()->json([
'labels' => $readings->pluck('recorded_at')->map(fn($t) => $t->toIso8601String()),
'cpu' => $readings->pluck('cpu_percent'),
]);
}
Step 5 — Add the Chart in Graphs/Index.jsx¶
Add a new option to <GraphTypeSelector> and a new chart branch in <GraphCanvas>, following the existing pattern.
Data Retention and Pruning¶
Storing 1-minute readings for all customers across multiple NAS devices accumulates data quickly.
Retention Policy¶
| Table | Retention |
|---|---|
customer_traffic_readings |
30 days |
sys_stats_history |
90 days |
signal_readings |
60 days |
Prune Commands¶
# Prune traffic readings older than 30 days
php artisan prune:traffic-readings
# Prune sys stats older than 90 days
php artisan prune:sys-stats
# Prune signal readings older than 60 days
php artisan prune:signal-readings
These are scheduled via the cron_jobs table managed in the Health section, or registered directly in Kernel.php:
$schedule->command('prune:traffic-readings')->daily()->at('03:00');
$schedule->command('prune:sys-stats')->weekly();
$schedule->command('prune:signal-readings')->daily()->at('03:30');
Disk Estimate¶
At 1-minute polling for 500 active customers across 2 NAS devices, customer_traffic_readings grows at approximately:
500 customers × 1 row/min × 60 min × 24 hr = 720,000 rows/day
Each row ≈ 80 bytes → ~57 MB/day
30-day retention → ~1.7 GB steady state
PostgreSQL BRIN indexes are recommended for recorded_at columns on high-volume time-series tables to keep index size manageable.
Addendum: PyroGraphs is now a separate product (2026-07-19)¶
Everything above this line documents the legacy /graphs system (GraphController,
graph_targets, customer_traffic_readings) — still live, still in production, unrelated to
what follows.
PyroGraphs (branded PyroNMS in the UI) is an independent monitoring product on its own
subdomain, graphs.pyronet.com.pk. It has its own device catalog (MonitorDevice /
MonitorDeviceInterface), its own auth guard (monitor), and — as of the 2026-07-19 overnight
session — its own real SNMP polling pipeline. It is documented at a code level in
app/Http/Controllers/Monitor/*, routes/monitor.php, resources/js/Pages/Monitor/*. A
dedicated 24_PyroGraphs.md chapter is a recommended follow-up (not written yet — this addendum
is a stopgap so the state is captured somewhere authoritative).
Key architectural fact discovered 2026-07-19: PyroGraphs' GraphViewer page was rendering
100% client-side mock data — MonitorDevice/MonitorDeviceInterface had zero data-collection
pipeline. Only the legacy Nas-based system (nas:poll-ports above) was ever actually polled.
This has been fixed: see app/Console/Commands/PollMonitorInterfaces.php,
app/Http/Controllers/Monitor/InterfaceController::history(), and the new
monitor_interface_readings table. Full details in the 2026-07-19 overnight engineering report
(PyroRadius Claude/2026-07-19_overnight_pyrographs_session.md).
Current PyroGraphs module status (2026-07-19): | Module | Status | |---|---| | Auth, Dashboard, Gallery | Live, real | | Devices (CRUD, SNMP discovery) | Live, real | | Trees | Live, real | | Users & Permissions | Live, real | | GraphViewer | Live, real data (fixed 2026-07-19 — previously mock) | | Templates / Data Sources / Collectors | Built 2026-07-18, not yet approved for merge — see engineering report | | Export/Share (Phase 3), API/Mobile (Phase 4) | Not started |
Last updated: 2026-07-19 (legacy /graphs content last verified 2026-07-13)