Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ open and unacknowledged:

![Escalation policy ladder](docs/assets/escalation.png)

**Network discovery** — scan a CIDR on a schedule; responsive addresses become hosts
(with a template applied):

![Discovery rules](docs/assets/discovery.png)

**Host detail** — items with min/max and server-rendered history sparklines:

| Dark | Light |
Expand Down
2 changes: 2 additions & 0 deletions app/api/routes/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from app.api.routes.web.auth import router as auth_router
from app.api.routes.web.channels import router as channels_router
from app.api.routes.web.dashboard import router as dashboard_router
from app.api.routes.web.discovery import router as discovery_router
from app.api.routes.web.escalation import router as escalation_router
from app.api.routes.web.hosts import router as hosts_router
from app.api.routes.web.maintenances import router as maintenances_router
Expand All @@ -17,6 +18,7 @@
router.include_router(channels_router)
router.include_router(escalation_router)
router.include_router(maintenances_router)
router.include_router(discovery_router)
router.include_router(problems_router)

__all__ = ["router"]
122 changes: 122 additions & 0 deletions app/api/routes/web/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from __future__ import annotations

import uuid
from datetime import UTC, datetime

from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse

from app.api.deps.db import DBSession
from app.api.routes.web._shared import login_redirect, resolve_current_user, templates
from app.core.models.discovery import DiscoveryMethod
from app.core.models.user import User
from app.core.schemas.discovery import DiscoveryRuleCreate
from app.core.services.discovery_service import DiscoveryService
from app.core.services.template_service import TemplateService

router = APIRouter()


async def _context(
session: DBSession, user: User, error: str | None = None, flash: str | None = None
) -> dict[str, object]:
tmpls = list(await TemplateService(session).list_for_owner(user.id))
return {
"current_user": user,
"active_nav": "discovery",
"rules": list(await DiscoveryService(session).list_for_owner(user.id)),
"templates_list": tmpls,
"template_names": {t.id: t.name for t in tmpls},
"methods": [m.value for m in DiscoveryMethod],
"error": error,
"flash": flash,
}


@router.get("/discovery", response_class=HTMLResponse, response_model=None)
async def discovery_page(request: Request, session: DBSession) -> HTMLResponse | RedirectResponse:
user = await resolve_current_user(request, session)
if user is None:
return login_redirect()
context = await _context(session, user, flash=request.query_params.get("flash"))
return templates.TemplateResponse(request, "discovery/list.html", context=context)


@router.post("/discovery/new", response_class=HTMLResponse, response_model=None)
async def create_discovery_form(
request: Request,
session: DBSession,
name: str = Form(...),
cidr: str = Form(...),
method: str = Form("ping"),
port: str | None = Form(None),
template_id: str | None = Form(None),
interval_seconds: int = Form(3600),
is_enabled: str | None = Form(None),
) -> HTMLResponse | RedirectResponse:
user = await resolve_current_user(request, session)
if user is None:
return login_redirect()
service = DiscoveryService(session)

async def _error(message: str) -> HTMLResponse:
return templates.TemplateResponse(
request,
"discovery/list.html",
context=await _context(session, user, error=message),
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)

template_uuid = uuid.UUID(template_id) if template_id else None
if (
template_uuid is not None
and await TemplateService(session).get(template_uuid, user.id) is None
):
return await _error("Unknown template.")
try:
payload = DiscoveryRuleCreate(
name=name,
cidr=cidr,
method=DiscoveryMethod(method),
port=int(port) if port else None,
template_id=template_uuid,
interval_seconds=interval_seconds,
is_enabled=is_enabled == "on",
)
except (ValueError, TypeError) as exc:
return await _error(str(exc))

await service.create(user.id, payload)
return RedirectResponse(url="/discovery?flash=Saved", status_code=status.HTTP_303_SEE_OTHER)


@router.post("/discovery/{rule_id}/scan")
async def scan_discovery_form(
rule_id: uuid.UUID, request: Request, session: DBSession
) -> RedirectResponse:
user = await resolve_current_user(request, session)
if user is None:
return login_redirect()
service = DiscoveryService(session)
rule = await service.get(rule_id, user.id)
if rule is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
created = await service.scan_rule(rule, datetime.now(UTC))
return RedirectResponse(
url=f"/discovery?flash=Discovered+{created}+host(s)", status_code=status.HTTP_303_SEE_OTHER
)


@router.post("/discovery/{rule_id}/delete")
async def delete_discovery_form(
rule_id: uuid.UUID, request: Request, session: DBSession
) -> RedirectResponse:
user = await resolve_current_user(request, session)
if user is None:
return login_redirect()
service = DiscoveryService(session)
rule = await service.get(rule_id, user.id)
if rule is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
await service.delete(rule)
return RedirectResponse(url="/discovery?flash=Deleted", status_code=status.HTTP_303_SEE_OTHER)
Binary file added docs/assets/discovery.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ GhostMonitor's reason to exist over a plain Zabbix clone is privacy (ghost-suite
template + interval) is scanned on a schedule; responsive addresses that are not yet
a host are provisioned as hosts, with the rule's template items applied. Guardrails:
CIDR capped (≤ 1024 hosts), bounded scan concurrency, dedupe by address, owner-scoped.
REST CRUD + scan-now under `/api/discovery-rules`. *(next)* a web UI.
REST CRUD + scan-now under `/api/discovery-rules`, and a `/discovery` web page to
manage rules (create, scan-now, delete).
- *(deferred — measure first)* Distributed collection (proxy/agent fan-in) and history
storage scaling (TimescaleDB / a dedicated TSDB). Deliberately not built yet: it is
premature without real load data. Add ingestion-volume/throughput metrics first so
Expand Down
1 change: 1 addition & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<a href="/" class="nav-link{% if active_nav == 'dashboard' %} nav-active{% endif %}">Dashboard</a>
<a href="/hosts" class="nav-link{% if active_nav == 'hosts' %} nav-active{% endif %}">Hosts</a>
<a href="/problems" class="nav-link{% if active_nav == 'problems' %} nav-active{% endif %}">Problems</a>
<a href="/discovery" class="nav-link{% if active_nav == 'discovery' %} nav-active{% endif %}">Discovery</a>
<a href="/channels" class="nav-link{% if active_nav == 'channels' %} nav-active{% endif %}">Channels</a>
<a href="/escalation" class="nav-link{% if active_nav == 'escalation' %} nav-active{% endif %}">Escalation</a>
<a href="/maintenances" class="nav-link{% if active_nav == 'maintenances' %} nav-active{% endif %}">Maintenances</a>
Expand Down
103 changes: 103 additions & 0 deletions templates/discovery/list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{% extends "base.html" %}

{% block title %}Discovery · GhostMonitor{% endblock %}

{% block page %}
<main class="page-bg">
<div class="container">
<div class="hero">
<h1 class="hero-title">
<span class="terminal-prompt">~ $&nbsp;</span><span>discovery</span><span class="terminal-cursor">▊</span>
</h1>
<div class="hero-sub">
Scan a network range on a schedule; responsive addresses become hosts (with a template applied).
</div>
</div>

{% if error %}<div class="flash flash-error">{{ error }}</div>{% endif %}
{% if flash %}<div class="flash flash-ok">{{ flash }}</div>{% endif %}

<section class="card">
<h2>Rules <span class="count">{{ rules|length }}</span></h2>
{% if rules %}
<table class="table">
<thead>
<tr><th>Name</th><th>Range</th><th>Method</th><th>Template</th><th>Every</th><th>Last run</th><th>Enabled</th><th></th></tr>
</thead>
<tbody>
{% for r in rules %}
<tr>
<td>{{ r.name }}</td>
<td class="muted">{{ r.cidr }}</td>
<td class="muted">{{ r.method.value }}{% if r.method.value == 'tcp' and r.port %}:{{ r.port }}{% endif %}</td>
<td class="muted">{{ template_names.get(r.template_id, '—') }}</td>
<td class="muted">{{ (r.interval_seconds // 60) }}m</td>
<td class="muted">{{ r.last_run_at.strftime('%Y-%m-%d %H:%M') if r.last_run_at else 'never' }}</td>
<td class="muted">{{ 'yes' if r.is_enabled else 'no' }}</td>
<td class="row-actions">
<form method="post" action="/discovery/{{ r.id }}/scan" class="form-inline">
<button type="submit" class="btn-new btn-muted">Scan now</button>
</form>
<form method="post" action="/discovery/{{ r.id }}/delete" class="form-inline" data-confirm="Delete rule {{ r.name }}?">
<button type="submit" class="btn-new btn-muted">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No discovery rules yet.</p>
{% endif %}
</section>

<section class="card" style="margin-top: 20px;">
<h2>New rule</h2>
<form method="post" action="/discovery/new" class="stacked">
<div class="field-row">
<label class="field">
<span>Name</span>
<input type="text" name="name" required maxlength="255" placeholder="LAN sweep">
</label>
<label class="field">
<span>CIDR (≤ 1024 hosts)</span>
<input type="text" name="cidr" required maxlength="64" placeholder="10.0.0.0/24">
</label>
</div>
<div class="field-row">
<label class="field">
<span>Method</span>
<select name="method">
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
</select>
</label>
<label class="field">
<span>TCP port (method = tcp)</span>
<input type="number" name="port" min="1" max="65535" placeholder="22">
</label>
<label class="field">
<span>Apply template (optional)</span>
<select name="template_id">
<option value="">— none —</option>
{% for t in templates_list %}<option value="{{ t.id }}">{{ t.name }}</option>{% endfor %}
</select>
</label>
</div>
<div class="field-row">
<label class="field">
<span>Interval (seconds)</span>
<input type="number" name="interval_seconds" min="60" max="604800" value="3600">
</label>
<label class="field inline">
<input type="checkbox" name="is_enabled" checked>
<span>Enabled</span>
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Create rule</button>
</div>
</form>
</section>
</div>
</main>
{% endblock %}
44 changes: 44 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,47 @@ async def fake_reachable(address: str, method: Any, port: Any) -> bool:

deleted = await client.delete(f"/api/discovery-rules/{rule_id}", headers=auth_headers)
assert deleted.status_code == 204


async def test_discovery_web_create_scan_delete(
web_client: httpx.AsyncClient, session: Any, user: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
created = await web_client.post(
"/discovery/new",
data={
"name": "lan",
"cidr": "10.9.9.0/30",
"method": "ping",
"interval_seconds": "3600",
"is_enabled": "on",
},
)
assert created.status_code in (200, 303)
rules = list(await DiscoveryService(session).list_for_owner(user.id))
assert [r.name for r in rules] == ["lan"]

page = await web_client.get("/discovery")
assert "lan" in page.text and "10.9.9.0/30" in page.text

async def fake_reachable(address: str, method: Any, port: Any) -> bool:
return True

monkeypatch.setattr(ds, "check_reachable", fake_reachable)
scan = await web_client.post(f"/discovery/{rules[0].id}/scan")
assert scan.status_code in (200, 303)

hosts = list(await HostService(session).list_for_owner(user.id))
assert {"10.9.9.1", "10.9.9.2"} <= {h.address for h in hosts}

deleted = await web_client.post(f"/discovery/{rules[0].id}/delete")
assert deleted.status_code in (200, 303)
assert list(await DiscoveryService(session).list_for_owner(user.id)) == []


async def test_discovery_web_rejects_huge_cidr(web_client: httpx.AsyncClient) -> None:
resp = await web_client.post(
"/discovery/new",
data={"name": "huge", "cidr": "10.0.0.0/8", "method": "ping", "interval_seconds": "3600"},
)
assert resp.status_code == 422
assert "max is" in resp.text
Loading