Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,32 @@ storage:

Packets are stored in a local SQLite database. Old packets are pruned automatically based on `max_packets_retained`.

### Webhooks (outbound HTTP)

Fire async HTTP POSTs to LAN services (Home Assistant, Node-RED, Slack-compatible hooks) when mesh events occur. **Off by default.** Failures are logged to the admin audit log and never block packet processing.

```yaml
webhooks:
enabled: false
rules:
- name: low-battery-ha
url: "http://192.168.1.10:8123/api/webhook/mesh_low_battery"
event: battery_low
cooldown_seconds: 3600
battery_threshold_percent: 20
- name: sos-keyword
url: "https://hooks.example.com/sos"
event: keyword_match
keyword: "SOS"
cooldown_seconds: 300
```

**Supported events:** `battery_low`, `node_offline`, `node_online`, `keyword_match`, `duty_spike`, and `storm_quarantine` (reserved — validates at startup but does not fire until storm-guard ships).

Each rule has a per-rule cooldown (per node for node/battery/keyword events). POST body is JSON with `event`, `rule`, `device_name`, `timestamp`, optional `node_id`, and `data` — no PSKs or channel keys.

**Dashboard:** **Configuration → Advanced → Webhooks** lists configured rules, last-fired timestamps, and a **Test** button that sends a dummy POST (`event: test`) to verify each URL from the Pi.

---

## Dashboard
Expand Down Expand Up @@ -744,6 +770,10 @@ storage: # local SQLite packet store
max_packets_retained: 100000
cleanup_interval_seconds: 3600

webhooks: # outbound HTTP on mesh events (off by default)
enabled: false
rules: []

dashboard: # local web UI
host: "0.0.0.0"
port: 8080
Expand Down
59 changes: 59 additions & 0 deletions frontend/css/configuration.css
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,62 @@ select.cfg-field__input option:checked {
.cfg-mc-channels {
margin-bottom: 14px;
}

.cfg-webhook-table-wrap {
overflow-x: auto;
margin-top: 12px;
}

.cfg-webhook-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}

.cfg-webhook-table th,
.cfg-webhook-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
vertical-align: middle;
}

.cfg-webhook-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(243, 244, 246, 0.55);
}

.cfg-webhook-empty {
color: rgba(243, 244, 246, 0.6);
font-size: 13px;
}

.cfg-webhook-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
}

.cfg-webhook-badge--ok {
background: rgba(52, 211, 153, 0.15);
color: #6ee7b7;
}

.cfg-webhook-badge--muted {
background: rgba(255, 255, 255, 0.06);
color: rgba(243, 244, 246, 0.45);
}

.cfg-webhook-result--ok {
color: #6ee7b7;
}

.cfg-webhook-result--err {
color: #fca5a5;
}
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/configuration/gps_stats_column.js"></script>
<script src="js/configuration/gps_card.js"></script>
<script src="js/configuration/advanced_card.js"></script>
<script src="js/configuration/webhook_status_card.js"></script>
<script src="js/configuration/configuration_panel.js"></script>
<script src="/vendor/xterm/xterm.js"></script>
<script src="/vendor/xterm/xterm-addon-fit.js"></script>
Expand Down
12 changes: 10 additions & 2 deletions frontend/js/configuration/configuration_panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,18 @@ class ConfigurationPanel {
} else if (section === 'advanced' && window.AdvancedConfigCard) {
const host = document.getElementById('cfg-advanced-panel');
if (host) {
host.innerHTML = '';
host.innerHTML = `
<div data-cfg-advanced></div>
<div data-cfg-webhooks></div>
`;
const card = new window.AdvancedConfigCard(api);
card.mount(host);
card.mount(host.querySelector('[data-cfg-advanced]'));
this._cards.set('advanced', card);
if (window.WebhookStatusCard) {
const whCard = new window.WebhookStatusCard(api);
whCard.mount(host.querySelector('[data-cfg-webhooks]'));
this._cards.set('webhooks', whCard);
}
}
}
this._mounted.add(section);
Expand Down
198 changes: 198 additions & 0 deletions frontend/js/configuration/webhook_status_card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Configuration → Advanced — webhook rules status and test panel (PR 11).
*
* Read-only view of ``webhooks.rules`` from config plus live last-fired
* timestamps from ``GET /api/webhooks/status``. Test sends a dummy POST
* via ``POST /api/webhooks/test/{rule_name}``.
*/

class WebhookStatusCard {
constructor(api) {
this._api = api;
this._root = null;
this._status = null;
this._refreshTimer = null;
}

mount(root) {
this._root = root;
this._root.innerHTML = `
<article class="cfg-card" id="cfg-webhooks">
<header class="cfg-card__head">
<h3 class="cfg-card__title">Webhooks</h3>
<p class="cfg-card__hint">
Outbound HTTP rules are defined in <code>local.yaml</code>
under <code>webhooks</code>. This panel shows live status
and lets you send a <strong>dummy test POST</strong> to
verify each URL from the Pi.
</p>
</header>
<div class="cfg-webhook-summary" data-wh-summary></div>
<div class="cfg-webhook-table-wrap">
<table class="cfg-webhook-table" data-wh-table>
<thead>
<tr>
<th>Rule</th>
<th>Event</th>
<th>Host</th>
<th>Last fired</th>
<th>Result</th>
<th></th>
</tr>
</thead>
<tbody data-wh-body>
<tr><td colspan="6">Loading…</td></tr>
</tbody>
</table>
</div>
<p class="cfg-status" data-wh-status aria-live="polite"></p>
</article>
`;
this._summaryEl = this._root.querySelector('[data-wh-summary]');
this._bodyEl = this._root.querySelector('[data-wh-body]');
this._statusEl = this._root.querySelector('[data-wh-status]');
this._wireActions();
}

async render(_config) {
await this._loadStatus();
this._renderSummary();
this._renderTable();
this._startRefresh();
}

_wireActions() {
this._root.addEventListener('click', (e) => {
const btn = e.target.closest('[data-wh-test]');
if (!btn) return;
e.preventDefault();
const name = btn.dataset.whTest;
if (name) this._runTest(name, btn);
});
}

async _loadStatus() {
const data = await this._api.get('/api/webhooks/status');
this._status = data || { enabled: false, engine_running: false, rules: [] };
}

_renderSummary() {
const s = this._status || {};
const enabled = s.enabled ? 'enabled' : 'disabled';
const running = s.engine_running ? 'running' : 'stopped';
const count = (s.rules || []).length;
this._summaryEl.innerHTML = `
<p class="cfg-card__hint">
Engine: <strong>${this._api.escape(enabled)}</strong>
· worker: <strong>${this._api.escape(running)}</strong>
· ${count} rule${count === 1 ? '' : 's'} in config
</p>
`;
}

_renderTable() {
const rules = (this._status && this._status.rules) || [];
if (!rules.length) {
this._bodyEl.innerHTML = `
<tr><td colspan="6" class="cfg-webhook-empty">
No webhook rules in config. Add rules under
<code>webhooks.rules</code> in <code>local.yaml</code>.
</td></tr>
`;
return;
}

this._bodyEl.innerHTML = rules.map((rule) => {
const last = this._formatLastFired(rule);
const result = this._formatResult(rule);
const badge = rule.deferred
? '<span class="cfg-webhook-badge cfg-webhook-badge--muted">reserved</span>'
: (rule.active
? '<span class="cfg-webhook-badge cfg-webhook-badge--ok">active</span>'
: '<span class="cfg-webhook-badge cfg-webhook-badge--muted">inactive</span>');
const testLabel = rule.deferred ? '—' : (
`<button type="button" class="terminal-button terminal-button--small"
data-wh-test="${this._api.escape(rule.name)}">Test</button>`
);
return `<tr>
<td>${this._api.escape(rule.name)} ${badge}</td>
<td><code>${this._api.escape(rule.event)}</code></td>
<td>${this._api.escape(rule.url_host || '—')}</td>
<td>${this._api.escape(last)}</td>
<td>${result}</td>
<td>${testLabel}</td>
</tr>`;
}).join('');
}

_formatLastFired(rule) {
if (!rule.last_fired_at) return '—';
try {
const d = new Date(rule.last_fired_at);
const stamp = d.toLocaleString();
return rule.last_was_test ? `${stamp} (test)` : stamp;
} catch (_e) {
return rule.last_fired_at;
}
}

_formatResult(rule) {
if (!rule.last_result) return '—';
const cls = rule.last_result === 'success'
? 'cfg-webhook-result--ok'
: 'cfg-webhook-result--err';
const code = rule.last_status_code != null
? ` HTTP ${rule.last_status_code}`
: '';
const err = rule.last_error
? ` — ${this._api.escape(rule.last_error)}`
: '';
return `<span class="${cls}">${this._api.escape(rule.last_result)}${code}</span>${err}`;
}

async _runTest(ruleName, btn) {
btn.disabled = true;
this._setStatus('Sending dummy test POST…', '');
const result = await this._api.post(
`/api/webhooks/test/${encodeURIComponent(ruleName)}`,
{},
);
if (result) {
const ok = result.result === 'success';
this._setStatus(
ok
? `Test OK for ${ruleName} (HTTP ${result.status_code ?? '—'})`
: `Test failed for ${ruleName}: ${result.error || result.result}`,
ok ? 'ok' : 'err',
);
await this._loadStatus();
this._renderTable();
} else {
this._setStatus(`Test request failed for ${ruleName}`, 'err');
}
btn.disabled = false;
}

_setStatus(msg, kind) {
this._statusEl.textContent = msg;
this._statusEl.className = 'cfg-status'
+ (kind === 'ok' ? ' cfg-status--ok' : '')
+ (kind === 'err' ? ' cfg-status--err' : '');
}

_startRefresh() {
if (this._refreshTimer) return;
this._refreshTimer = setInterval(async () => {
const section = document.querySelector('[data-section="configuration/advanced"]');
if (!section || !section.classList.contains('section--active')) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
return;
}
await this._loadStatus();
this._renderTable();
}, 15_000);
}
}

window.WebhookStatusCard = WebhookStatusCard;
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ meshcore>=2.1.0
paho-mqtt>=2.1.0
bcrypt>=4.2.0
PyJWT>=2.10.0
httpx>=0.27.0
38 changes: 38 additions & 0 deletions src/api/routes/webhooks_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Webhook status and test endpoints for the dashboard (PR 11)."""
from __future__ import annotations

from fastapi import APIRouter, HTTPException

from src.webhook.engine import WebhookEngine

router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])

_engine: WebhookEngine | None = None


def init_routes(engine: WebhookEngine | None) -> None:
global _engine
_engine = engine


@router.get("/status")
async def webhook_status():
"""Active rules, last-fired timestamps, and engine state (no secrets)."""
if _engine is None:
return {
"enabled": False,
"engine_running": False,
"rules": [],
}
return _engine.get_status()


@router.post("/test/{rule_name}")
async def webhook_test(rule_name: str):
"""Send a dummy POST to verify the rule URL from the Pi."""
if _engine is None:
raise HTTPException(status_code=503, detail="webhook engine not ready")
try:
return await _engine.fire_test(rule_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
Loading
Loading