Skip to content

23 — Graphs

Table of Contents

  1. Overview
  2. Graph Types
  3. GraphController
  4. graph_targets Table
  5. PollTraffic Command
  6. customer_traffic_readings Table
  7. RecordSysStats Command
  8. sys_stats_history Table
  9. signal_readings Table and SignalReading Model
  10. Graphs/Index.jsx
  11. Time Range Options
  12. How to Add a New Graph Target
  13. 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)

$schedule->command('traffic:poll')->everyMinute()->withoutOverlapping();

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

$schedule->command('stats:record')->everyFiveMinutes()->withoutOverlapping();

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):

/system/resource/print

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)