|
|
|
@@ -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("_", " ");
|
|
|
|
|
}
|