Add asset-based monitor setup

This commit is contained in:
Keith Smith
2026-05-23 21:07:05 -06:00
parent 8b5dea152e
commit bd6c508c94
9 changed files with 858 additions and 74 deletions
+16
View File
@@ -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),
}),
};
+2 -40
View File
@@ -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>
);
}
+558
View File
@@ -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("_", " ");
}
+19
View File
@@ -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;