From 753cddc80bf6a285fb18301c31f1807f816e6518 Mon Sep 17 00:00:00 2001 From: Mike Eiberg Date: Sat, 9 May 2026 19:04:49 +0800 Subject: [PATCH] luci-app-starlink-panel: add Starlink dashboard LuCI application that provides a Starlink dish status dashboard. Queries dish telemetry via starlink-dish (gRPC), displays connection state, latency, obstruction, alignment, alerts, outages, and IPv6 connectivity. Polls every 10 seconds via two parallel rpcd calls. Signed-off-by: Mike Eiberg --- applications/luci-app-starlink-panel/Makefile | 10 + .../resources/view/starlink-panel/status.css | 291 +++++++++ .../resources/view/starlink-panel/status.js | 556 ++++++++++++++++++ .../root/usr/libexec/rpcd/luci.starlink-panel | 169 ++++++ .../luci/menu.d/luci-app-starlink-panel.json | 13 + .../rpcd/acl.d/luci-app-starlink-panel.json | 15 + 6 files changed, 1054 insertions(+) create mode 100644 applications/luci-app-starlink-panel/Makefile create mode 100644 applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.css create mode 100644 applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.js create mode 100644 applications/luci-app-starlink-panel/root/usr/libexec/rpcd/luci.starlink-panel create mode 100644 applications/luci-app-starlink-panel/root/usr/share/luci/menu.d/luci-app-starlink-panel.json create mode 100644 applications/luci-app-starlink-panel/root/usr/share/rpcd/acl.d/luci-app-starlink-panel.json diff --git a/applications/luci-app-starlink-panel/Makefile b/applications/luci-app-starlink-panel/Makefile new file mode 100644 index 000000000000..f52a7f37047b --- /dev/null +++ b/applications/luci-app-starlink-panel/Makefile @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MIT +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI Starlink Status Dashboard +LUCI_DEPENDS:=+luci-base +rpcd +starlink-dish + +PKG_MAINTAINER:=Mike Eiberg +PKG_LICENSE:=MIT + +include ../../luci.mk diff --git a/applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.css b/applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.css new file mode 100644 index 000000000000..43df64a12fb7 --- /dev/null +++ b/applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.css @@ -0,0 +1,291 @@ +/* SPDX-License-Identifier: MIT */ + +:root { + --sl-border: rgba(0, 0, 0, 0.15); + --sl-subtle: rgba(0, 0, 0, 0.05); + --sl-muted: #555; + --sl-accent: #0550ae; + --sl-green: #1a7f37; + --sl-yellow: #9a6700; + --sl-red: #cf222e; +} + +@media (prefers-color-scheme: dark) { + :root { + --sl-border: rgba(255, 255, 255, 0.12); + --sl-subtle: rgba(255, 255, 255, 0.06); + --sl-muted: #8b949e; + --sl-accent: #58a6ff; + --sl-green: #3fb950; + --sl-yellow: #d29922; + --sl-red: #f85149; + } +} + +/* ── Layout ─────────────────────────────────────────────────────────────────── */ + +.sl-wrap { + font-family: inherit; + padding: 20px; + min-height: 400px; +} + +.sl-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--sl-border); +} + +.sl-title { + font-size: 1.3em; + font-weight: 700; + color: var(--sl-accent); + display: flex; + align-items: center; + gap: 8px; +} + +.sl-meta { + font-size: 0.8em; + color: var(--sl-muted); + display: flex; + align-items: center; + gap: 10px; +} + +.sl-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} + +/* ── Cards ──────────────────────────────────────────────────────────────────── */ + +.sl-card { + border: 1px solid var(--sl-border); + border-radius: 8px; + overflow: hidden; +} + +.sl-card-full { + grid-column: 1 / -1; +} + +.sl-card-hd { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--sl-border); + font-size: 0.88em; + font-weight: 600; + color: var(--sl-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.sl-card-icon { + font-size: 1.1em; +} + +.sl-card-bd { + padding: 12px 14px; +} + +/* ── Rows ───────────────────────────────────────────────────────────────────── */ + +.sl-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 0; + border-bottom: 1px solid var(--sl-border); + font-size: 0.88em; + gap: 8px; +} + +.sl-row:last-child { + border-bottom: none; +} + +.sl-lbl { + color: var(--sl-muted); + white-space: nowrap; +} + +.sl-val { + font-weight: 500; + text-align: right; + word-break: break-all; +} + +/* ── Big stat row ───────────────────────────────────────────────────────────── */ + +.sl-big-row { + display: flex; + justify-content: space-around; + padding: 10px 0; +} + +.sl-big-item { + text-align: center; +} + +.sl-big-num { + font-size: 1.5em; + font-weight: 700; +} + +.sl-big-lbl { + font-size: 0.75em; + color: var(--sl-muted); + margin-top: 2px; +} + +/* ── Misc ───────────────────────────────────────────────────────────────────── */ + +.sl-qdisc { + font-family: monospace; + font-size: 0.78em; + color: var(--sl-muted); + padding: 8px; + background: var(--sl-subtle); + border-radius: 4px; + margin-top: 10px; + word-break: break-all; +} + +.sl-na { + color: var(--sl-muted); + font-size: 0.85em; + font-style: italic; + text-align: center; + padding: 12px 0; +} + +.sl-note { + background: var(--sl-subtle); + border: 1px solid var(--sl-border); + border-left: 3px solid var(--sl-accent); + border-radius: 0 4px 4px 0; + padding: 10px 12px; + font-size: 0.82em; + color: var(--sl-muted); + margin-top: 8px; +} + +.sl-note code { + background: var(--sl-subtle); + padding: 1px 5px; + border-radius: 3px; + font-family: monospace; + color: var(--sl-accent); +} + +.sl-alert-row { + margin-top: 4px; + font-size: 0.85em; + color: var(--sl-yellow); +} + +/* ── Alignment card ─────────────────────────────────────────────────────────── */ + +.sl-align-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 4px 0; +} + +.sl-align-item { + text-align: center; + background: var(--sl-subtle); + border: 1px solid var(--sl-border); + border-radius: 6px; + padding: 12px 8px; +} + +.sl-align-val { + font-size: 1.4em; + font-weight: 700; + letter-spacing: -0.01em; +} + +.sl-align-lbl { + font-size: 0.78em; + color: var(--sl-muted); + margin-top: 4px; +} + +.sl-align-ok { + font-size: 1.1em; + font-weight: 600; + color: var(--sl-green); + text-align: center; + padding: 8px; +} + +/* ── Reboot button ──────────────────────────────────────────────────────────── */ + +.sl-reboot-btn { + width: 100%; + margin-top: 12px; + padding: 8px 0; + background: var(--sl-subtle); + border: 1px solid #f0883e; + color: #f0883e; + border-radius: 6px; + font-size: 0.88em; + font-weight: 600; + cursor: pointer; + letter-spacing: 0.03em; +} + +.sl-reboot-btn:hover { + background: rgba(240, 136, 62, 0.15); + border-color: #f0883e; +} + +.sl-reboot-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Alerts list ────────────────────────────────────────────────────────────── */ + +.sl-al-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} + +.sl-al-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + border-bottom: 1px solid var(--sl-border); + font-size: 0.87em; +} + +.sl-al-item:nth-child(odd):last-child { + grid-column: 1 / -1; +} + +.sl-al-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.sl-al-ok { background: var(--sl-green); } +.sl-al-err { background: var(--sl-red); } + +.sl-al-txt-err { + color: var(--sl-red); + font-weight: 600; +} diff --git a/applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.js b/applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.js new file mode 100644 index 000000000000..a80649df44ff --- /dev/null +++ b/applications/luci-app-starlink-panel/htdocs/luci-static/resources/view/starlink-panel/status.js @@ -0,0 +1,556 @@ +'use strict'; +'require view'; +'require rpc'; +'require poll'; + +// ── RPC declarations ────────────────────────────────────────────────────────── + +const callStatus = rpc.declare({ + object: 'luci.starlink-panel', + method: 'status', + expect: {} +}); + +const callDish = rpc.declare({ + object: 'luci.starlink-panel', + method: 'dish', + expect: {} +}); + +const callRebootDish = rpc.declare({ + object: 'luci.starlink-panel', + method: 'reboot_dish', + expect: {} +}); + +// ── Formatters ──────────────────────────────────────────────────────────────── + +function fmtBytes(b) { + b = parseInt(b) || 0; + if (b === 0) return '0 B'; + var units = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = 0; + while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } + return b.toFixed(i > 0 ? 2 : 0) + ' ' + units[i]; +} + +function fmtBps(bps) { + bps = parseFloat(bps) || 0; + if (bps >= 1e9) return (bps / 1e9).toFixed(2) + ' Gbps'; + if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' Mbps'; + if (bps >= 1e3) return (bps / 1e3).toFixed(1) + ' Kbps'; + return bps.toFixed(0) + ' bps'; +} + +function fmtUptime(s) { + s = parseInt(s) || 0; + var d = Math.floor(s / 86400); + var h = Math.floor((s % 86400) / 3600); + var m = Math.floor((s % 3600) / 60); + if (d > 0) return d + 'd ' + h + 'h ' + m + 'm'; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; +} + +function fmtPct(f) { + return (parseFloat(f) * 100).toFixed(2) + '%'; +} + +// ── Tiny HTML helpers ───────────────────────────────────────────────────────── + +const BADGE_COLORS = { + ok: 'background:#1a7f37;color:#fff', + warn: 'background:#9a6700;color:#fff', + err: 'background:#cf222e;color:#fff', + info: 'background:#0550ae;color:#fff', + muted: 'background:#6e7781;color:#fff', + off: 'background:#444c56;color:#cdd9e5' +}; + +function badge(text, type) { + var s = BADGE_COLORS[type] || BADGE_COLORS.muted; + return '' + String(text) + ''; +} + +function dot(ok) { + var c = ok ? '#2ea043' : '#cf222e'; + return ''; +} + +function row(label, value) { + return '
' + label + '' + value + '
'; +} + +function card(title, icon, body, extraClass) { + return '
' + + '
' + icon + '' + title + '
' + + '
' + body + '
' + + '
'; +} + +function alertRow(label, value, isAlert) { + if (!isAlert) return ''; + return '
' + badge('!', 'err') + ' ' + label + ': ' + value + '
'; +} + +// ── Card builders ───────────────────────────────────────────────────────────── + +function buildDishCard(d) { + var body = ''; + + if (!d || !d.available) { + var notInstalled = !d || !d.error || d.error.indexOf('not found') !== -1; + if (notInstalled) { + var reason = (d && d.error) ? d.error : 'unavailable'; + body += '
Dish API: ' + reason + '
'; + body += '
Ensure starlink-dish is installed at /usr/bin/starlink-dish and the dish is reachable at 192.168.100.1:9200.
'; + } else { + body += '
starlink-dish OK — dish unreachable (rebooting?)
'; + } + return card('Dish Telemetry', '📡', body); + } + + var state = d.state || 'UNKNOWN'; + var isConn = state === 'CONNECTED'; + var latency = parseFloat(d.latency_ms) || 0; + var drop = parseFloat(d.drop_rate) || 0; + var obst = parseFloat(d.fraction_obstructed) || 0; + var elev = parseFloat(d.elevation_deg) || 0; + var snrOk = d.snr_above_noise === 'true'; + + body += row('State', badge(state, isConn ? 'ok' : 'warn')); + body += row('PoP Latency', badge(latency.toFixed(1) + ' ms', + latency < 50 ? 'ok' : latency < 100 ? 'warn' : 'err')); + body += row('Drop Rate', badge(fmtPct(drop), + drop < 0.001 ? 'ok' : drop < 0.01 ? 'warn' : 'err')); + body += row('Obstruction', badge(fmtPct(obst), + obst < 0.005 ? 'ok' : obst < 0.05 ? 'warn' : 'err')); + body += row('SNR OK', badge(snrOk ? 'yes' : 'no', snrOk ? 'ok' : 'warn')); + body += row('Elevation', elev.toFixed(1) + '°'); + + if (d.gps_sats && parseInt(d.gps_sats) > 0) { + var gpsValid = d.gps_valid === 'true'; + body += row('GPS', badge(parseInt(d.gps_sats) + ' sats', gpsValid ? 'ok' : 'warn') + (gpsValid ? '' : ' ' + badge('no fix', 'err'))); + } + if (d.eth_speed_mbps && parseInt(d.eth_speed_mbps) > 0) + body += row('Ethernet', parseInt(d.eth_speed_mbps) + ' Mbps'); + if (d.attitude) + body += row('Alignment', badge(d.attitude.replace('FILTER_', ''), 'info')); + if (d.uptime) + body += row('Dish Uptime', fmtUptime(d.uptime)); + if (d.class_of_service && d.class_of_service !== 'UNKNOWN') + body += row('Service', badge(d.class_of_service.replace('CLASS_OF_SERVICE_', ''), 'info')); + if (d.mobility_class && d.mobility_class !== 'UNKNOWN' && d.mobility_class !== 'MOBILITY_CLASS_NONE') + body += row('Mobility', badge(d.mobility_class.replace('MOBILITY_CLASS_', ''), 'info')); + var secsToSlot = parseFloat(d.seconds_to_slot) || 0; + if (secsToSlot > 0 && secsToSlot < 600) + body += row('Next Slot', badge(secsToSlot.toFixed(0) + 's', 'warn')); + var obstDur = parseFloat(d.avg_obstruction_dur) || 0; + var obstInt = parseFloat(d.avg_obstruction_int) || 0; + if (obstDur > 0) + body += row('Avg Obstruction', obstDur.toFixed(1) + 's every ' + (obstInt > 0 ? obstInt.toFixed(0) + 's' : '—')); + if (d.sw_reboot_ready === 'true') + body += row('SW Update', badge('reboot required', 'warn')); + + // Active alerts only + if (d.al_throttle === 'true') body += alertRow('Thermal throttle', 'active', true); + if (d.al_motors === 'true') body += alertRow('Motors stuck', 'active', true); + if (d.al_mast === 'true') body += alertRow('Mast not vertical','active', true); + if (d.al_slow_eth === 'true') body += alertRow('Slow ethernet', 'active', true); + if (d.al_heating === 'true') body += alertRow('Snow melt heating','active', true); + + if (d.hardware) body += row('Dish HW', '' + d.hardware + ''); + if (d.software) body += row('Firmware', '' + d.software + ''); + if (d.dish_id) body += row('Dish ID', '' + d.dish_id + ''); + if (d.country_code) body += row('Country', d.country_code); + if (parseInt(d.bootcount) > 0) body += row('Boot Count', parseInt(d.bootcount).toLocaleString()); + var rebootHour = parseInt(d.swupdate_reboot_hour); + if (!isNaN(rebootHour)) body += row('Daily Reboot', rebootHour + ':00 local'); + var dlR = d.dl_restrict && d.dl_restrict !== 'NO_LIMIT' ? d.dl_restrict.replace(/_/g, ' ') : null; + var ulR = d.ul_restrict && d.ul_restrict !== 'NO_LIMIT' ? d.ul_restrict.replace(/_/g, ' ') : null; + if (dlR) body += row('DL Limit', badge(dlR, 'warn')); + if (ulR) body += row('UL Limit', badge(ulR, 'warn')); + + return card('Dish Telemetry', '📡', body); +} + +function buildAlignmentCard(d) { + var body = ''; + + if (!d || !d.available) { + body += '
No dish data
'; + return card('Alignment', '🎯', body); + } + + var boreEl = parseFloat(d.bore_elevation_deg) || 0; + var desEl = parseFloat(d.desired_elevation_deg) || 0; + var boreAz = parseFloat(d.bore_azimuth_deg) || 0; + var desAz = parseFloat(d.desired_azimuth_deg) || 0; + var tiltNow = parseFloat(d.tilt_angle_deg) || 0; + + var tiltDiff = desEl - boreEl; + var rotateDiff = desAz - boreAz; + // Normalise azimuth diff to [-180, 180] + while (rotateDiff > 180) rotateDiff -= 360; + while (rotateDiff < -180) rotateDiff += 360; + + var tiltAbs = Math.abs(tiltDiff).toFixed(2); + var rotateAbs = Math.abs(rotateDiff).toFixed(2); + var tiltDir = tiltDiff < 0 ? '↓' : '↑'; + var rotateDir = rotateDiff > 0 ? '↻' : '↶'; + + var aligned = Math.abs(tiltDiff) < 5 && Math.abs(rotateDiff) < 5; + + if (aligned) { + body += '
✓ Dish is well aligned
'; + } + + body += '
'; + body += '
' + + '
' + tiltAbs + '°' + tiltDir + '
' + + '
Tilt recommendation
'; + body += '
' + + '
' + rotateAbs + '°' + rotateDir + '
' + + '
Rotate recommendation
'; + body += '
'; + + body += row('Current tilt', tiltNow.toFixed(2) + '°'); + body += row('Elevation', boreEl.toFixed(2) + '° → ' + desEl.toFixed(2) + '°'); + body += row('Azimuth', boreAz.toFixed(2) + '° → ' + desAz.toFixed(2) + '°'); + if (d.attitude) body += row('Attitude', badge(d.attitude.replace('FILTER_', ''), 'info')); + var attUnc = parseFloat(d.attitude_uncertainty_deg) || 0; + if (attUnc > 0) body += row('Attitude Uncertainty', badge(attUnc.toFixed(2) + '°', attUnc < 1 ? 'ok' : attUnc < 3 ? 'warn' : 'err')); + if (d.has_actuators) body += row('Actuators', badge(d.has_actuators.replace('HAS_ACTUATORS_', ''), 'info')); + + // Reboot button + body += ''; + + return card('Alignment', '🎯', body); +} + +function alItem(ok, okText, errText) { + var cls = ok ? 'sl-al-ok' : 'sl-al-err'; + var tcls = ok ? 'sl-al-txt-ok' : 'sl-al-txt-err'; + return '
' + + '' + (ok ? okText : errText) + '
'; +} + +function buildAlertsCard(d) { + if (!d || !d.available) { + return card('Alerts', '🔔', '
No dish data
'); + } + + var ok = function(f) { return f !== 'true'; }; + var swOk = d.sw_update_state === 'IDLE' || d.sw_update_state === '' || d.sw_update_state === 'SOFTWARE_UPDATE_STATE_UNKNOWN'; + var notObstructed = d.currently_obstructed !== 'true' && + parseFloat(d.fraction_obstructed || 0) < 0.005; + var notDisabled = d.disablement === 'OKAY' || d.disablement === ''; + + var body = '
'; + body += alItem(ok(d.al_heating), 'Not heating', 'Dish is heating'); + body += alItem(ok(d.al_throttle), 'Normal temperature', 'Thermal throttle active'); + body += alItem(ok(d.al_shutdown), 'Not in thermal shutdown', 'Thermal shutdown active'); + body += alItem(ok(d.al_psu_throttle),'External PSU temp OK', 'PSU thermal throttle'); + body += alItem(ok(d.al_motors), 'Motors healthy', 'Motors stuck'); + body += alItem(ok(d.al_mast), 'Mast is near vertical', 'Mast not vertical'); + body += alItem(ok(d.al_slow_eth), 'Normal Ethernet speeds', 'Slow Ethernet speeds'); + body += alItem(swOk, 'Software is up to date', 'Software update: ' + d.sw_update_state); + body += alItem(ok(d.al_roaming), 'Moving at an acceptable speed', 'Moving too fast (roaming)'); + body += alItem(notObstructed, 'Not obstructed', 'Dish obstructed'); + body += alItem(notDisabled, 'Not disabled', 'Disabled: ' + d.disablement); + body += alItem(ok(d.snr_persistently_low), 'SNR normal', 'SNR persistently low'); + body += alItem(ok(d.al_unexpected_location), 'Location verified', 'Unexpected location'); + body += alItem(ok(d.al_install_pending), 'Install complete', 'Install pending'); + var swRebootOk = d.sw_reboot_ready !== 'true'; + body += alItem(swRebootOk, 'Software up to date', 'Reboot required for SW update'); + body += '
'; + + var heatingOn = d.al_heating === 'true'; + var snowMode = (d.snow_melt_mode || 'AUTO').toUpperCase(); + var snowLabel = snowMode === 'ALWAYS_ON' ? 'ALWAYS ON' : snowMode === 'ALWAYS_OFF' ? 'ALWAYS OFF' : 'AUTO'; + var snowColor = snowMode === 'ALWAYS_OFF' ? 'muted' : 'ok'; + body += row('Snow melt', badge(snowLabel, snowColor) + (heatingOn ? ' ' + badge('heating', 'warn') : '')); + + return card('Alerts', '🔔', body); +} + +function buildIPv6Card(s) { + var body = ''; + + var hasWan = !!(s.wan_ipv6 && s.wan_ipv6.trim()); + var hasLan = !!(s.lan_ipv6 && s.lan_ipv6.trim()); + var hasRoute = !!(s.ipv6_default_route && s.ipv6_default_route.trim()); + var hasPrefix = !!(s.delegated_prefix && s.delegated_prefix.trim()); + var hasPrefLft = !!(s.max_preferred_lifetime && s.max_preferred_lifetime !== 'not_set' && s.max_preferred_lifetime !== ''); + var hasValidLft = !!(s.max_valid_lifetime && s.max_valid_lifetime !== 'not_set' && s.max_valid_lifetime !== ''); + + body += row('WAN IPv6', + dot(hasWan) + (hasWan + ? '' + s.wan_ipv6 + '' + : badge('None', 'err'))); + + body += row('LAN Prefix', + dot(hasLan) + (hasLan + ? '' + s.lan_ipv6 + '' + : badge('None', 'err'))); + + if (hasPrefix) { + body += row('Delegated /56', + '' + s.delegated_prefix + ''); + } + + body += row('Default Route', hasRoute + ? badge('present', 'ok') + : badge('missing', 'err')); + + if (hasPrefLft) body += row('Preferred LFT', badge(s.max_preferred_lifetime + 's', 'ok')); + if (hasValidLft) body += row('Valid LFT', badge(s.max_valid_lifetime + 's', 'ok')); + + return card('IPv6 Connectivity', '🌐', body); +} + +function buildTrafficCard(s, d) { + var body = ''; + + // Instantaneous throughput from dish gRPC (if available) + if (d && d.available && (d.downlink_bps || d.uplink_bps)) { + body += '
'; + body += '
↓ ' + fmtBps(d.downlink_bps) + '
Downlink (dish)
'; + body += '
↑ ' + fmtBps(d.uplink_bps) + '
Uplink (dish)
'; + body += '
'; + } + + if (s.wan_stats) { + body += row('WAN RX', fmtBytes(s.wan_stats.rx_bytes)); + body += row('WAN TX', fmtBytes(s.wan_stats.tx_bytes)); + body += row('WAN RX pkts', (parseInt(s.wan_stats.rx_packets) || 0).toLocaleString()); + body += row('WAN TX pkts', (parseInt(s.wan_stats.tx_packets) || 0).toLocaleString()); + } + if (s.lan_stats) { + body += row('LAN RX', fmtBytes(s.lan_stats.rx_bytes)); + body += row('LAN TX', fmtBytes(s.lan_stats.tx_bytes)); + } + + return card('Traffic', '📊', body); +} + +function buildQualityCard(s, d) { + var body = ''; + + // Dish PoP latency + if (d && d.available && d.latency_ms) { + var l = parseFloat(d.latency_ms); + body += row('Dish → PoP', + badge(l.toFixed(1) + ' ms', l < 50 ? 'ok' : l < 100 ? 'warn' : 'err')); + } + + // Router ping to well-known targets + if (s.ping_8888) { + var p8 = parseFloat(s.ping_8888); + body += row('Ping 8.8.8.8', + badge(p8.toFixed(1) + ' ms', p8 < 60 ? 'ok' : p8 < 150 ? 'warn' : 'err')); + } + if (s.ping_1001) { + var p1 = parseFloat(s.ping_1001); + body += row('Ping 1.0.0.1', + badge(p1.toFixed(1) + ' ms', p1 < 60 ? 'ok' : p1 < 150 ? 'warn' : 'err')); + } + + // Conntrack + if (s.conntrack_count && s.conntrack_max) { + var ct = parseInt(s.conntrack_count); + var mx = parseInt(s.conntrack_max); + var pct = mx > 0 ? Math.round(ct / mx * 100) : 0; + body += row('Conntrack', + ct.toLocaleString() + ' / ' + mx.toLocaleString() + + ' ' + badge(pct + '%', pct < 70 ? 'ok' : pct < 90 ? 'warn' : 'err')); + } + + if (s.uptime) { + body += row('Router Uptime', fmtUptime(s.uptime)); + } + + return card('Quality', '📶', body); +} + +function buildReadyStatesCard(d) { + if (!d || !d.available) { + return card('Ready States', '🔌', '
No dish data
'); + } + + var t = function(v) { return v === 'true'; }; + var body = '
'; + body += alItem(t(d.rs_rf), 'RF', 'RF'); + body += alItem(t(d.rs_l1l2), 'L1/L2','L1/L2'); + body += alItem(t(d.rs_xphy), 'xPHY', 'xPHY'); + body += alItem(t(d.rs_scp), 'SCP', 'SCP'); + body += alItem(t(d.rs_aap), 'AAP', 'AAP'); + body += '
'; + + var signedCals = d.has_signed_cals === 'true'; + body += row('Signed Cals', badge(signedCals ? 'yes' : 'no', signedCals ? 'ok' : 'warn')); + + return card('Ready States', '🔌', body); +} + +function buildBootStatsCard(d) { + if (!d || !d.available) return null; + + var stable = parseInt(d.init_stable_s) || 0; + var firstPing = parseInt(d.init_first_ping_s) || 0; + var gpsT = parseInt(d.init_gps_s) || 0; + + if (!stable && !firstPing && !gpsT) return null; + + var body = ''; + if (gpsT) body += row('GPS valid', gpsT + 's'); + if (firstPing) body += row('First PoP ping', firstPing + 's'); + if (stable) body += row('Stable connection', badge(stable + 's', stable < 60 ? 'ok' : stable < 120 ? 'warn' : 'err')); + if (parseInt(d.obst_patches_valid) > 0) + body += row('Obstruction patches', parseInt(d.obst_patches_valid).toLocaleString()); + + return card('Boot Stats', '🚀', body); +} + +function buildOutageCard(d) { + if (!d || !d.available || !d.outages || !d.outages.length) { + return card('Recent Outages', '⚡', '
No outage history
'); + } + + // Outages are chronological oldest-first; show the 6 most recent + var outages = d.outages.slice(-6).reverse(); + var body = ''; + + for (var i = 0; i < outages.length; i++) { + var o = outages[i]; + var cause = (o.cause || 'UNKNOWN').replace(/_/g, ' '); + var dur = parseFloat(o.duration) || 0; + var durStr = dur < 1 ? '<1s' : dur < 60 ? dur.toFixed(1) + 's' : (dur / 60).toFixed(1) + 'm'; + var tsMs = (parseFloat(o.startTimestampNs) || 0) / 1e6; + var agoS = tsMs > 0 ? Math.round((Date.now() - tsMs) / 1000) : 0; + var agoStr = agoS <= 0 ? '' : agoS < 60 ? agoS + 's ago' : + agoS < 3600 ? Math.round(agoS / 60) + 'm ago' : + Math.round(agoS / 3600) + 'h ago'; + + var causeType = cause.indexOf('OBSTRUCTED') >= 0 ? 'warn' : + cause.indexOf('NO SCHEDULE') >= 0 ? 'info' : 'err'; + + body += row(badge(cause, causeType), durStr + (agoStr ? ' · ' + agoStr : '')); + } + + return card('Recent Outages', '⚡', body); +} + +// ── Reboot handler (global so inline onclick can reach it) ─────────────────── + +window.starlinkRebootDish = function(btn) { + if (!window.confirm('Reboot the Starlink dish?\n\nThe dish will be offline for ~60 seconds.')) + return; + btn.disabled = true; + btn.textContent = '⟳ Rebooting…'; + callRebootDish().then(function(r) { + if (r && r.success) { + btn.textContent = '✓ Reboot sent — dish offline ~60s'; + btn.style.borderColor = 'var(--sl-green)'; + btn.style.color = 'var(--sl-green)'; + } else { + btn.textContent = '✗ Reboot failed'; + btn.style.borderColor = 'var(--sl-red)'; + btn.style.color = 'var(--sl-red)'; + btn.disabled = false; + } + }).catch(function() { + btn.textContent = '✗ RPC error'; + btn.disabled = false; + }); +}; + +// ── View ────────────────────────────────────────────────────────────────────── + +return view.extend({ + handleSaveApply: null, + handleSave: null, + handleReset: null, + + load: function() { + return Promise.all([ callStatus(), callDish() ]); + }, + + render: function(data) { + var self = this; + var container = E('div'); + + if (!document.getElementById('sl-panel-css')) { + var link = document.createElement('link'); + link.id = 'sl-panel-css'; + link.rel = 'stylesheet'; + link.href = L.resource('view/starlink-panel/status.css'); + document.head.appendChild(link); + } + + this._updateView(container, data[0] || {}, data[1] || {}); + + poll.add(function() { + return Promise.all([ callStatus(), callDish() ]).then(function(d) { + self._updateView(container, d[0] || {}, d[1] || {}); + }); + }, 10); + + return container; + }, + + _updateView: function(container, s, d) { + // Preserve reboot button state across re-renders + var prevBtn = container.querySelector('#sl-reboot-btn'); + var rebootBtnState = prevBtn && prevBtn.disabled ? { + text: prevBtn.textContent, + borderColor: prevBtn.style.borderColor, + color: prevBtn.style.color, + disabled: true + } : null; + + var dishState = (d && d.available) ? (d.state || 'UNKNOWN') : null; + var isConn = dishState === 'CONNECTED'; + var now = new Date().toLocaleTimeString(); + + var html = '
'; + + // Header + html += '
'; + html += '
🛸 Starlink
'; + html += '
'; + if (dishState) { + html += badge(dishState, isConn ? 'ok' : 'warn') + ' '; + } + html += 'Updated ' + now + ''; + html += '
'; + + // Cards grid + html += '
'; + html += buildDishCard(d); + html += buildAlignmentCard(d); + html += buildAlertsCard(d); + html += buildIPv6Card(s); + html += buildTrafficCard(s, d); + html += buildQualityCard(s, d); + html += buildReadyStatesCard(d); + var bootCard = buildBootStatsCard(d); + if (bootCard) html += bootCard; + html += buildOutageCard(d); + html += '
'; + + html += '
'; + container.innerHTML = html; + + // Restore reboot button state if it was in a triggered state + if (rebootBtnState) { + var btn = container.querySelector('#sl-reboot-btn'); + if (btn) { + btn.disabled = true; + btn.textContent = rebootBtnState.text; + btn.style.borderColor = rebootBtnState.borderColor; + btn.style.color = rebootBtnState.color; + } + } + } +}); \ No newline at end of file diff --git a/applications/luci-app-starlink-panel/root/usr/libexec/rpcd/luci.starlink-panel b/applications/luci-app-starlink-panel/root/usr/libexec/rpcd/luci.starlink-panel new file mode 100644 index 000000000000..6cca015aca0c --- /dev/null +++ b/applications/luci-app-starlink-panel/root/usr/libexec/rpcd/luci.starlink-panel @@ -0,0 +1,169 @@ +#!/bin/sh +# /usr/libexec/rpcd/luci.starlink-panel +# RPCd backend for luci-app-starlink-panel. +# Called by rpcd: $1=list|call, $2=method_name (when call) + +DISH_IP="192.168.100.1" +DISH_PORT="9200" + +# ── helpers ────────────────────────────────────────────────────────────────── + +get_wan_dev() { + uci -q get network.wan.device 2>/dev/null || echo "eth0" +} + +get_wan_ipv6() { + local dev="$1" + ip -6 addr show dev "$dev" 2>/dev/null \ + | awk '/inet6[[:space:]].*scope global/ {print $2; exit}' +} + +get_lan_ipv6() { + # Search all bridge interfaces (br-lan, br-lan.10, br-lan.20, etc.) for a + # global IPv6 prefix — with VLANs the prefix may not be on br-lan itself. + ip -6 addr show 2>/dev/null \ + | awk ' + /^[0-9]+:/ { split($2, a, "@"); iface = a[1]; sub(/:$/, "", iface) } + iface ~ /^br-/ && /inet6[[:space:]].*scope global/ { print $2; exit } + ' +} + +get_ipv6_default_route() { + ip -6 route show default 2>/dev/null | head -1 +} + +get_delegated_prefix() { + # Starlink delegates a /56; may appear as route dest or in "from X/56" source rule + ip -6 route show 2>/dev/null \ + | awk 'match($0, /[0-9a-f:]+\/56/) {print substr($0, RSTART, RLENGTH); exit}' +} + +get_iface_stats() { + local iface="${1}:" + awk -v dev="$iface" ' + $1 == dev { + printf "{\"rx_bytes\":%s,\"tx_bytes\":%s,\"rx_packets\":%s,\"tx_packets\":%s}", + $2, $10, $3, $11 + } + ' /proc/net/dev +} + +json_str() { + # Escape a value for embedding in a JSON string + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + + +get_conntrack_count() { + cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo "0" +} + +get_conntrack_max() { + cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || echo "0" +} + +get_uptime_sec() { + awk '{printf "%d", $1}' /proc/uptime 2>/dev/null || echo "0" +} + +get_wan_qdisc() { + local dev="$1" + if command -v tc >/dev/null 2>&1; then + tc qdisc show dev "$dev" 2>/dev/null | head -1 + fi +} + +# ── dish gRPC query ─────────────────────────────────────────────────────────── + +query_dish_status() { + if ! command -v starlink-dish >/dev/null 2>&1; then + printf '{"available":false,"error":"starlink-dish not found — install the starlink-dish package"}' + return + fi + starlink-dish dish -d "http://${DISH_IP}:${DISH_PORT}" +} + +# ── system status ───────────────────────────────────────────────────────────── + +get_system_status() { + local wan_dev wan_ipv6 lan_ipv6 ipv6_route delegated + local wan_stats lan_stats ct_count ct_max + local tcp_cc def_qdisc wan_qdisc + local pref_lft valid_lft uptime_s + local ping_8 ping_1 + + wan_dev=$(get_wan_dev) + wan_ipv6=$(get_wan_ipv6 "$wan_dev") + lan_ipv6=$(get_lan_ipv6) + ipv6_route=$(get_ipv6_default_route) + delegated=$(get_delegated_prefix) + + wan_stats=$(get_iface_stats "$wan_dev") + lan_stats=$(get_iface_stats "br-lan") + + ct_count=$(get_conntrack_count) + ct_max=$(get_conntrack_max) + + tcp_cc=$(cat /proc/sys/net/ipv4/tcp_congestion_control 2>/dev/null || echo "unknown") + def_qdisc=$(cat /proc/sys/net/core/default_qdisc 2>/dev/null || echo "unknown") + wan_qdisc=$(get_wan_qdisc "$wan_dev") + + pref_lft=$(uci -q get dhcp.lan.max_preferred_lifetime 2>/dev/null || echo "") + valid_lft=$(uci -q get dhcp.lan.max_valid_lifetime 2>/dev/null || echo "") + + uptime_s=$(get_uptime_sec) + + # Parallel pings — write to PID-scoped temp files to avoid concurrent-request collisions + ping -c 1 -W 2 8.8.8.8 2>/dev/null \ + | sed -n 's|.*= [0-9.]*/\([0-9.]*\)/.*|\1|p' > /tmp/.sl_p8.$$ & + ping -c 1 -W 2 1.0.0.1 2>/dev/null \ + | sed -n 's|.*= [0-9.]*/\([0-9.]*\)/.*|\1|p' > /tmp/.sl_p1.$$ & + wait + ping_8=$(cat /tmp/.sl_p8.$$ 2>/dev/null); rm -f /tmp/.sl_p8.$$ + ping_1=$(cat /tmp/.sl_p1.$$ 2>/dev/null); rm -f /tmp/.sl_p1.$$ + + printf '{' + printf '"wan_dev":"%s",' "$(json_str "$wan_dev")" + printf '"wan_ipv6":"%s",' "$(json_str "$wan_ipv6")" + printf '"lan_ipv6":"%s",' "$(json_str "$lan_ipv6")" + printf '"ipv6_default_route":"%s",' "$(json_str "$ipv6_route")" + printf '"delegated_prefix":"%s",' "$(json_str "$delegated")" + printf '"wan_stats":%s,' "${wan_stats:-null}" + printf '"lan_stats":%s,' "${lan_stats:-null}" + printf '"conntrack_count":%s,' "$ct_count" + printf '"conntrack_max":%s,' "$ct_max" + printf '"tcp_cc":"%s",' "$(json_str "$tcp_cc")" + printf '"default_qdisc":"%s",' "$(json_str "$def_qdisc")" + printf '"wan_qdisc":"%s",' "$(json_str "$wan_qdisc")" + printf '"max_preferred_lifetime":"%s",' "$(json_str "$pref_lft")" + printf '"max_valid_lifetime":"%s",' "$(json_str "$valid_lft")" + printf '"uptime":%s,' "$uptime_s" + printf '"ping_8888":"%s",' "$(json_str "$ping_8")" + printf '"ping_1001":"%s"' "$(json_str "$ping_1")" + printf '}' +} + +# ── dish reboot ─────────────────────────────────────────────────────────────── + +reboot_dish() { + if ! command -v starlink-dish >/dev/null 2>&1; then + printf '{"success":false,"error":"starlink-dish not found — install the starlink-dish package"}' + return + fi + starlink-dish reboot -d "http://${DISH_IP}:${DISH_PORT}" +} + +# ── rpcd dispatch ───────────────────────────────────────────────────────────── + +case "$1" in + list) + printf '{"status":{"timeout":20},"dish":{"timeout":10},"reboot_dish":{"timeout":15}}' + ;; + call) + case "$2" in + status) get_system_status ;; + dish) query_dish_status ;; + reboot_dish) reboot_dish ;; + esac + ;; +esac diff --git a/applications/luci-app-starlink-panel/root/usr/share/luci/menu.d/luci-app-starlink-panel.json b/applications/luci-app-starlink-panel/root/usr/share/luci/menu.d/luci-app-starlink-panel.json new file mode 100644 index 000000000000..021574ff3fe2 --- /dev/null +++ b/applications/luci-app-starlink-panel/root/usr/share/luci/menu.d/luci-app-starlink-panel.json @@ -0,0 +1,13 @@ +{ + "admin/network/starlink-panel": { + "title": "Starlink", + "order": 50, + "action": { + "type": "view", + "path": "starlink-panel/status" + }, + "depends": { + "acl": [ "luci-app-starlink-panel" ] + } + } +} diff --git a/applications/luci-app-starlink-panel/root/usr/share/rpcd/acl.d/luci-app-starlink-panel.json b/applications/luci-app-starlink-panel/root/usr/share/rpcd/acl.d/luci-app-starlink-panel.json new file mode 100644 index 000000000000..325b067bb0e1 --- /dev/null +++ b/applications/luci-app-starlink-panel/root/usr/share/rpcd/acl.d/luci-app-starlink-panel.json @@ -0,0 +1,15 @@ +{ + "luci-app-starlink-panel": { + "description": "LuCI Starlink Status Dashboard", + "read": { + "ubus": { + "luci.starlink-panel": [ "status", "dish" ] + } + }, + "write": { + "ubus": { + "luci.starlink-panel": [ "reboot_dish", "disable_hw_offloading" ] + } + } + } +}