Initial InfraPulse scaffold

This commit is contained in:
Keith Smith
2026-05-22 17:36:40 -06:00
commit a707186a5e
92 changed files with 6918 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InfraPulse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3024
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "infrapulse-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.13.3",
"vite": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+101
View File
@@ -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",
}),
};
+135
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+61
View File
@@ -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],
);
}
+11
View File
@@ -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>,
);
+110
View File
@@ -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>;
}
+115
View File
@@ -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>;
}
+19
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+214
View File
@@ -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>;
}
+223
View File
@@ -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>;
}
+23
View File
@@ -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;
}
+94
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
ink: "#0f172a",
panel: "#111827",
line: "#273244",
pulse: "#14b8a6",
signal: "#f59e0b",
danger: "#ef4444",
},
},
},
plugins: [],
} satisfies Config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});