Initial InfraPulse scaffold
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
Asset,
|
||||
Incident,
|
||||
Monitor,
|
||||
MonitorUpdate,
|
||||
NotificationChannel,
|
||||
NotificationChannelCreate,
|
||||
NotificationChannelUpdate,
|
||||
User,
|
||||
WebsiteMonitorCreate,
|
||||
} from "../types/api";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, token?: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Request failed with ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<LoginResponse> {
|
||||
const body = new URLSearchParams();
|
||||
body.set("username", email);
|
||||
body.set("password", password);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
return (await response.json()) as LoginResponse;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: (token: string) => request<User>("/auth/me", token),
|
||||
assets: (token: string) => request<Asset[]>("/assets", token),
|
||||
monitors: (token: string) => request<Monitor[]>("/monitors", token),
|
||||
createWebsiteMonitor: (token: string, payload: WebsiteMonitorCreate) =>
|
||||
request<Monitor>("/monitors/website", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateMonitor: (token: string, monitorId: number, payload: MonitorUpdate) =>
|
||||
request<Monitor>(`/monitors/${monitorId}`, token, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteMonitor: (token: string, monitorId: number) =>
|
||||
request<void>(`/monitors/${monitorId}`, token, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
incidents: (token: string) => request<Incident[]>("/incidents", token),
|
||||
acknowledgeIncident: (token: string, incidentId: number) =>
|
||||
request<Incident>(`/incidents/${incidentId}/acknowledge`, token, {
|
||||
method: "POST",
|
||||
}),
|
||||
silenceIncident: (token: string, incidentId: number, minutes = 60) =>
|
||||
request<Incident>(`/incidents/${incidentId}/silence?minutes=${minutes}`, token, {
|
||||
method: "POST",
|
||||
}),
|
||||
notificationChannels: (token: string) => request<NotificationChannel[]>("/notifications/channels", token),
|
||||
createNotificationChannel: (token: string, payload: NotificationChannelCreate) =>
|
||||
request<NotificationChannel>("/notifications/channels", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateNotificationChannel: (token: string, channelId: number, payload: NotificationChannelUpdate) =>
|
||||
request<NotificationChannel>(`/notifications/channels/${channelId}`, token, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
testNotificationChannel: (token: string, channelId: number) =>
|
||||
request<{ status: string; message: string }>(`/notifications/channels/${channelId}/test`, token, {
|
||||
method: "POST",
|
||||
}),
|
||||
deleteNotificationChannel: (token: string, channelId: number) =>
|
||||
request<void>(`/notifications/channels/${channelId}`, token, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { Shell, type PageId } from "../components/Shell";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { AlertsPage } from "../pages/AlertsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { ListPage } from "../pages/ListPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { WebsitesPage } from "../pages/WebsitesPage";
|
||||
import type { Asset, Incident, Monitor } from "../types/api";
|
||||
|
||||
export function App() {
|
||||
const auth = useAuth();
|
||||
const initialIncidentId = getIncidentIdFromPath();
|
||||
const [page, setPage] = useState<PageId>(initialIncidentId ? "alerts" : "dashboard");
|
||||
const [selectedIncidentId, setSelectedIncidentId] = useState<number | null>(initialIncidentId);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [monitors, setMonitors] = useState<Monitor[]>([]);
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
|
||||
async function refreshData() {
|
||||
if (!auth.token || !auth.user) return;
|
||||
const [nextAssets, nextMonitors, nextIncidents] = await Promise.all([api.assets(auth.token), api.monitors(auth.token), api.incidents(auth.token)]);
|
||||
setAssets(nextAssets);
|
||||
setMonitors(nextMonitors);
|
||||
setIncidents(nextIncidents);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.token || !auth.user) return;
|
||||
refreshData()
|
||||
.catch(() => {
|
||||
setAssets([]);
|
||||
setMonitors([]);
|
||||
setIncidents([]);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
refreshData().catch(() => undefined);
|
||||
}, 10000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [auth.token, auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const incidentId = getIncidentIdFromPath();
|
||||
setSelectedIncidentId(incidentId);
|
||||
if (incidentId) setPage("alerts");
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, []);
|
||||
|
||||
function handlePageChange(nextPage: PageId) {
|
||||
setPage(nextPage);
|
||||
setSelectedIncidentId(null);
|
||||
if (window.location.pathname.startsWith("/incidents/")) {
|
||||
window.history.pushState({}, "", "/");
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.loading) {
|
||||
return <div className="flex min-h-screen items-center justify-center bg-[#090d13] text-sm text-slate-300">Loading InfraPulse...</div>;
|
||||
}
|
||||
|
||||
if (!auth.user || !auth.token) {
|
||||
return <LoginPage onLogin={auth.signIn} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
) : null}
|
||||
{page === "websites" ? (
|
||||
<WebsitesPage token={auth.token} monitors={monitors} onCreated={refreshData} />
|
||||
) : null}
|
||||
{page === "alerts" ? (
|
||||
<AlertsPage token={auth.token} incidents={incidents} selectedIncidentId={selectedIncidentId} onChanged={refreshData} />
|
||||
) : null}
|
||||
{page === "discovery" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : null}
|
||||
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
|
||||
{page === "credentials" ? <ListPage title="Credentials" description="Encrypted reusable credentials with masked secrets." /> : null}
|
||||
{page === "notifications" ? <NotificationsPage token={auth.token} /> : null}
|
||||
{page === "admin" ? <ListPage title="Admin" description="Users, roles, authentication settings, and global configuration." /> : null}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function getIncidentIdFromPath(): number | null {
|
||||
const match = window.location.pathname.match(/^\/incidents\/(\d+)$/);
|
||||
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,29 @@
|
||||
import type { ButtonHTMLAttributes, PropsWithChildren } from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "ghost" | "danger";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: "bg-pulse text-slate-950 hover:bg-teal-300",
|
||||
ghost: "border border-line bg-slate-900/60 text-slate-100 hover:bg-slate-800",
|
||||
danger: "bg-danger text-white hover:bg-red-400",
|
||||
};
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className = "",
|
||||
variant = "primary",
|
||||
...props
|
||||
}: PropsWithChildren<ButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Database,
|
||||
Gauge,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Network,
|
||||
Radar,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { User } from "../types/api";
|
||||
import { Button } from "./Button";
|
||||
|
||||
const navigation = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: Gauge },
|
||||
{ id: "assets", label: "Assets", icon: Network },
|
||||
{ id: "websites", label: "Websites", icon: Globe },
|
||||
{ id: "alerts", label: "Alerts", icon: Bell },
|
||||
{ id: "discovery", label: "Discovery", icon: Radar },
|
||||
{ id: "graphs", label: "Graphs", icon: Activity },
|
||||
{ id: "credentials", label: "Credentials", icon: KeyRound },
|
||||
{ id: "notifications", label: "Notifications", icon: Database },
|
||||
{ id: "admin", label: "Admin", icon: Settings },
|
||||
] as const;
|
||||
|
||||
export type PageId = (typeof navigation)[number]["id"];
|
||||
|
||||
interface ShellProps {
|
||||
children: ReactNode;
|
||||
currentPage: PageId;
|
||||
onPageChange: (page: PageId) => void;
|
||||
onSignOut: () => void;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function Shell({ children, currentPage, onPageChange, onSignOut, user }: ShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#090d13] text-slate-100">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#0d131c] lg:block">
|
||||
<div className="flex h-16 items-center gap-3 border-b border-line px-5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-pulse text-slate-950">
|
||||
<Shield size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">InfraPulse</div>
|
||||
<div className="text-xs text-slate-400">Monitoring appliance</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-1 p-3">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`flex h-10 w-full items-center gap-3 rounded-md px-3 text-left text-sm transition ${
|
||||
active ? "bg-slate-800 text-white" : "text-slate-400 hover:bg-slate-900 hover:text-white"
|
||||
}`}
|
||||
onClick={() => onPageChange(item.id)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="lg:pl-64">
|
||||
<header className="sticky top-0 z-10 flex min-h-16 items-center justify-between border-b border-line bg-[#0b1018]/95 px-4 backdrop-blur lg:px-8">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-slate-400">Signed in as</div>
|
||||
<div className="truncate text-sm font-medium">{user.email}</div>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onSignOut}>
|
||||
<LogOut size={16} />
|
||||
Logout
|
||||
</Button>
|
||||
</header>
|
||||
<main className="px-4 py-6 lg:px-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api, login } from "../api/client";
|
||||
import type { User } from "../types/api";
|
||||
|
||||
const TOKEN_KEY = "infrapulse_token";
|
||||
|
||||
export function useAuth() {
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api
|
||||
.me(token)
|
||||
.then((nextUser) => {
|
||||
if (!cancelled) setUser(nextUser);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
if (!cancelled) {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
token,
|
||||
user,
|
||||
loading,
|
||||
signIn: async (email: string, password: string) => {
|
||||
const response = await login(email, password);
|
||||
localStorage.setItem(TOKEN_KEY, response.access_token);
|
||||
setToken(response.access_token);
|
||||
},
|
||||
signOut: () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
},
|
||||
}),
|
||||
[loading, token, user],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./app/App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, BellOff, CheckCheck, RefreshCw } from "lucide-react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "../components/Button";
|
||||
import type { Incident } from "../types/api";
|
||||
|
||||
interface AlertsPageProps {
|
||||
token: string;
|
||||
incidents: Incident[];
|
||||
selectedIncidentId?: number | null;
|
||||
onChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }: AlertsPageProps) {
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
async function runAction(incidentId: number, action: "ack" | "silence") {
|
||||
setBusyId(incidentId);
|
||||
try {
|
||||
if (action === "ack") {
|
||||
await api.acknowledgeIncident(token, incidentId);
|
||||
} else {
|
||||
await api.silenceIncident(token, incidentId, 60);
|
||||
}
|
||||
await onChanged();
|
||||
} finally {
|
||||
setBusyId(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">Alerts</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">Open incidents, acknowledgements, silences, recoveries, and notification history.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onChanged}>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="grid grid-cols-[1fr_100px_130px_220px] gap-3 border-b border-line px-4 py-3 text-xs uppercase text-slate-500 max-lg:hidden">
|
||||
<div>Incident</div>
|
||||
<div>Severity</div>
|
||||
<div>Status</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{incidents.length ? (
|
||||
incidents.map((incident) => {
|
||||
const open = incident.status === "open";
|
||||
return (
|
||||
<div
|
||||
key={incident.id}
|
||||
className={`grid gap-4 p-4 lg:grid-cols-[1fr_100px_130px_220px] lg:items-center ${
|
||||
selectedIncidentId === incident.id ? "bg-slate-800/60" : ""
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle size={16} className={open ? "text-signal" : "text-pulse"} />
|
||||
{incident.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
Opened {new Date(incident.opened_at).toLocaleString()}
|
||||
{incident.resolved_at ? `, resolved ${new Date(incident.resolved_at).toLocaleString()}` : ""}
|
||||
</div>
|
||||
{typeof incident.details.last_message === "string" ? <div className="mt-1 text-sm text-slate-500">{incident.details.last_message}</div> : null}
|
||||
</div>
|
||||
<Badge value={incident.severity} tone={incident.severity === "critical" ? "critical" : "neutral"} />
|
||||
<div className="space-y-1">
|
||||
<Badge value={incident.status} tone={open ? "warning" : "ok"} />
|
||||
{incident.acknowledged_at ? <div className="text-xs text-slate-500">Acknowledged</div> : null}
|
||||
{incident.silenced_until ? <div className="text-xs text-slate-500">Silenced</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!open || Boolean(incident.acknowledged_at) || busyId === incident.id} onClick={() => runAction(incident.id, "ack")} variant="ghost">
|
||||
<CheckCheck size={16} />
|
||||
Ack
|
||||
</Button>
|
||||
<Button disabled={!open || busyId === incident.id} onClick={() => runAction(incident.id, "silence")} variant="ghost">
|
||||
<BellOff size={16} />
|
||||
Silence
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No incidents recorded.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ value, tone }: { value: string; tone: "critical" | "warning" | "ok" | "neutral" }) {
|
||||
const classes = {
|
||||
critical: "border-red-500/40 bg-red-950/40 text-red-200",
|
||||
warning: "border-amber-500/40 bg-amber-950/40 text-amber-200",
|
||||
ok: "border-teal-500/40 bg-teal-950/40 text-teal-200",
|
||||
neutral: "border-slate-600 bg-slate-900 text-slate-300",
|
||||
}[tone];
|
||||
return <span className={`inline-flex h-7 w-fit items-center rounded-md border px-2 text-xs font-medium ${classes}`}>{value}</span>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Globe2, Server } from "lucide-react";
|
||||
|
||||
import type { Asset, Incident, Monitor } from "../types/api";
|
||||
|
||||
interface DashboardPageProps {
|
||||
assets: Asset[];
|
||||
monitors: Monitor[];
|
||||
incidents: Incident[];
|
||||
}
|
||||
|
||||
export function DashboardPage({ assets, monitors, incidents }: DashboardPageProps) {
|
||||
const downMonitors = monitors.filter((monitor) => monitor.status === "down").length;
|
||||
const activeIncidents = incidents.filter((incident) => incident.status === "open").length;
|
||||
const websites = monitors.filter((monitor) => monitor.monitor_type === "http");
|
||||
|
||||
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">Dashboard</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">Current infrastructure health, incidents, and website status.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatusTile icon={CheckCircle2} label="Overall Status" value={activeIncidents ? "Attention" : "Healthy"} tone={activeIncidents ? "warn" : "ok"} />
|
||||
<StatusTile icon={AlertTriangle} label="Active Incidents" value={String(activeIncidents)} tone={activeIncidents ? "warn" : "ok"} />
|
||||
<StatusTile icon={Server} label="Assets" value={String(assets.length)} />
|
||||
<StatusTile icon={Globe2} label="Websites" value={String(websites.length)} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="flex items-center justify-between border-b border-line p-4">
|
||||
<h2 className="text-base font-semibold">Website Monitors</h2>
|
||||
<span className="text-sm text-slate-400">{downMonitors} down</span>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{websites.length ? (
|
||||
websites.map((monitor) => (
|
||||
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_120px_110px] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{monitor.name}</div>
|
||||
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">{monitor.interval_seconds}s interval</div>
|
||||
<StatusBadge status={monitor.status} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No website monitors 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">Recent Attention</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{incidents.length ? (
|
||||
incidents.slice(0, 6).map((incident) => (
|
||||
<div key={incident.id} className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle size={15} className="text-signal" />
|
||||
<span className="font-medium">{incident.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-slate-400">
|
||||
<Clock3 size={13} />
|
||||
{new Date(incident.opened_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No incidents recorded.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
icon: typeof CheckCircle2;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "ok" | "warn";
|
||||
}) {
|
||||
const color = tone === "ok" ? "text-pulse" : tone === "warn" ? "text-signal" : "text-slate-300";
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-[#0d131c] p-4">
|
||||
<div className={`mb-4 flex h-9 w-9 items-center justify-center rounded-md bg-slate-900 ${color}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ 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"
|
||||
: "border-slate-600 bg-slate-900 text-slate-300";
|
||||
return <span className={`inline-flex h-7 items-center justify-center rounded-md border px-2 text-xs font-medium ${classes}`}>{status}</span>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ListPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPage({ title, description, children }: ListPageProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">{title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">{description}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-line bg-[#0d131c] p-5">{children ?? <p className="text-sm text-slate-400">Initial UI shell ready for implementation.</p>}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { LockKeyhole, Shield } from "lucide-react";
|
||||
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const [email, setEmail] = useState("admin@example.com");
|
||||
const [password, setPassword] = useState("change-me");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onLogin(email, password);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen bg-[#090d13] text-slate-100 lg:grid-cols-[minmax(0,1fr)_440px]">
|
||||
<section className="flex min-h-[42vh] flex-col justify-between bg-[linear-gradient(rgba(9,13,19,0.45),rgba(9,13,19,0.9)),url('https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1800&q=80')] bg-cover bg-center p-6 lg:min-h-screen lg:p-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-pulse text-slate-950">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">InfraPulse</div>
|
||||
</div>
|
||||
<div className="max-w-3xl pb-6">
|
||||
<h1 className="max-w-2xl text-4xl font-semibold leading-tight lg:text-6xl">
|
||||
Beautiful, self-hosted infrastructure monitoring.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl text-base leading-7 text-slate-300">
|
||||
Guided setup, clean dashboards, website checks, incidents, and notifications without enterprise-tool overhead.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex items-center justify-center border-l border-line bg-[#0d131c] p-6">
|
||||
<form className="w-full max-w-sm space-y-5" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-slate-800">
|
||||
<LockKeyhole size={19} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Sign in</h2>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Email</span>
|
||||
<input
|
||||
className="h-11 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Password</span>
|
||||
<input
|
||||
className="h-11 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <div className="rounded-md border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
|
||||
|
||||
<Button className="w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Signing in..." : "Login"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Bell, Edit3, RefreshCw, Send, Trash2, X } from "lucide-react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "../components/Button";
|
||||
import type { NotificationChannel } from "../types/api";
|
||||
|
||||
interface NotificationsPageProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function NotificationsPage({ token }: NotificationsPageProps) {
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [channelType, setChannelType] = useState("generic_webhook");
|
||||
const [url, setUrl] = useState("");
|
||||
const [username, setUsername] = useState("InfraPulse");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [editingChannelId, setEditingChannelId] = useState<number | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setChannels(await api.notificationChannels(token));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setChannels([]));
|
||||
}, [token]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
if (editingChannelId) {
|
||||
await api.updateNotificationChannel(token, editingChannelId, {
|
||||
name,
|
||||
channel_type: channelType,
|
||||
settings: { username: username.trim() || "InfraPulse" },
|
||||
secret: url.trim() ? url.trim() : undefined,
|
||||
is_enabled: enabled,
|
||||
});
|
||||
} else {
|
||||
await api.createNotificationChannel(token, {
|
||||
name,
|
||||
channel_type: channelType,
|
||||
settings: { username: username.trim() || "InfraPulse" },
|
||||
secret: url,
|
||||
is_enabled: enabled,
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Could not save channel");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(channel: NotificationChannel) {
|
||||
setEditingChannelId(channel.id);
|
||||
setName(channel.name);
|
||||
setChannelType(channel.channel_type);
|
||||
setUrl("");
|
||||
setUsername(String(channel.settings.username || "InfraPulse"));
|
||||
setEnabled(channel.is_enabled);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingChannelId(null);
|
||||
setName("");
|
||||
setChannelType("generic_webhook");
|
||||
setUrl("");
|
||||
setUsername("InfraPulse");
|
||||
setEnabled(true);
|
||||
}
|
||||
|
||||
async function testChannel(channelId: number) {
|
||||
setBusyId(channelId);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await api.testNotificationChannel(token, channelId);
|
||||
setMessage(result.message);
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Notification test failed");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChannel(channelId: number) {
|
||||
setBusyId(channelId);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.deleteNotificationChannel(token, channelId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Could not delete channel");
|
||||
} finally {
|
||||
setBusyId(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">Notifications</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">Webhook destinations for alert and recovery messages.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={refresh}>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={submit}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={18} className="text-pulse" />
|
||||
<h2 className="text-base font-semibold">{editingChannelId ? "Edit Webhook Channel" : "Add Webhook Channel"}</h2>
|
||||
</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>
|
||||
|
||||
<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={channelType} onChange={(event) => setChannelType(event.target.value)}>
|
||||
<option value="generic_webhook">Generic Webhook</option>
|
||||
<option value="mattermost">Mattermost</option>
|
||||
<option value="zoom_team_chat">Zoom Team Chat</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Webhook 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={url} onChange={(event) => setUrl(event.target.value)} required={!editingChannelId} type="url" />
|
||||
{editingChannelId ? <span className="text-xs text-slate-500">Leave blank to keep the stored webhook URL.</span> : null}
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Post Username</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={username} onChange={(event) => setUsername(event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-line bg-slate-950 px-3 py-2">
|
||||
<span className="text-sm text-slate-300">Enabled</span>
|
||||
<input className="h-5 w-5 accent-teal-400" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} type="checkbox" />
|
||||
</div>
|
||||
|
||||
{message ? <div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">{message}</div> : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{editingChannelId ? (
|
||||
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
|
||||
<X size={16} />
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button className="flex-1" disabled={submitting} type="submit">
|
||||
{submitting ? "Saving..." : editingChannelId ? "Save Channel" : "Create Channel"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="border-b border-line p-4">
|
||||
<h2 className="text-base font-semibold">Channels</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{channels.length ? (
|
||||
channels.map((channel) => (
|
||||
<div key={channel.id} className="grid gap-3 p-4 md:grid-cols-[1fr_140px_90px_150px] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
<div className="text-sm text-slate-400">{String(channel.settings.username || "InfraPulse")}</div>
|
||||
<div className="text-xs text-slate-500">{channel.has_secret ? "Secret stored" : "No secret"}</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">{channel.channel_type}</div>
|
||||
<Status enabled={channel.is_enabled} />
|
||||
<div className="flex gap-2">
|
||||
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => testChannel(channel.id)} title="Test channel" type="button" variant="ghost">
|
||||
<Send size={15} />
|
||||
</Button>
|
||||
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => startEdit(channel)} title="Edit channel" type="button" variant="ghost">
|
||||
<Edit3 size={15} />
|
||||
</Button>
|
||||
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => deleteChannel(channel.id)} title="Delete channel" type="button" variant="ghost">
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No notification channels yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ enabled }: { enabled: boolean }) {
|
||||
const classes = enabled ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : "border-slate-600 bg-slate-900 text-slate-300";
|
||||
return <span className={`inline-flex h-7 w-fit items-center rounded-md border px-2 text-xs font-medium ${classes}`}>{enabled ? "enabled" : "disabled"}</span>;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Edit3, Globe2, Plus, RefreshCw, Trash2, X } from "lucide-react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "../components/Button";
|
||||
import type { Monitor } from "../types/api";
|
||||
|
||||
interface WebsitesPageProps {
|
||||
token: string;
|
||||
monitors: Monitor[];
|
||||
onCreated: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps) {
|
||||
const websites = monitors.filter((monitor) => monitor.monitor_type === "http");
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("https://");
|
||||
const [expectedStatus, setExpectedStatus] = useState(200);
|
||||
const [expectedText, setExpectedText] = useState("");
|
||||
const [intervalSeconds, setIntervalSeconds] = useState(60);
|
||||
const [failureThreshold, setFailureThreshold] = useState(3);
|
||||
const [alertEnabled, setAlertEnabled] = useState(true);
|
||||
const [editingMonitorId, setEditingMonitorId] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (editingMonitorId) {
|
||||
await api.updateMonitor(token, editingMonitorId, {
|
||||
name,
|
||||
target: url,
|
||||
interval_seconds: intervalSeconds,
|
||||
config: {
|
||||
expected_status: expectedStatus,
|
||||
expected_text: expectedText.trim() ? expectedText.trim() : null,
|
||||
unexpected_text: null,
|
||||
timeout_seconds: 10,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await api.createWebsiteMonitor(token, {
|
||||
name,
|
||||
url,
|
||||
expected_status: expectedStatus,
|
||||
expected_text: expectedText.trim() ? expectedText.trim() : null,
|
||||
unexpected_text: null,
|
||||
timeout_seconds: 10,
|
||||
interval_seconds: intervalSeconds,
|
||||
create_asset: true,
|
||||
alert_enabled: alertEnabled,
|
||||
alert_severity: "critical",
|
||||
failure_threshold: failureThreshold,
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
await onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save website monitor");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(monitor: Monitor) {
|
||||
setEditingMonitorId(monitor.id);
|
||||
setName(monitor.name);
|
||||
setUrl(monitor.target);
|
||||
setExpectedStatus(Number(monitor.config?.expected_status ?? 200));
|
||||
setExpectedText(typeof monitor.config?.expected_text === "string" ? monitor.config.expected_text : "");
|
||||
setIntervalSeconds(monitor.interval_seconds);
|
||||
setAlertEnabled(true);
|
||||
setFailureThreshold(3);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingMonitorId(null);
|
||||
setName("");
|
||||
setUrl("https://");
|
||||
setExpectedStatus(200);
|
||||
setExpectedText("");
|
||||
setIntervalSeconds(60);
|
||||
setFailureThreshold(3);
|
||||
setAlertEnabled(true);
|
||||
}
|
||||
|
||||
async function deleteMonitor(monitorId: number) {
|
||||
setDeletingId(monitorId);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteMonitor(token, monitorId);
|
||||
await onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not delete website monitor");
|
||||
} finally {
|
||||
setDeletingId(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">Websites</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">HTTP status, expected content, response time, and alert thresholds.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onCreated}>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-[420px_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">
|
||||
<Globe2 size={18} className="text-pulse" />
|
||||
<h2 className="text-base font-semibold">{editingMonitorId ? "Edit Website Monitor" : "Add Website Monitor"}</h2>
|
||||
</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>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">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={url} onChange={(event) => setUrl(event.target.value)} required type="url" />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Expected Status</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={expectedStatus} onChange={(event) => setExpectedStatus(Number(event.target.value))} min={100} max={599} type="number" />
|
||||
</label>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Interval Seconds</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>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Expected Text</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={expectedText} onChange={(event) => setExpectedText(event.target.value)} />
|
||||
</label>
|
||||
|
||||
{!editingMonitorId ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-line bg-slate-950 px-3 py-2">
|
||||
<span className="text-sm text-slate-300">Alert on repeated failures</span>
|
||||
<input className="h-5 w-5 accent-teal-400" checked={alertEnabled} onChange={(event) => setAlertEnabled(event.target.checked)} type="checkbox" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!editingMonitorId ? (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Failure Threshold</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={failureThreshold} onChange={(event) => setFailureThreshold(Number(event.target.value))} min={1} max={20} type="number" />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="rounded-md border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{editingMonitorId ? (
|
||||
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
|
||||
<X size={16} />
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button className="flex-1" disabled={submitting} type="submit">
|
||||
<Plus size={16} />
|
||||
{submitting ? "Saving..." : editingMonitorId ? "Save Monitor" : "Create Monitor"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="border-b border-line p-4">
|
||||
<h2 className="text-base font-semibold">Configured Websites</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{websites.length ? (
|
||||
websites.map((monitor) => (
|
||||
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_110px_170px] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{monitor.name}</div>
|
||||
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
|
||||
</div>
|
||||
<Status status={monitor.status} />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-slate-400">{monitor.last_checked_at ? new Date(monitor.last_checked_at).toLocaleTimeString() : "Not checked"}</div>
|
||||
<Button aria-label={`Edit ${monitor.name}`} className="h-8 w-8 px-0" onClick={() => startEdit(monitor)} title="Edit monitor" type="button" variant="ghost">
|
||||
<Edit3 size={15} />
|
||||
</Button>
|
||||
<Button aria-label={`Delete ${monitor.name}`} className="h-8 w-8 px-0" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete monitor" type="button" variant="ghost">
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No website monitors yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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"
|
||||
: "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>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #090d13;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
id: number;
|
||||
name: string;
|
||||
asset_type: string;
|
||||
address?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Monitor {
|
||||
id: number;
|
||||
asset_id?: number | null;
|
||||
name: string;
|
||||
monitor_type: string;
|
||||
target: string;
|
||||
config?: Record<string, unknown>;
|
||||
status: string;
|
||||
interval_seconds: number;
|
||||
last_checked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MonitorUpdate {
|
||||
name?: string;
|
||||
target?: string;
|
||||
config?: Record<string, unknown>;
|
||||
interval_seconds?: number;
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: number;
|
||||
asset_id?: number | null;
|
||||
monitor_id?: number | null;
|
||||
alert_rule_id?: number | null;
|
||||
title: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
opened_at: string;
|
||||
resolved_at?: string | null;
|
||||
acknowledged_at?: string | null;
|
||||
silenced_until?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
channel_type: string;
|
||||
settings: Record<string, unknown>;
|
||||
has_secret: boolean;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationChannelCreate {
|
||||
name: string;
|
||||
channel_type: string;
|
||||
settings: {
|
||||
username?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
secret?: string | null;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationChannelUpdate {
|
||||
name?: string;
|
||||
channel_type?: string;
|
||||
settings?: {
|
||||
username?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
secret?: string | null;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WebsiteMonitorCreate {
|
||||
name: string;
|
||||
url: string;
|
||||
expected_status: number;
|
||||
expected_text?: string | null;
|
||||
unexpected_text?: string | null;
|
||||
timeout_seconds: number;
|
||||
interval_seconds: number;
|
||||
create_asset: boolean;
|
||||
alert_enabled: boolean;
|
||||
alert_severity: string;
|
||||
failure_threshold: number;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user