Add asset-based monitor setup
This commit is contained in:
+88
-20
@@ -4,14 +4,42 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.credentials import SNMP_CREDENTIAL_TYPE
|
||||
from app.auth.dependencies import get_current_user, require_role
|
||||
from app.db.session import get_db
|
||||
from app.models import AlertRule, Asset, CheckResult, Incident, Monitor, User
|
||||
from app.schemas.core import CheckResultRead, MonitorCreate, MonitorRead, MonitorUpdate, PingMonitorCreate, TcpMonitorCreate, WebsiteMonitorCreate
|
||||
from app.models import AlertRule, Asset, CheckResult, Credential, Incident, Monitor, User
|
||||
from app.schemas.core import (
|
||||
CheckResultRead,
|
||||
MonitorCreate,
|
||||
MonitorRead,
|
||||
MonitorUpdate,
|
||||
PingMonitorCreate,
|
||||
SnmpMonitorsCreate,
|
||||
TcpMonitorCreate,
|
||||
WebsiteMonitorCreate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/monitors", tags=["monitors"])
|
||||
|
||||
|
||||
def _get_asset_or_404(db: Session, asset_id: int) -> Asset:
|
||||
asset = db.get(Asset, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
return asset
|
||||
|
||||
|
||||
def _resolve_asset_id(db: Session, asset_id: int | None, create_asset: bool, asset: Asset) -> int | None:
|
||||
if asset_id is not None:
|
||||
_get_asset_or_404(db, asset_id)
|
||||
return asset_id
|
||||
if not create_asset:
|
||||
return None
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
return asset.id
|
||||
|
||||
|
||||
@router.get("", response_model=list[MonitorRead])
|
||||
def list_monitors(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Monitor]:
|
||||
return list(db.scalars(select(Monitor).order_by(Monitor.name)).all())
|
||||
@@ -23,6 +51,8 @@ def create_monitor(
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Monitor:
|
||||
if payload.asset_id is not None:
|
||||
_get_asset_or_404(db, payload.asset_id)
|
||||
monitor = Monitor(**payload.model_dump())
|
||||
db.add(monitor)
|
||||
db.commit()
|
||||
@@ -36,12 +66,12 @@ def create_website_monitor(
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Monitor:
|
||||
asset_id: int | None = None
|
||||
if payload.create_asset:
|
||||
asset = Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={})
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
asset_id = asset.id
|
||||
asset_id = _resolve_asset_id(
|
||||
db,
|
||||
payload.asset_id,
|
||||
payload.create_asset,
|
||||
Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={}),
|
||||
)
|
||||
|
||||
monitor = Monitor(
|
||||
asset_id=asset_id,
|
||||
@@ -86,12 +116,12 @@ def create_ping_monitor(
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Monitor:
|
||||
asset_id: int | None = None
|
||||
if payload.create_asset:
|
||||
asset = Asset(name=payload.name, asset_type="host", address=payload.host, status="unknown", extra={})
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
asset_id = asset.id
|
||||
asset_id = _resolve_asset_id(
|
||||
db,
|
||||
payload.asset_id,
|
||||
payload.create_asset,
|
||||
Asset(name=payload.name, asset_type="host", address=payload.host, status="unknown", extra={}),
|
||||
)
|
||||
|
||||
monitor = Monitor(
|
||||
asset_id=asset_id,
|
||||
@@ -129,13 +159,13 @@ def create_tcp_monitor(
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Monitor:
|
||||
asset_id: int | None = None
|
||||
target = f"{payload.host}:{payload.port}"
|
||||
if payload.create_asset:
|
||||
asset = Asset(name=payload.name, asset_type="tcp_service", address=target, status="unknown", extra={})
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
asset_id = asset.id
|
||||
asset_id = _resolve_asset_id(
|
||||
db,
|
||||
payload.asset_id,
|
||||
payload.create_asset,
|
||||
Asset(name=payload.name, asset_type="tcp_service", address=target, status="unknown", extra={}),
|
||||
)
|
||||
|
||||
monitor = Monitor(
|
||||
asset_id=asset_id,
|
||||
@@ -167,6 +197,44 @@ def create_tcp_monitor(
|
||||
return monitor
|
||||
|
||||
|
||||
@router.post("/snmp/from-discovery", response_model=list[MonitorRead])
|
||||
def create_snmp_monitors_from_discovery(
|
||||
payload: SnmpMonitorsCreate,
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[Monitor]:
|
||||
asset = _get_asset_or_404(db, payload.asset_id)
|
||||
profile = db.get(Credential, payload.credential_profile_id)
|
||||
if profile is None or profile.credential_type != SNMP_CREDENTIAL_TYPE:
|
||||
raise HTTPException(status_code=404, detail="SNMP credential profile not found")
|
||||
|
||||
monitors: list[Monitor] = []
|
||||
for item in payload.selected_items:
|
||||
monitor = Monitor(
|
||||
asset_id=asset.id,
|
||||
name=f"{asset.name} {item.label}",
|
||||
monitor_type="snmp",
|
||||
target=payload.host,
|
||||
config={
|
||||
"credential_profile_id": payload.credential_profile_id,
|
||||
"item_id": item.item_id,
|
||||
"item_type": item.item_type,
|
||||
"group": item.group,
|
||||
"label": item.label,
|
||||
"unit": item.unit,
|
||||
},
|
||||
interval_seconds=payload.interval_seconds,
|
||||
status="unknown",
|
||||
)
|
||||
db.add(monitor)
|
||||
monitors.append(monitor)
|
||||
|
||||
db.commit()
|
||||
for monitor in monitors:
|
||||
db.refresh(monitor)
|
||||
return monitors
|
||||
|
||||
|
||||
@router.get("/{monitor_id}", response_model=MonitorRead)
|
||||
def get_monitor(monitor_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Monitor:
|
||||
monitor = db.get(Monitor, monitor_id)
|
||||
|
||||
@@ -60,6 +60,7 @@ class MonitorRead(MonitorCreate):
|
||||
class WebsiteMonitorCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
url: str = Field(min_length=1, max_length=512)
|
||||
asset_id: int | None = None
|
||||
expected_status: int = Field(default=200, ge=100, le=599)
|
||||
expected_text: str | None = None
|
||||
unexpected_text: str | None = None
|
||||
@@ -76,6 +77,7 @@ class WebsiteMonitorCreate(BaseModel):
|
||||
class PingMonitorCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
asset_id: int | None = None
|
||||
timeout_seconds: int = Field(default=5, ge=1, le=60)
|
||||
interval_seconds: int = Field(default=60, ge=10)
|
||||
create_asset: bool = True
|
||||
@@ -88,6 +90,7 @@ class TcpMonitorCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
asset_id: int | None = None
|
||||
timeout_seconds: int = Field(default=5, ge=1, le=60)
|
||||
interval_seconds: int = Field(default=60, ge=10)
|
||||
create_asset: bool = True
|
||||
@@ -234,6 +237,14 @@ class SnmpDiscoveryItemRead(BaseModel):
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class SnmpMonitorsCreate(BaseModel):
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
asset_id: int
|
||||
credential_profile_id: int
|
||||
selected_items: list[SnmpDiscoveryItemRead] = Field(min_length=1)
|
||||
interval_seconds: int = Field(default=60, ge=10)
|
||||
|
||||
|
||||
class SnmpDiscoveryRead(BaseModel):
|
||||
host: str
|
||||
credential_profile_id: int
|
||||
|
||||
@@ -2,7 +2,8 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AlertRule, Asset, Monitor
|
||||
from app.core.secrets import encrypt_secret
|
||||
from app.models import AlertRule, Asset, Credential, Monitor
|
||||
|
||||
|
||||
def test_create_website_monitor_creates_asset_and_alert_rule(client: TestClient, db_session: Session) -> None:
|
||||
@@ -72,3 +73,141 @@ def test_create_website_monitor_can_skip_default_alert_rule(client: TestClient,
|
||||
assert monitor is not None
|
||||
assert monitor.asset_id is None
|
||||
assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id)).all() == []
|
||||
|
||||
|
||||
def test_create_website_monitor_can_attach_existing_asset_without_default_alert(client: TestClient, db_session: Session) -> None:
|
||||
asset = Asset(name="Existing App", asset_type="application", address="app.example.com", status="unknown", extra={})
|
||||
db_session.add(asset)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/monitors/website",
|
||||
json={
|
||||
"name": "Existing App HTTPS",
|
||||
"url": "https://app.example.com",
|
||||
"asset_id": asset.id,
|
||||
"create_asset": True,
|
||||
"alert_enabled": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
monitor = db_session.get(Monitor, body["id"])
|
||||
assert monitor is not None
|
||||
assert monitor.asset_id == asset.id
|
||||
assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id)).all() == []
|
||||
assert db_session.scalars(select(Asset)).all() == [asset]
|
||||
|
||||
|
||||
def test_create_ping_monitor_rejects_missing_asset(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/monitors/ping",
|
||||
json={
|
||||
"name": "Missing Asset Ping",
|
||||
"host": "192.0.2.10",
|
||||
"asset_id": 999,
|
||||
"create_asset": False,
|
||||
"alert_enabled": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_tcp_monitor_can_attach_existing_asset(client: TestClient, db_session: Session) -> None:
|
||||
asset = Asset(name="Router", asset_type="network_device", address="192.0.2.1", status="unknown", extra={})
|
||||
db_session.add(asset)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/monitors/tcp",
|
||||
json={
|
||||
"name": "Router SSH",
|
||||
"host": "192.0.2.1",
|
||||
"port": 22,
|
||||
"asset_id": asset.id,
|
||||
"create_asset": False,
|
||||
"alert_enabled": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["asset_id"] == asset.id
|
||||
assert body["target"] == "192.0.2.1:22"
|
||||
|
||||
|
||||
def test_create_snmp_monitors_from_discovery_attaches_asset_and_skips_alerts(client: TestClient, db_session: Session) -> None:
|
||||
asset = Asset(name="Core Switch", asset_type="network_device", address="192.0.2.10", status="unknown", extra={})
|
||||
profile = Credential(
|
||||
name="Core Switch Read Only",
|
||||
credential_type="snmp",
|
||||
encrypted_secret=encrypt_secret("private-community"),
|
||||
extra={"version": "2c"},
|
||||
)
|
||||
db_session.add_all([asset, profile])
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/monitors/snmp/from-discovery",
|
||||
json={
|
||||
"host": "192.0.2.10",
|
||||
"asset_id": asset.id,
|
||||
"credential_profile_id": profile.id,
|
||||
"interval_seconds": 120,
|
||||
"selected_items": [
|
||||
{
|
||||
"item_id": "device.uptime",
|
||||
"item_type": "device_uptime",
|
||||
"group": "Device Health",
|
||||
"label": "Device uptime",
|
||||
"unit": "seconds",
|
||||
},
|
||||
{
|
||||
"item_id": "interface.1.status",
|
||||
"item_type": "interface_status",
|
||||
"group": "Interface uplink",
|
||||
"label": "uplink status",
|
||||
"unit": None,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == 2
|
||||
assert {monitor["name"] for monitor in body} == {"Core Switch Device uptime", "Core Switch uplink status"}
|
||||
assert all(monitor["asset_id"] == asset.id for monitor in body)
|
||||
assert all(monitor["monitor_type"] == "snmp" for monitor in body)
|
||||
assert all(monitor["interval_seconds"] == 120 for monitor in body)
|
||||
assert all("1.3.6" not in str(monitor["config"]) for monitor in body)
|
||||
|
||||
monitor_ids = [monitor["id"] for monitor in body]
|
||||
assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id.in_(monitor_ids))).all() == []
|
||||
|
||||
|
||||
def test_create_snmp_monitors_rejects_missing_profile(client: TestClient, db_session: Session) -> None:
|
||||
asset = Asset(name="Core Switch", asset_type="network_device", address="192.0.2.10", status="unknown", extra={})
|
||||
db_session.add(asset)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/monitors/snmp/from-discovery",
|
||||
json={
|
||||
"host": "192.0.2.10",
|
||||
"asset_id": asset.id,
|
||||
"credential_profile_id": 999,
|
||||
"selected_items": [
|
||||
{
|
||||
"item_id": "device.uptime",
|
||||
"item_type": "device_uptime",
|
||||
"group": "Device Health",
|
||||
"label": "Device uptime",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
+11
-5
@@ -8,7 +8,7 @@ Last updated: 2026-05-23
|
||||
- Local repository path: `/home/ksmith/projects/OrbitalWard`
|
||||
- Git remote: `https://git.firebugit.com/ksmith/OrbitalWard.git`
|
||||
- Main branch: `main`
|
||||
- Latest pushed commit at last update: `3b75075 Rename project to OrbitalWard`
|
||||
- Latest pushed commit at last update: `8b5dea1 Add guided SNMP discovery UI`
|
||||
|
||||
The project was previously named InfraPulse. Do not reintroduce the old name in product copy, package names, environment variables, service names, or docs unless explicitly discussing historical context.
|
||||
|
||||
@@ -34,15 +34,21 @@ OrbitalWard is a secure monitoring appliance focused on the v0.1 vertical slice:
|
||||
- Alert rules, incident opening/resolution, acknowledge, silence, and webhook notifications.
|
||||
- Generic webhook, Mattermost, and Zoom Team Chat notification channels.
|
||||
- Saved webhook URLs encrypted at rest and not returned to the UI.
|
||||
- Guided SNMP device discovery is v0.1 scope, but not yet implemented.
|
||||
- SNMPv2c credential profiles with encrypted community strings.
|
||||
- Guided SNMP device discovery with friendly device, interface, and monitorable item results.
|
||||
- Asset setup supports creating, selecting, and deleting assets, plus attaching ping, TCP, website, and SNMP monitors without creating alert rules automatically.
|
||||
|
||||
## Verification State
|
||||
|
||||
After the rename and TLS expiry work, these checks passed in Docker:
|
||||
Recent Docker checks:
|
||||
|
||||
- `docker compose -f docker-compose.dev.yml up -d --build`
|
||||
- `docker compose -f docker-compose.dev.yml exec -T backend python -m pytest tests`
|
||||
- `docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck`
|
||||
- `docker compose -f docker-compose.dev.yml exec -T frontend npm run build`
|
||||
|
||||
Earlier rename and monitor work also verified:
|
||||
|
||||
- `docker compose -f docker-compose.dev.yml up -d --build`
|
||||
- `docker compose -f docker-compose.dev.yml exec -T worker python -m compileall app`
|
||||
- Backend health returned `{"status":"ok","service":"orbitalward-backend"}`.
|
||||
- Direct worker probes for TCP and ICMP ping checks passed inside the Docker network.
|
||||
@@ -76,7 +82,7 @@ Issue source docs:
|
||||
- `docs/progress.md`
|
||||
- `docs/roadmap.md`
|
||||
|
||||
Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, SNMPv2c credential profiles, the SNMP device discovery API, and guided SNMP discovery UI. The next recommended implementation issue is creating monitors from SNMP discovery selections.
|
||||
Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, SNMPv2c credential profiles, the SNMP device discovery API, guided SNMP discovery UI, and asset-based monitor setup. The next recommended implementation work is SNMP collection for configured SNMP monitors and friendly metric/profile mapping.
|
||||
|
||||
## Guardrails
|
||||
|
||||
|
||||
+13
-8
@@ -80,6 +80,16 @@ Implemented guided SNMP discovery UI slice:
|
||||
- UI displays friendly monitorable item groups and supports selecting items for the next monitor-creation step.
|
||||
- Normal discovery UI avoids raw SNMP OIDs and saved secret values.
|
||||
|
||||
Implemented asset-based monitor setup slice:
|
||||
|
||||
- Assets page can create a new asset or select an existing asset before configuring monitors.
|
||||
- Assets page can delete assets, with confirmation that attached monitors are also removed.
|
||||
- Asset setup supports choosing ping, TCP, website, and SNMP monitoring in any combination.
|
||||
- Website, ping, and TCP monitor APIs can attach new monitors to an existing asset without creating duplicate assets.
|
||||
- Asset setup creates monitors without automatically creating alert rules; alerting remains managed separately.
|
||||
- SNMP setup can run guided discovery from the asset flow and save selected friendly items as SNMP monitors attached to the asset.
|
||||
- SNMP monitor creation stores friendly discovery metadata and avoids raw OIDs in normal UI/API responses.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- General credential vault workflows beyond SNMP profiles are not complete.
|
||||
@@ -87,8 +97,7 @@ Implemented guided SNMP discovery UI slice:
|
||||
- User management UI is not implemented.
|
||||
- Role management is basic and needs full admin flows.
|
||||
- Richer alert condition editing is not implemented yet.
|
||||
- SNMP monitor creation from selected discovery items is not implemented yet.
|
||||
- SNMP collection for interface status, traffic counters, errors, uptime, CPU, and memory checks is not implemented yet.
|
||||
- SNMP monitors can be configured, but SNMP collection for interface status, traffic counters, errors, uptime, CPU, memory, storage, and sensor checks is not implemented yet.
|
||||
- Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications.
|
||||
- Email/SMTP notifications are not implemented yet.
|
||||
- Graphing exists only as placeholders; metric visualization is not implemented.
|
||||
@@ -98,8 +107,8 @@ Implemented guided SNMP discovery UI slice:
|
||||
|
||||
## Recommended Next Work
|
||||
|
||||
1. Create monitors from SNMP discovery selections.
|
||||
2. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
|
||||
1. Add SNMP interface status, traffic, errors, uptime, CPU, memory, storage, and sensor collection.
|
||||
2. Add SNMP profile mapping for friendly metric names across common vendors.
|
||||
3. Add notification policy/routing controls.
|
||||
4. Add email/SMTP notification channel.
|
||||
5. Add audit event writes for auth, monitor, credential, notification, and incident actions.
|
||||
@@ -108,10 +117,6 @@ Implemented guided SNMP discovery UI slice:
|
||||
8. Add graphs for website response time and monitor status history.
|
||||
9. Add richer alert condition editing.
|
||||
10. Add frontend coverage for monitor, alert, and notification workflows.
|
||||
8. Add user administration UI.
|
||||
9. Add graphs for website response time and monitor status history.
|
||||
10. Add richer alert condition editing.
|
||||
11. Add frontend coverage for monitor, alert, and notification workflows.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AlertRule,
|
||||
AlertRuleUpdate,
|
||||
Asset,
|
||||
AssetCreate,
|
||||
CheckResult,
|
||||
Incident,
|
||||
Monitor,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
SnmpCredentialProfileUpdate,
|
||||
SnmpDiscoveryRequest,
|
||||
SnmpDiscoveryResult,
|
||||
SnmpMonitorsCreate,
|
||||
TcpMonitorCreate,
|
||||
User,
|
||||
WebsiteMonitorCreate,
|
||||
@@ -65,6 +67,15 @@ export async function login(email: string, password: string): Promise<LoginRespo
|
||||
export const api = {
|
||||
me: (token: string) => request<User>("/auth/me", token),
|
||||
assets: (token: string) => request<Asset[]>("/assets", token),
|
||||
createAsset: (token: string, payload: AssetCreate) =>
|
||||
request<Asset>("/assets", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteAsset: (token: string, assetId: number) =>
|
||||
request<void>(`/assets/${assetId}`, token, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
monitors: (token: string) => request<Monitor[]>("/monitors", token),
|
||||
createWebsiteMonitor: (token: string, payload: WebsiteMonitorCreate) =>
|
||||
request<Monitor>("/monitors/website", token, {
|
||||
@@ -146,4 +157,9 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
createSnmpMonitorsFromDiscovery: (token: string, payload: SnmpMonitorsCreate) =>
|
||||
request<Monitor[]>("/monitors/snmp/from-discovery", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "../api/client";
|
||||
import { Shell, type PageId } from "../components/Shell";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { AlertsPage } from "../pages/AlertsPage";
|
||||
import { AssetsPage } from "../pages/AssetsPage";
|
||||
import { CredentialsPage } from "../pages/CredentialsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { DiscoveryPage } from "../pages/DiscoveryPage";
|
||||
@@ -75,9 +76,7 @@ export function App() {
|
||||
<Shell currentPage={page} onPageChange={handlePageChange} onSignOut={auth.signOut} user={auth.user}>
|
||||
{page === "dashboard" ? <DashboardPage assets={assets} monitors={monitors} incidents={incidents} /> : null}
|
||||
{page === "assets" ? (
|
||||
<ListPage title="Assets" description="Servers, devices, websites, containers, services, and infrastructure targets.">
|
||||
<SimpleTable rows={assets.map((asset) => [asset.name, asset.asset_type, asset.address ?? "-", asset.status])} columns={["Name", "Type", "Address", "Status"]} />
|
||||
</ListPage>
|
||||
<AssetsPage token={auth.token} assets={assets} monitors={monitors} onChanged={refreshData} />
|
||||
) : null}
|
||||
{page === "websites" ? (
|
||||
<WebsitesPage token={auth.token} monitors={monitors} onCreated={refreshData} />
|
||||
@@ -102,40 +101,3 @@ function getIncidentIdFromPath(): number | null {
|
||||
if (!match) return null;
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function SimpleTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead className="text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="border-b border-line px-3 py-2 font-medium">
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length ? (
|
||||
rows.map((row, rowIndex) => (
|
||||
<tr key={`${row.join("-")}-${rowIndex}`}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={`${cell}-${cellIndex}`} className="border-b border-line px-3 py-3 text-slate-300">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="px-3 py-5 text-slate-400" colSpan={columns.length}>
|
||||
No records yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Activity, CheckSquare, Globe2, Network, PlugZap, Plus, RefreshCw, Router, Save, Search, Server, Square, Trash2 } from "lucide-react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "../components/Button";
|
||||
import type { Asset, Monitor, SnmpCredentialProfile, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api";
|
||||
|
||||
interface AssetsPageProps {
|
||||
token: string;
|
||||
assets: Asset[];
|
||||
monitors: Monitor[];
|
||||
onChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
type SetupAssetId = "new" | number;
|
||||
|
||||
const assetTypes = [
|
||||
{ value: "server", label: "Server" },
|
||||
{ value: "network_device", label: "Network Device" },
|
||||
{ value: "website", label: "Website" },
|
||||
{ value: "service", label: "Service" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPageProps) {
|
||||
const [setupAssetId, setSetupAssetId] = useState<SetupAssetId>("new");
|
||||
const selectedAsset = typeof setupAssetId === "number" ? assets.find((asset) => asset.id === setupAssetId) ?? null : null;
|
||||
const selectedAssetMonitors = selectedAsset ? monitors.filter((monitor) => monitor.asset_id === selectedAsset.id) : [];
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [assetType, setAssetType] = useState("server");
|
||||
const [address, setAddress] = useState("");
|
||||
const [intervalSeconds, setIntervalSeconds] = useState(60);
|
||||
const [pingEnabled, setPingEnabled] = useState(true);
|
||||
const [tcpEnabled, setTcpEnabled] = useState(false);
|
||||
const [websiteEnabled, setWebsiteEnabled] = useState(false);
|
||||
const [snmpEnabled, setSnmpEnabled] = useState(false);
|
||||
const [tcpPort, setTcpPort] = useState(443);
|
||||
const [websiteUrl, setWebsiteUrl] = useState("https://");
|
||||
const [profiles, setProfiles] = useState<SnmpCredentialProfile[]>([]);
|
||||
const [profileId, setProfileId] = useState<number | "">("");
|
||||
const [discoveryResult, setDiscoveryResult] = useState<SnmpDiscoveryResult | null>(null);
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
const [loadingProfiles, setLoadingProfiles] = useState(false);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingAssetId, setDeletingAssetId] = useState<number | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const setupName = selectedAsset?.name ?? name.trim();
|
||||
const setupAddress = selectedAsset?.address?.trim() || address.trim();
|
||||
const selectedItems = useMemo(
|
||||
() => (discoveryResult?.monitorable_items ?? []).filter((item) => selectedItemIds.has(item.item_id)),
|
||||
[discoveryResult, selectedItemIds]
|
||||
);
|
||||
const groupedItems = useMemo(() => groupItems(discoveryResult?.monitorable_items ?? []), [discoveryResult]);
|
||||
|
||||
async function refreshProfiles() {
|
||||
setLoadingProfiles(true);
|
||||
try {
|
||||
const nextProfiles = await api.snmpCredentialProfiles(token);
|
||||
setProfiles(nextProfiles);
|
||||
setProfileId((current) => current || nextProfiles[0]?.id || "");
|
||||
} finally {
|
||||
setLoadingProfiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshProfiles().catch(() => setProfiles([]));
|
||||
}, [token]);
|
||||
|
||||
function resetDiscovery() {
|
||||
setDiscoveryResult(null);
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
|
||||
function handleAssetChoice(value: string) {
|
||||
setSetupAssetId(value === "new" ? "new" : Number(value));
|
||||
resetDiscovery();
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function runSnmpDiscovery() {
|
||||
if (!profileId || !setupAddress) return;
|
||||
setDiscovering(true);
|
||||
setMessage(null);
|
||||
resetDiscovery();
|
||||
try {
|
||||
const discovered = await api.discoverSnmpDevice(token, {
|
||||
host: setupAddress,
|
||||
credential_profile_id: profileId,
|
||||
});
|
||||
setDiscoveryResult(discovered);
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "SNMP discovery failed");
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
if (!setupName) {
|
||||
throw new Error("Asset name is required");
|
||||
}
|
||||
if ((pingEnabled || tcpEnabled || snmpEnabled) && !setupAddress) {
|
||||
throw new Error("Address is required for ping, TCP, and SNMP monitoring");
|
||||
}
|
||||
if (websiteEnabled && !normalizedWebsiteUrl(websiteUrl, setupAddress)) {
|
||||
throw new Error("Website URL is required");
|
||||
}
|
||||
if (snmpEnabled && !profileId) {
|
||||
throw new Error("SNMP profile is required");
|
||||
}
|
||||
if (snmpEnabled && selectedItems.length === 0) {
|
||||
throw new Error("Select at least one SNMP item");
|
||||
}
|
||||
|
||||
const asset =
|
||||
selectedAsset ??
|
||||
(await api.createAsset(token, {
|
||||
name: setupName,
|
||||
asset_type: assetType,
|
||||
address: setupAddress || null,
|
||||
metadata: {},
|
||||
}));
|
||||
|
||||
const monitorCreates: Promise<unknown>[] = [];
|
||||
if (pingEnabled) {
|
||||
monitorCreates.push(
|
||||
api.createPingMonitor(token, {
|
||||
name: `${asset.name} ping`,
|
||||
host: setupAddress,
|
||||
asset_id: asset.id,
|
||||
timeout_seconds: 5,
|
||||
interval_seconds: intervalSeconds,
|
||||
create_asset: false,
|
||||
alert_enabled: false,
|
||||
alert_severity: "warning",
|
||||
failure_threshold: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (tcpEnabled) {
|
||||
monitorCreates.push(
|
||||
api.createTcpMonitor(token, {
|
||||
name: `${asset.name} TCP ${tcpPort}`,
|
||||
host: setupAddress,
|
||||
port: tcpPort,
|
||||
asset_id: asset.id,
|
||||
timeout_seconds: 5,
|
||||
interval_seconds: intervalSeconds,
|
||||
create_asset: false,
|
||||
alert_enabled: false,
|
||||
alert_severity: "warning",
|
||||
failure_threshold: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (websiteEnabled) {
|
||||
const url = normalizedWebsiteUrl(websiteUrl, setupAddress);
|
||||
monitorCreates.push(
|
||||
api.createWebsiteMonitor(token, {
|
||||
name: `${asset.name} website`,
|
||||
url,
|
||||
asset_id: asset.id,
|
||||
expected_status: 200,
|
||||
expected_text: null,
|
||||
unexpected_text: null,
|
||||
timeout_seconds: 10,
|
||||
check_tls_expiry: url.toLowerCase().startsWith("https://"),
|
||||
tls_warning_days: 30,
|
||||
interval_seconds: intervalSeconds,
|
||||
create_asset: false,
|
||||
alert_enabled: false,
|
||||
alert_severity: "critical",
|
||||
failure_threshold: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (snmpEnabled && profileId) {
|
||||
monitorCreates.push(
|
||||
api.createSnmpMonitorsFromDiscovery(token, {
|
||||
host: setupAddress,
|
||||
asset_id: asset.id,
|
||||
credential_profile_id: profileId,
|
||||
selected_items: selectedItems,
|
||||
interval_seconds: intervalSeconds,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(monitorCreates);
|
||||
resetForm(asset.id);
|
||||
await onChanged();
|
||||
setMessage("Asset setup saved");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Could not save asset setup");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(nextAssetId?: number) {
|
||||
if (nextAssetId) {
|
||||
setSetupAssetId(nextAssetId);
|
||||
} else {
|
||||
setSetupAssetId("new");
|
||||
}
|
||||
setName("");
|
||||
setAssetType("server");
|
||||
setAddress("");
|
||||
setIntervalSeconds(60);
|
||||
setPingEnabled(true);
|
||||
setTcpEnabled(false);
|
||||
setWebsiteEnabled(false);
|
||||
setSnmpEnabled(false);
|
||||
setTcpPort(443);
|
||||
setWebsiteUrl("https://");
|
||||
resetDiscovery();
|
||||
}
|
||||
|
||||
function toggleItem(itemId: string) {
|
||||
setSelectedItemIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId);
|
||||
} else {
|
||||
next.add(itemId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectGroup(items: SnmpDiscoveryItem[]) {
|
||||
setSelectedItemIds((current) => {
|
||||
const next = new Set(current);
|
||||
const allSelected = items.every((item) => next.has(item.item_id));
|
||||
for (const item of items) {
|
||||
if (allSelected) {
|
||||
next.delete(item.item_id);
|
||||
} else {
|
||||
next.add(item.item_id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAsset(asset: Asset, monitorCount: number) {
|
||||
const confirmed = window.confirm(
|
||||
monitorCount
|
||||
? `Delete ${asset.name} and its ${monitorCount} attached monitor${monitorCount === 1 ? "" : "s"}?`
|
||||
: `Delete ${asset.name}?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeletingAssetId(asset.id);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.deleteAsset(token, asset.id);
|
||||
if (selectedAsset?.id === asset.id) {
|
||||
resetForm();
|
||||
}
|
||||
await onChanged();
|
||||
setMessage("Asset deleted");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Could not delete asset");
|
||||
} finally {
|
||||
setDeletingAssetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Assets</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">Add assets and choose the monitors attached to each one.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onChanged}>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-[460px_minmax(0,1fr)]">
|
||||
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={handleSubmit}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={18} className="text-pulse" />
|
||||
<h2 className="text-base font-semibold">Asset Setup</h2>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Asset</span>
|
||||
<select className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={setupAssetId} onChange={(event) => handleAssetChoice(event.target.value)}>
|
||||
<option value="new">New asset</option>
|
||||
{assets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedAsset ? (
|
||||
<div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">
|
||||
<div className="font-medium">{selectedAsset.name}</div>
|
||||
<div className="mt-1 text-slate-400">{friendlyAssetType(selectedAsset.asset_type)} - {selectedAsset.address || "No address"}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Name</span>
|
||||
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.3fr]">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Type</span>
|
||||
<select className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={assetType} onChange={(event) => setAssetType(event.target.value)}>
|
||||
{assetTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Address</span>
|
||||
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={address} onChange={(event) => { setAddress(event.target.value); resetDiscovery(); }} placeholder="192.168.1.1 or app.example.com" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<MethodToggle active={pingEnabled} icon={<Activity size={16} />} label="Ping" onClick={() => setPingEnabled((value) => !value)} />
|
||||
<MethodToggle active={tcpEnabled} icon={<PlugZap size={16} />} label="TCP" onClick={() => setTcpEnabled((value) => !value)} />
|
||||
<MethodToggle active={websiteEnabled} icon={<Globe2 size={16} />} label="Website" onClick={() => setWebsiteEnabled((value) => !value)} />
|
||||
<MethodToggle active={snmpEnabled} icon={<Router size={16} />} label="SNMP" onClick={() => setSnmpEnabled((value) => !value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Interval</span>
|
||||
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={intervalSeconds} onChange={(event) => setIntervalSeconds(Number(event.target.value))} min={10} type="number" />
|
||||
</label>
|
||||
{tcpEnabled ? (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">TCP Port</span>
|
||||
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={tcpPort} onChange={(event) => setTcpPort(Number(event.target.value))} min={1} max={65535} type="number" />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{websiteEnabled ? (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Website URL</span>
|
||||
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={websiteUrl} onChange={(event) => setWebsiteUrl(event.target.value)} placeholder={setupAddress ? normalizedWebsiteUrl("", setupAddress) : "https://example.com"} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{snmpEnabled ? (
|
||||
<div className="space-y-3 rounded-md border border-line bg-slate-950 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">SNMP Profile</span>
|
||||
<select className="h-10 w-full rounded-md border border-line bg-[#0d131c] px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={profileId} onChange={(event) => { setProfileId(Number(event.target.value)); resetDiscovery(); }} required={snmpEnabled}>
|
||||
<option value="" disabled>
|
||||
Select a profile
|
||||
</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} - SNMPv{profile.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button className="w-full sm:w-auto" disabled={discovering || !setupAddress || !profileId || loadingProfiles} onClick={runSnmpDiscovery} type="button" variant="ghost">
|
||||
<Search size={16} />
|
||||
{discovering ? "Scanning..." : "Scan"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!profiles.length ? <div className="rounded-md border border-line bg-[#0d131c] p-3 text-sm text-slate-400">Add an SNMP profile before scanning.</div> : null}
|
||||
|
||||
{discoveryResult ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 rounded-md border border-line bg-[#0d131c] p-3 text-sm sm:grid-cols-3">
|
||||
<SummaryItem label="Device" value={discoveryResult.device_name || discoveryResult.host} />
|
||||
<SummaryItem label="Interfaces" value={String(discoveryResult.interfaces.length)} />
|
||||
<SummaryItem label="Selected" value={String(selectedItems.length)} />
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto rounded-md border border-line bg-[#0d131c]">
|
||||
{groupedItems.map(({ group, items }) => (
|
||||
<div key={group} className="border-b border-line p-3 last:border-b-0">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{group}</h3>
|
||||
<Button className="h-8 px-3" onClick={() => selectGroup(items)} type="button" variant="ghost">
|
||||
{items.every((item) => selectedItemIds.has(item.item_id)) ? <CheckSquare size={15} /> : <Square size={15} />}
|
||||
Group
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{items.map((item) => (
|
||||
<button key={item.item_id} className="flex min-h-12 items-center gap-3 rounded-md border border-line bg-slate-950 px-3 py-2 text-left transition hover:bg-slate-900" onClick={() => toggleItem(item.item_id)} type="button">
|
||||
{selectedItemIds.has(item.item_id) ? <CheckSquare className="shrink-0 text-pulse" size={18} /> : <Square className="shrink-0 text-slate-500" size={18} />}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-slate-200">{item.label}</span>
|
||||
<span className="block text-xs text-slate-500">{friendlyItemType(item.item_type)}{item.unit ? `, ${item.unit}` : ""}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message ? <div className={`rounded-md border p-3 text-sm ${message.includes("saved") || message.includes("deleted") ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : "border-red-500/40 bg-red-950/40 text-red-200"}`}>{message}</div> : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={() => resetForm()} type="button" variant="ghost">
|
||||
<Plus size={16} />
|
||||
New
|
||||
</Button>
|
||||
<Button className="flex-1" disabled={submitting} type="submit">
|
||||
<Save size={16} />
|
||||
{submitting ? "Saving..." : "Save Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="flex items-center gap-2 border-b border-line p-4">
|
||||
<Network size={18} className="text-pulse" />
|
||||
<h2 className="text-base font-semibold">Configured Assets</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{assets.length ? (
|
||||
assets.map((asset) => {
|
||||
const assetMonitors = monitors.filter((monitor) => monitor.asset_id === asset.id);
|
||||
return (
|
||||
<div key={asset.id} className={`grid gap-2 p-4 transition hover:bg-slate-900/50 md:grid-cols-[1fr_auto] md:items-center ${selectedAsset?.id === asset.id ? "bg-slate-900/70" : ""}`}>
|
||||
<button className="grid w-full gap-2 text-left md:grid-cols-[1fr_160px_120px] md:items-center" onClick={() => handleAssetChoice(String(asset.id))} type="button">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-slate-100">{asset.name}</span>
|
||||
<span className="block truncate text-sm text-slate-400">{asset.address || "No address"}</span>
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">{friendlyAssetType(asset.asset_type)}</span>
|
||||
<span className="text-sm text-slate-300">{assetMonitors.length} monitor{assetMonitors.length === 1 ? "" : "s"}</span>
|
||||
</button>
|
||||
<Button aria-label={`Delete ${asset.name}`} className="h-8 px-3 text-red-100 md:w-24" disabled={deletingAssetId === asset.id} onClick={() => deleteAsset(asset, assetMonitors.length)} title="Delete asset" type="button" variant="ghost">
|
||||
<Trash2 className="text-red-200" size={15} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No assets yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="border-b border-line p-4">
|
||||
<h2 className="text-base font-semibold">{selectedAsset ? `${selectedAsset.name} Monitors` : "Asset Monitors"}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{selectedAsset && selectedAssetMonitors.length ? (
|
||||
selectedAssetMonitors.map((monitor) => (
|
||||
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_110px_110px_120px] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{monitor.name}</div>
|
||||
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
|
||||
</div>
|
||||
<span className="text-sm uppercase text-slate-400">{monitor.monitor_type}</span>
|
||||
<Status status={monitor.status} />
|
||||
<div className="text-sm text-slate-400 md:text-right">{monitor.interval_seconds}s</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">{selectedAsset ? "No monitors attached to this asset." : "Select an asset to view attached monitors."}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MethodToggle({ active, icon, label, onClick }: { active: boolean; icon: ReactNode; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className={`flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium transition ${active ? "border-teal-400/60 bg-teal-950/40 text-teal-100" : "border-line bg-slate-950 text-slate-400 hover:bg-slate-900 hover:text-slate-100"}`} onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs uppercase text-slate-500">{label}</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-200">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ status }: { status: string }) {
|
||||
const classes =
|
||||
status === "up"
|
||||
? "border-teal-500/40 bg-teal-950/40 text-teal-200"
|
||||
: status === "down"
|
||||
? "border-red-500/40 bg-red-950/40 text-red-200"
|
||||
: status === "warning"
|
||||
? "border-amber-500/40 bg-amber-950/40 text-amber-200"
|
||||
: "border-slate-600 bg-slate-900 text-slate-300";
|
||||
return <span className={`inline-flex h-7 w-24 items-center justify-center rounded-md border text-xs font-medium ${classes}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function groupItems(items: SnmpDiscoveryItem[]) {
|
||||
const groups = new Map<string, SnmpDiscoveryItem[]>();
|
||||
for (const item of items) {
|
||||
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
|
||||
}
|
||||
return Array.from(groups, ([group, groupItems]) => ({ group, items: groupItems }));
|
||||
}
|
||||
|
||||
function normalizedWebsiteUrl(url: string, address: string) {
|
||||
const candidate = url.trim() && url.trim() !== "https://" ? url.trim() : address.trim();
|
||||
if (!candidate) return "";
|
||||
if (candidate.startsWith("http://") || candidate.startsWith("https://")) return candidate;
|
||||
return `https://${candidate}`;
|
||||
}
|
||||
|
||||
function friendlyAssetType(value: string) {
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function friendlyItemType(value: string) {
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export interface Asset {
|
||||
asset_type: string;
|
||||
address?: string | null;
|
||||
status: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AssetCreate {
|
||||
name: string;
|
||||
asset_type: string;
|
||||
address?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Monitor {
|
||||
@@ -172,9 +180,18 @@ export interface SnmpDiscoveryResult {
|
||||
monitorable_items: SnmpDiscoveryItem[];
|
||||
}
|
||||
|
||||
export interface SnmpMonitorsCreate {
|
||||
host: string;
|
||||
asset_id: number;
|
||||
credential_profile_id: number;
|
||||
selected_items: SnmpDiscoveryItem[];
|
||||
interval_seconds: number;
|
||||
}
|
||||
|
||||
export interface WebsiteMonitorCreate {
|
||||
name: string;
|
||||
url: string;
|
||||
asset_id?: number | null;
|
||||
expected_status: number;
|
||||
expected_text?: string | null;
|
||||
unexpected_text?: string | null;
|
||||
@@ -191,6 +208,7 @@ export interface WebsiteMonitorCreate {
|
||||
export interface PingMonitorCreate {
|
||||
name: string;
|
||||
host: string;
|
||||
asset_id?: number | null;
|
||||
timeout_seconds: number;
|
||||
interval_seconds: number;
|
||||
create_asset: boolean;
|
||||
@@ -203,6 +221,7 @@ export interface TcpMonitorCreate {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
asset_id?: number | null;
|
||||
timeout_seconds: number;
|
||||
interval_seconds: number;
|
||||
create_asset: boolean;
|
||||
|
||||
Reference in New Issue
Block a user