generated from nhcarrigan/template
feat: initial implementation of Oriana uptime monitor
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 50s
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 50s
Implements a full-stack uptime monitoring application with: - HTTPS, HTTPS keyword, HTTPS status, port, and MongoDB Atlas monitor types - Cron-based monitoring engine with webhook notifications on status changes - Discord OAuth2 admin panel (single-owner) - Public status page with category grouping and failure reason display - Admin dashboard with sortable monitors table and detailed failure info - SQLite persistence with migration support
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
prod
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -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>Oriana</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "oriana-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"react-router-dom": "7.2.0",
|
||||||
|
"recharts": "2.15.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.0.10",
|
||||||
|
"@types/react-dom": "19.0.4",
|
||||||
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
|
"typescript": "5.8.2",
|
||||||
|
"vite": "6.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1437
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { useAuth } from "./hooks/useAuth.js";
|
||||||
|
import { StatusPage } from "./pages/StatusPage.js";
|
||||||
|
import { Login } from "./pages/Login.js";
|
||||||
|
import { Dashboard } from "./pages/Dashboard.js";
|
||||||
|
import { Monitors } from "./pages/Monitors.js";
|
||||||
|
import { Incidents } from "./pages/Incidents.js";
|
||||||
|
|
||||||
|
const ProtectedRoute = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.ReactElement => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const App = (): React.ReactElement => {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<StatusPage />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/monitors"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Monitors />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/incidents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Incidents />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import type { CurrentUser } from "../types.js";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: "/admin", label: "Dashboard" },
|
||||||
|
{ to: "/admin/monitors", label: "Monitors" },
|
||||||
|
{ to: "/admin/incidents", label: "Incidents & Maintenance" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AdminNav = ({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
user: CurrentUser;
|
||||||
|
}): React.ReactElement => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const handleLogout = async (): Promise<void> => {
|
||||||
|
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
background: "#0a0009",
|
||||||
|
color: "#f5f5f5",
|
||||||
|
padding: "0 24px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
height: 56,
|
||||||
|
borderBottom: "1px solid #44275a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 18, marginRight: 8, fontFamily: "Griffy, cursive", color: "#d4a5c7" }}>
|
||||||
|
Oriana
|
||||||
|
</span>
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
style={{
|
||||||
|
color: pathname === link.to ? "#e8d5e8" : "#d4a5c7",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: pathname === link.to ? 700 : 400,
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
style={{
|
||||||
|
color: "#d4a5c7",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 13,
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↗ Status Page
|
||||||
|
</Link>
|
||||||
|
<span style={{ fontSize: 13, color: "#d4a5c7" }}>
|
||||||
|
{user.globalName ?? user.username}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleLogout()}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid #a8577e",
|
||||||
|
color: "#d4a5c7",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "4px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { CurrentUser } from "../types.js";
|
||||||
|
|
||||||
|
export const useAuth = (): { user: CurrentUser | null; loading: boolean } => {
|
||||||
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/auth/me", { credentials: "include" })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
setUser((await res.json()) as CurrentUser);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, loading };
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Griffy&family=Kalam:wght@300;400;700&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--witch-purple: #2b1b3d;
|
||||||
|
--witch-plum: #44275a;
|
||||||
|
--witch-rose: #a8577e;
|
||||||
|
--witch-mauve: #d4a5c7;
|
||||||
|
--witch-lavender: #e8d5e8;
|
||||||
|
--witch-black: #0a0009;
|
||||||
|
--witch-silver: #c0c0c0;
|
||||||
|
--witch-moon: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--witch-purple);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
font-family: "Kalam", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: "Griffy", cursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--witch-rose);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--witch-mauve);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import { App } from "./App.js";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "../hooks/useAuth.js";
|
||||||
|
import { AdminNav } from "../components/AdminNav.js";
|
||||||
|
import type { Incident, Monitor, StatusResponse } from "../types.js";
|
||||||
|
|
||||||
|
export const Dashboard = (): React.ReactElement => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||||
|
const [monitors, setMonitors] = useState<Monitor[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/status")
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((d) => {
|
||||||
|
setStatus(d as StatusResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch("/api/monitors", { credentials: "include" })
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((d) => {
|
||||||
|
setMonitors(d as Monitor[]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const upCount = status?.monitors.filter((m) => m.status === "up").length ?? 0;
|
||||||
|
const downCount = status?.monitors.filter((m) => m.status === "down").length ?? 0;
|
||||||
|
const activeIncidents = status?.incidents.filter(
|
||||||
|
(i: Incident) => i.status !== "resolved",
|
||||||
|
).length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100vh" }}>
|
||||||
|
{user && <AdminNav user={user} />}
|
||||||
|
<div style={{ maxWidth: 900, margin: "0 auto", padding: "32px 16px" }}>
|
||||||
|
<h1 style={{ margin: "0 0 24px", fontSize: 24, color: "#d4a5c7" }}>Dashboard</h1>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16, marginBottom: 32 }}>
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 20 }}>
|
||||||
|
<div style={{ fontSize: 32, fontWeight: 700, color: "#4ade80", fontFamily: "Griffy, cursive" }}>{upCount}</div>
|
||||||
|
<div style={{ color: "#d4a5c7", fontSize: 14 }}>Monitors Up</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 20 }}>
|
||||||
|
<div style={{ fontSize: 32, fontWeight: 700, color: "#f87171", fontFamily: "Griffy, cursive" }}>{downCount}</div>
|
||||||
|
<div style={{ color: "#d4a5c7", fontSize: 14 }}>Monitors Down</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 20 }}>
|
||||||
|
<div style={{ fontSize: 32, fontWeight: 700, color: "#fb923c", fontFamily: "Griffy, cursive" }}>{activeIncidents}</div>
|
||||||
|
<div style={{ color: "#d4a5c7", fontSize: 14 }}>Active Incidents</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, overflow: "hidden" }}>
|
||||||
|
<div style={{ padding: "16px 20px", borderBottom: "1px solid #2b1b3d", fontWeight: 600, color: "#e8d5e8" }}>
|
||||||
|
All Monitors
|
||||||
|
</div>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#0a0009", fontSize: 12, color: "#d4a5c7" }}>
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Name</th>
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Type</th>
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Category</th>
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Status / Details</th>
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Interval</th>
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{monitors.map((m) => {
|
||||||
|
const summary = status?.monitors.find((s) => s.id === m.id);
|
||||||
|
const statusColour =
|
||||||
|
summary?.status === "up"
|
||||||
|
? "#4ade80"
|
||||||
|
: summary?.status === "down"
|
||||||
|
? "#f87171"
|
||||||
|
: "#c0c0c0";
|
||||||
|
const checkedAt = summary?.lastChecked
|
||||||
|
? new Date(summary.lastChecked).toLocaleString()
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<tr key={m.id} style={{ borderTop: "1px solid #2b1b3d", fontSize: 14 }}>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#f5f5f5" }}>{m.name}</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.type}</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.category}</td>
|
||||||
|
<td style={{ padding: "10px 20px" }}>
|
||||||
|
<span style={{ fontWeight: 600, color: statusColour }}>
|
||||||
|
{(summary?.status ?? "unknown").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{summary?.status === "down" && (
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12, color: "#f87171" }}>
|
||||||
|
{summary.message ?? "No details available."}
|
||||||
|
{summary.statusCode !== null && (
|
||||||
|
<span style={{ marginLeft: 6, color: "#c0c0c0" }}>
|
||||||
|
(HTTP {summary.statusCode})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.responseTimeMs !== null && (
|
||||||
|
<span style={{ marginLeft: 6, color: "#c0c0c0" }}>
|
||||||
|
· {summary.responseTimeMs}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checkedAt !== null && (
|
||||||
|
<div style={{ color: "#c0c0c0", marginTop: 2 }}>
|
||||||
|
Last checked: {checkedAt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary?.status === "up" && (
|
||||||
|
<div style={{ marginTop: 2, fontSize: 12, color: "#c0c0c0" }}>
|
||||||
|
{summary.responseTimeMs !== null && `${summary.responseTimeMs}ms`}
|
||||||
|
{summary.responseTimeMs !== null && checkedAt !== null && " · "}
|
||||||
|
{checkedAt !== null && checkedAt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.intervalSeconds}s</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#f5f5f5" }}>{m.enabled ? "✓" : "✗"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useEffect, useReducer, useState } from "react";
|
||||||
|
import { useAuth } from "../hooks/useAuth.js";
|
||||||
|
import { AdminNav } from "../components/AdminNav.js";
|
||||||
|
import type { Incident, IncidentStatus, MaintenanceWindow } from "../types.js";
|
||||||
|
|
||||||
|
type IncidentForm = { title: string; message: string; status: IncidentStatus };
|
||||||
|
type MaintenanceForm = {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultIncidentForm: IncidentForm = {
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
status: "investigating",
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMaintenanceForm: MaintenanceForm = {
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
startTime: "",
|
||||||
|
endTime: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid #44275a",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
background: "#2b1b3d",
|
||||||
|
color: "#f5f5f5",
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColour: Record<IncidentStatus, string> = {
|
||||||
|
investigating: "#fb923c",
|
||||||
|
identified: "#facc15",
|
||||||
|
monitoring: "#60a5fa",
|
||||||
|
resolved: "#4ade80",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Incidents = (): React.ReactElement => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||||
|
const [maintenance, setMaintenance] = useState<MaintenanceWindow[]>([]);
|
||||||
|
const [showIncidentForm, setShowIncidentForm] = useState(false);
|
||||||
|
const [showMaintenanceForm, setShowMaintenanceForm] = useState(false);
|
||||||
|
const [editingIncident, setEditingIncident] = useState<Incident | null>(null);
|
||||||
|
const [editingMaintenance, setEditingMaintenance] = useState<MaintenanceWindow | null>(null);
|
||||||
|
const [incidentForm, setIncidentForm] = useReducer(
|
||||||
|
(state: IncidentForm, updates: Partial<IncidentForm>) => ({ ...state, ...updates }),
|
||||||
|
defaultIncidentForm,
|
||||||
|
);
|
||||||
|
const [maintenanceForm, setMaintenanceForm] = useReducer(
|
||||||
|
(state: MaintenanceForm, updates: Partial<MaintenanceForm>) => ({ ...state, ...updates }),
|
||||||
|
defaultMaintenanceForm,
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = (): void => {
|
||||||
|
fetch("/api/incidents")
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((d) => { setIncidents(d as Incident[]); });
|
||||||
|
fetch("/api/maintenance")
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((d) => { setMaintenance(d as MaintenanceWindow[]); });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const submitIncident = async (): Promise<void> => {
|
||||||
|
const url = editingIncident ? `/api/incidents/${editingIncident.id}` : "/api/incidents";
|
||||||
|
const method = editingIncident ? "PUT" : "POST";
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(incidentForm),
|
||||||
|
});
|
||||||
|
setShowIncidentForm(false);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteIncident = async (id: string): Promise<void> => {
|
||||||
|
if (!confirm("Delete this incident?")) return;
|
||||||
|
await fetch(`/api/incidents/${id}`, { method: "DELETE", credentials: "include" });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitMaintenance = async (): Promise<void> => {
|
||||||
|
const url = editingMaintenance ? `/api/maintenance/${editingMaintenance.id}` : "/api/maintenance";
|
||||||
|
const method = editingMaintenance ? "PUT" : "POST";
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(maintenanceForm),
|
||||||
|
});
|
||||||
|
setShowMaintenanceForm(false);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMaintenance = async (id: string): Promise<void> => {
|
||||||
|
if (!confirm("Delete this maintenance window?")) return;
|
||||||
|
await fetch(`/api/maintenance/${id}`, { method: "DELETE", credentials: "include" });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryButton: React.CSSProperties = {
|
||||||
|
background: "#a8577e",
|
||||||
|
color: "#f5f5f5",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryButton: React.CSSProperties = {
|
||||||
|
background: "transparent",
|
||||||
|
color: "#d4a5c7",
|
||||||
|
border: "1px solid #44275a",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100vh" }}>
|
||||||
|
{user && <AdminNav user={user} />}
|
||||||
|
<div style={{ maxWidth: 900, margin: "0 auto", padding: "32px 16px" }}>
|
||||||
|
|
||||||
|
{/* Incidents */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 24, color: "#d4a5c7" }}>Incidents</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingIncident(null);
|
||||||
|
setIncidentForm(defaultIncidentForm);
|
||||||
|
setShowIncidentForm(true);
|
||||||
|
}}
|
||||||
|
style={primaryButton}
|
||||||
|
>
|
||||||
|
New Incident
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showIncidentForm && (
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 24, marginBottom: 24 }}>
|
||||||
|
<h2 style={{ margin: "0 0 16px", fontSize: 18, color: "#e8d5e8" }}>{editingIncident ? "Edit Incident" : "New Incident"}</h2>
|
||||||
|
<div style={{ display: "grid", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Title</label>
|
||||||
|
<input style={inputStyle} value={incidentForm.title} onChange={(e) => { setIncidentForm({ title: e.target.value }); }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Message</label>
|
||||||
|
<textarea style={{ ...inputStyle, height: 80, resize: "vertical" }} value={incidentForm.message} onChange={(e) => { setIncidentForm({ message: e.target.value }); }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Status</label>
|
||||||
|
<select style={inputStyle} value={incidentForm.status} onChange={(e) => { setIncidentForm({ status: e.target.value as IncidentStatus }); }}>
|
||||||
|
<option value="investigating">Investigating</option>
|
||||||
|
<option value="identified">Identified</option>
|
||||||
|
<option value="monitoring">Monitoring</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
|
||||||
|
<button onClick={() => void submitIncident()} style={primaryButton}>
|
||||||
|
{editingIncident ? "Save Changes" : "Create Incident"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowIncidentForm(false); }} style={secondaryButton}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, marginBottom: 40, overflow: "hidden" }}>
|
||||||
|
{incidents.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "#c0c0c0" }}>No incidents.</div>
|
||||||
|
) : incidents.map((incident) => (
|
||||||
|
<div key={incident.id} style={{ padding: 16, borderBottom: "1px solid #2b1b3d" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 15, color: "#f5f5f5" }}>{incident.title}</strong>
|
||||||
|
<span style={{ marginLeft: 10, fontSize: 12, fontWeight: 600, color: statusColour[incident.status] }}>{incident.status}</span>
|
||||||
|
<div style={{ fontSize: 13, color: "#d4a5c7", marginTop: 4 }}>{incident.message}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#c0c0c0", marginTop: 4 }}>
|
||||||
|
Created {new Date(incident.createdAt).toLocaleString()} · Updated {new Date(incident.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||||
|
<button onClick={() => { setEditingIncident(incident); setIncidentForm({ title: incident.title, message: incident.message, status: incident.status }); setShowIncidentForm(true); }} style={{ border: "1px solid #a8577e", background: "transparent", color: "#d4a5c7", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Edit</button>
|
||||||
|
<button onClick={() => void deleteIncident(incident.id)} style={{ border: "1px solid #f87171", color: "#f87171", background: "transparent", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maintenance */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 22, color: "#d4a5c7" }}>Maintenance Windows</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingMaintenance(null);
|
||||||
|
setMaintenanceForm(defaultMaintenanceForm);
|
||||||
|
setShowMaintenanceForm(true);
|
||||||
|
}}
|
||||||
|
style={primaryButton}
|
||||||
|
>
|
||||||
|
New Window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMaintenanceForm && (
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, padding: 24, marginBottom: 24 }}>
|
||||||
|
<h2 style={{ margin: "0 0 16px", fontSize: 18, color: "#e8d5e8" }}>{editingMaintenance ? "Edit Maintenance Window" : "New Maintenance Window"}</h2>
|
||||||
|
<div style={{ display: "grid", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Title</label>
|
||||||
|
<input style={inputStyle} value={maintenanceForm.title} onChange={(e) => { setMaintenanceForm({ title: e.target.value }); }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Message</label>
|
||||||
|
<textarea style={{ ...inputStyle, height: 60, resize: "vertical" }} value={maintenanceForm.message} onChange={(e) => { setMaintenanceForm({ message: e.target.value }); }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Start Time</label>
|
||||||
|
<input type="datetime-local" style={inputStyle} value={maintenanceForm.startTime} onChange={(e) => { setMaintenanceForm({ startTime: e.target.value }); }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>End Time</label>
|
||||||
|
<input type="datetime-local" style={inputStyle} value={maintenanceForm.endTime} onChange={(e) => { setMaintenanceForm({ endTime: e.target.value }); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
|
||||||
|
<button onClick={() => void submitMaintenance()} style={primaryButton}>
|
||||||
|
{editingMaintenance ? "Save Changes" : "Create Window"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowMaintenanceForm(false); }} style={secondaryButton}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, overflow: "hidden" }}>
|
||||||
|
{maintenance.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "#c0c0c0" }}>No maintenance windows.</div>
|
||||||
|
) : maintenance.map((w) => (
|
||||||
|
<div key={w.id} style={{ padding: 16, borderBottom: "1px solid #2b1b3d" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 15, color: "#f5f5f5" }}>{w.title}</strong>
|
||||||
|
<div style={{ fontSize: 13, color: "#d4a5c7", marginTop: 4 }}>{w.message}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#c0c0c0", marginTop: 4 }}>
|
||||||
|
{new Date(w.startTime).toLocaleString()} → {new Date(w.endTime).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||||
|
<button onClick={() => { setEditingMaintenance(w); setMaintenanceForm({ title: w.title, message: w.message, startTime: w.startTime, endTime: w.endTime }); setShowMaintenanceForm(true); }} style={{ border: "1px solid #a8577e", background: "transparent", color: "#d4a5c7", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Edit</button>
|
||||||
|
<button onClick={() => void deleteMaintenance(w.id)} style={{ border: "1px solid #f87171", color: "#f87171", background: "transparent", borderRadius: 4, padding: "2px 10px", cursor: "pointer", fontSize: 12, fontFamily: "Kalam, sans-serif" }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../hooks/useAuth.js";
|
||||||
|
|
||||||
|
export const Login = (): React.ReactElement => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && user) {
|
||||||
|
navigate("/admin");
|
||||||
|
}
|
||||||
|
}, [user, loading, navigate]);
|
||||||
|
|
||||||
|
const error = new URLSearchParams(window.location.search).get("error");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#44275a",
|
||||||
|
border: "1px solid #a8577e",
|
||||||
|
borderRadius: 15,
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 360,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: "0 0 8px", fontSize: 28, color: "#d4a5c7" }}>Oriana</h1>
|
||||||
|
<p style={{ color: "#c0c0c0", margin: "0 0 24px", fontSize: 14 }}>
|
||||||
|
Admin access via Discord
|
||||||
|
</p>
|
||||||
|
{error === "unauthorised" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#0a0009",
|
||||||
|
border: "1px solid #f87171",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
color: "#f87171",
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your Discord account is not authorised to access this dashboard.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/auth/discord"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "#5865f2",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "12px 24px",
|
||||||
|
borderRadius: 8,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 15,
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in with Discord
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<a href="/" style={{ fontSize: 13, color: "#d4a5c7" }}>
|
||||||
|
← Back to status page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
import { useEffect, useReducer, useState } from "react";
|
||||||
|
import { useAuth } from "../hooks/useAuth.js";
|
||||||
|
import { AdminNav } from "../components/AdminNav.js";
|
||||||
|
import type { Monitor, MonitorType } from "../types.js";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
type: MonitorType;
|
||||||
|
url: string;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
keyword: string;
|
||||||
|
allowedStatusCodes: string;
|
||||||
|
deniedStatusCodes: string;
|
||||||
|
intervalSeconds: string;
|
||||||
|
category: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultForm: FormState = {
|
||||||
|
name: "",
|
||||||
|
type: "https",
|
||||||
|
url: "",
|
||||||
|
host: "",
|
||||||
|
port: "",
|
||||||
|
keyword: "",
|
||||||
|
allowedStatusCodes: "",
|
||||||
|
deniedStatusCodes: "",
|
||||||
|
intervalSeconds: "60",
|
||||||
|
category: "General",
|
||||||
|
isPublic: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
type SortKey = "category" | "enabled" | "intervalSeconds" | "isPublic" | "name" | "target" | "type";
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
const sortAccessors: Record<SortKey, (m: Monitor) => number | string> = {
|
||||||
|
category: (m) => m.category.toLowerCase(),
|
||||||
|
enabled: (m) => m.enabled,
|
||||||
|
intervalSeconds: (m) => m.intervalSeconds,
|
||||||
|
isPublic: (m) => m.isPublic,
|
||||||
|
name: (m) => m.name.toLowerCase(),
|
||||||
|
target: (m): string => {
|
||||||
|
const raw = m.url ?? (m.host !== null && m.port !== null
|
||||||
|
? `${m.host}:${m.port}`
|
||||||
|
: "");
|
||||||
|
return raw.toLowerCase();
|
||||||
|
},
|
||||||
|
type: (m) => m.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Monitors = (): React.ReactElement => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [monitors, setMonitors] = useState<Monitor[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Monitor | null>(null);
|
||||||
|
const [form, setForm] = useReducer(
|
||||||
|
(state: FormState, updates: Partial<FormState>) => ({ ...state, ...updates }),
|
||||||
|
defaultForm,
|
||||||
|
);
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
|
||||||
|
const handleSort = (key: SortKey): void => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir(sortDir === "asc" ? "desc" : "asc");
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortIcon = (col: SortKey): string => {
|
||||||
|
if (sortKey !== col) { return " ↕"; }
|
||||||
|
return sortDir === "asc" ? " ↑" : " ↓";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedMonitors = [...monitors].sort((a, b) => {
|
||||||
|
const aVal = sortAccessors[sortKey](a);
|
||||||
|
const bVal = sortAccessors[sortKey](b);
|
||||||
|
if (aVal < bVal) {
|
||||||
|
return sortDir === "asc" ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (aVal > bVal) {
|
||||||
|
return sortDir === "asc" ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadMonitors = (): void => {
|
||||||
|
fetch("/api/monitors", { credentials: "include" })
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((d) => {
|
||||||
|
setMonitors(d as Monitor[]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMonitors();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(defaultForm);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (monitor: Monitor): void => {
|
||||||
|
setEditing(monitor);
|
||||||
|
setForm({
|
||||||
|
name: monitor.name,
|
||||||
|
type: monitor.type,
|
||||||
|
url: monitor.url ?? "",
|
||||||
|
host: monitor.host ?? "",
|
||||||
|
port: monitor.port?.toString() ?? "",
|
||||||
|
keyword: monitor.keyword ?? "",
|
||||||
|
allowedStatusCodes: monitor.allowedStatusCodes
|
||||||
|
? (JSON.parse(monitor.allowedStatusCodes) as number[]).join(", ")
|
||||||
|
: "",
|
||||||
|
deniedStatusCodes: monitor.deniedStatusCodes
|
||||||
|
? (JSON.parse(monitor.deniedStatusCodes) as number[]).join(", ")
|
||||||
|
: "",
|
||||||
|
intervalSeconds: monitor.intervalSeconds.toString(),
|
||||||
|
category: monitor.category,
|
||||||
|
isPublic: monitor.isPublic === 1,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
const body = {
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
url: form.url || undefined,
|
||||||
|
host: form.host || undefined,
|
||||||
|
port: form.port ? Number(form.port) : undefined,
|
||||||
|
keyword: form.keyword || undefined,
|
||||||
|
allowedStatusCodes: form.allowedStatusCodes
|
||||||
|
? form.allowedStatusCodes.split(",").map((s) => Number(s.trim()))
|
||||||
|
: undefined,
|
||||||
|
deniedStatusCodes: form.deniedStatusCodes
|
||||||
|
? form.deniedStatusCodes.split(",").map((s) => Number(s.trim()))
|
||||||
|
: undefined,
|
||||||
|
intervalSeconds: Number(form.intervalSeconds),
|
||||||
|
category: form.category,
|
||||||
|
isPublic: form.isPublic,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = editing ? `/api/monitors/${editing.id}` : "/api/monitors";
|
||||||
|
const method = editing ? "PUT" : "POST";
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setShowForm(false);
|
||||||
|
loadMonitors();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string): Promise<void> => {
|
||||||
|
if (!confirm("Delete this monitor?")) return;
|
||||||
|
await fetch(`/api/monitors/${id}`, { method: "DELETE", credentials: "include" });
|
||||||
|
loadMonitors();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (monitor: Monitor): Promise<void> => {
|
||||||
|
await fetch(`/api/monitors/${monitor.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: !monitor.enabled }),
|
||||||
|
});
|
||||||
|
loadMonitors();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePublic = async (monitor: Monitor): Promise<void> => {
|
||||||
|
await fetch(`/api/monitors/${monitor.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ isPublic: !monitor.isPublic }),
|
||||||
|
});
|
||||||
|
loadMonitors();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid #44275a",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
background: "#2b1b3d",
|
||||||
|
color: "#f5f5f5",
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100vh" }}>
|
||||||
|
{user && <AdminNav user={user} />}
|
||||||
|
<div style={{ maxWidth: 900, margin: "0 auto", padding: "32px 16px" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 24, color: "#d4a5c7" }}>Monitors</h1>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
style={{
|
||||||
|
background: "#a8577e",
|
||||||
|
color: "#f5f5f5",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Monitor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#44275a",
|
||||||
|
border: "1px solid #a8577e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: "0 0 16px", fontSize: 18, color: "#e8d5e8" }}>
|
||||||
|
{editing ? "Edit Monitor" : "New Monitor"}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Name</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => { setForm({ name: e.target.value }); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Type</label>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => { setForm({ type: e.target.value as MonitorType }); }}
|
||||||
|
>
|
||||||
|
<option value="https">HTTPS</option>
|
||||||
|
<option value="https_keyword">HTTPS + Keyword</option>
|
||||||
|
<option value="https_status">HTTPS + Status Codes</option>
|
||||||
|
<option value="mongodb">MongoDB Atlas</option>
|
||||||
|
<option value="port">Port</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Category</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => { setForm({ category: e.target.value }); }}
|
||||||
|
placeholder="General"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>
|
||||||
|
Interval (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
value={form.intervalSeconds}
|
||||||
|
onChange={(e) => { setForm({ intervalSeconds: e.target.value }); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(form.type === "https" || form.type === "https_keyword" || form.type === "https_status") && (
|
||||||
|
<div style={{ gridColumn: "span 2" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>URL</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => { setForm({ url: e.target.value }); }}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.type === "mongodb" && (
|
||||||
|
<div style={{ gridColumn: "span 2" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Connection String</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => { setForm({ url: e.target.value }); }}
|
||||||
|
placeholder="mongodb+srv://user:password@cluster.mongodb.net/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.type === "https_keyword" && (
|
||||||
|
<div style={{ gridColumn: "span 2" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Keyword</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.keyword}
|
||||||
|
onChange={(e) => { setForm({ keyword: e.target.value }); }}
|
||||||
|
placeholder="Expected text in response"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.type === "https_status" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>
|
||||||
|
Allowed Status Codes (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.allowedStatusCodes}
|
||||||
|
onChange={(e) => { setForm({ allowedStatusCodes: e.target.value }); }}
|
||||||
|
placeholder="200, 201"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>
|
||||||
|
Denied Status Codes (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.deniedStatusCodes}
|
||||||
|
onChange={(e) => { setForm({ deniedStatusCodes: e.target.value }); }}
|
||||||
|
placeholder="500, 503"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.type === "port" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Host</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => { setForm({ host: e.target.value }); }}
|
||||||
|
placeholder="my-server.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#d4a5c7" }}>Port</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => { setForm({ port: e.target.value }); }}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: "span 2" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", color: "#d4a5c7", fontSize: 13 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isPublic}
|
||||||
|
onChange={(e) => { setForm({ isPublic: e.target.checked }); }}
|
||||||
|
style={{ accentColor: "#a8577e", width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
Show on public status page
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
style={{
|
||||||
|
background: "#a8577e",
|
||||||
|
color: "#f5f5f5",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editing ? "Save Changes" : "Create Monitor"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowForm(false); }}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "#d4a5c7",
|
||||||
|
border: "1px solid #44275a",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ background: "#44275a", border: "1px solid #a8577e", borderRadius: 12, overflow: "hidden" }}>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#0a0009", fontSize: 12, color: "#d4a5c7" }}>
|
||||||
|
{(["name", "type", "category", "target", "intervalSeconds", "enabled", "isPublic"] as SortKey[]).map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
onClick={() => { handleSort(col); }}
|
||||||
|
style={{ padding: "8px 20px", textAlign: "left", cursor: "pointer", userSelect: "none" }}
|
||||||
|
>
|
||||||
|
{col === "intervalSeconds" ? "Interval" : col === "isPublic" ? "Public" : col.charAt(0).toUpperCase() + col.slice(1)}
|
||||||
|
{sortIcon(col)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th style={{ padding: "8px 20px", textAlign: "left" }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedMonitors.map((m) => (
|
||||||
|
<tr key={m.id} style={{ borderTop: "1px solid #2b1b3d", fontSize: 14 }}>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#f5f5f5" }}>{m.name}</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.type}</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.category}</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7", maxWidth: 180, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{m.url ?? (m.host && m.port ? `${m.host}:${m.port}` : "—")}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "10px 20px", color: "#d4a5c7" }}>{m.intervalSeconds}s</td>
|
||||||
|
<td style={{ padding: "10px 20px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleToggleEnabled(m)}
|
||||||
|
style={{
|
||||||
|
background: m.enabled ? "rgba(74, 222, 128, 0.15)" : "rgba(248, 113, 113, 0.15)",
|
||||||
|
color: m.enabled ? "#4ade80" : "#f87171",
|
||||||
|
border: `1px solid ${m.enabled ? "#4ade80" : "#f87171"}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.enabled ? "On" : "Off"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "10px 20px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleTogglePublic(m)}
|
||||||
|
style={{
|
||||||
|
background: m.isPublic ? "rgba(74, 222, 128, 0.15)" : "rgba(192, 192, 192, 0.15)",
|
||||||
|
color: m.isPublic ? "#4ade80" : "#c0c0c0",
|
||||||
|
border: `1px solid ${m.isPublic ? "#4ade80" : "#c0c0c0"}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.isPublic ? "Visible" : "Hidden"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "10px 20px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { openEdit(m); }}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid #a8577e",
|
||||||
|
color: "#d4a5c7",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
marginRight: 6,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleDelete(m.id)}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid #f87171",
|
||||||
|
color: "#f87171",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "Kalam, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{monitors.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} style={{ padding: 40, textAlign: "center", color: "#c0c0c0" }}>
|
||||||
|
No monitors yet. Add one above!
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import type { MonitorHistory, StatusResponse } from "../types.js";
|
||||||
|
|
||||||
|
const formatTime = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
|
||||||
|
const MonitorCard = ({
|
||||||
|
monitor,
|
||||||
|
}: {
|
||||||
|
monitor: StatusResponse["monitors"][number];
|
||||||
|
}): React.ReactElement => {
|
||||||
|
const [history, setHistory] = useState<MonitorHistory[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/monitors/${monitor.id}/history?limit=20`)
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setHistory((data as MonitorHistory[]).toReversed());
|
||||||
|
});
|
||||||
|
}, [monitor.id]);
|
||||||
|
|
||||||
|
const chartData = history.map((h) => ({
|
||||||
|
time: formatTime(h.checkedAt),
|
||||||
|
ms: h.responseTimeMs,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #44275a",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
background: "#44275a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 16, color: "#f5f5f5" }}>{monitor.name}</strong>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#d4a5c7",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monitor.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color:
|
||||||
|
monitor.status === "up"
|
||||||
|
? "#4ade80"
|
||||||
|
: monitor.status === "down"
|
||||||
|
? "#f87171"
|
||||||
|
: "#c0c0c0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monitor.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{monitor.status === "down" && monitor.message !== null && (
|
||||||
|
<div style={{ fontSize: 12, color: "#f87171", marginTop: 4 }}>
|
||||||
|
{monitor.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{monitor.responseTimeMs !== null && (
|
||||||
|
<div style={{ fontSize: 12, color: "#d4a5c7", marginTop: 4 }}>
|
||||||
|
{monitor.responseTimeMs}ms
|
||||||
|
{monitor.lastChecked &&
|
||||||
|
` · Last checked ${formatTime(monitor.lastChecked)}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 8, right: 0, left: -32, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#2b1b3d" />
|
||||||
|
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "#d4a5c7" }} />
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: "#d4a5c7" }} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value}ms`, "Response time"]}
|
||||||
|
contentStyle={{ background: "#0a0009", border: "1px solid #44275a", color: "#f5f5f5" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ms"
|
||||||
|
stroke="#a8577e"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusPage = (): React.ReactElement => {
|
||||||
|
const [data, setData] = useState<StatusResponse | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = (): void => {
|
||||||
|
fetch("/api/status")
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((d) => {
|
||||||
|
setData(d as StatusResponse);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 30_000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allUp = data?.monitors.every((m) => m.status === "up");
|
||||||
|
|
||||||
|
const grouped = data?.monitors.reduce<Record<string, StatusResponse["monitors"]>>(
|
||||||
|
(acc, monitor) => {
|
||||||
|
const { category } = monitor;
|
||||||
|
if (acc[category] === undefined) {
|
||||||
|
acc[category] = [];
|
||||||
|
}
|
||||||
|
acc[category].push(monitor);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
) ?? {};
|
||||||
|
const categories = Object.keys(grouped).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 800,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "32px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 28, color: "#d4a5c7" }}>Oriana Status</h1>
|
||||||
|
<a href="/login" style={{ fontSize: 14, color: "#a8577e" }}>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: "#f87171", marginBottom: 16 }}>
|
||||||
|
Failed to load status data.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
background: "#0a0009",
|
||||||
|
border: `1px solid ${allUp ? "#4ade80" : "#f87171"}`,
|
||||||
|
color: allUp ? "#4ade80" : "#f87171",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allUp
|
||||||
|
? "All systems operational"
|
||||||
|
: "One or more systems are experiencing issues"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.maintenance.length > 0 && (
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{ fontSize: 18, marginBottom: 12, color: "#d4a5c7" }}>
|
||||||
|
Scheduled Maintenance
|
||||||
|
</h2>
|
||||||
|
{data.maintenance.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #a8577e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
background: "#44275a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: "#f5f5f5" }}>{w.title}</strong>
|
||||||
|
<div style={{ fontSize: 13, marginTop: 4, color: "#d4a5c7" }}>{w.message}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#c0c0c0", marginTop: 4 }}>
|
||||||
|
{new Date(w.startTime).toLocaleString()} →{" "}
|
||||||
|
{new Date(w.endTime).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.incidents.length > 0 && (
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{ fontSize: 18, marginBottom: 12, color: "#d4a5c7" }}>
|
||||||
|
Active Incidents
|
||||||
|
</h2>
|
||||||
|
{data.incidents.map((incident) => (
|
||||||
|
<div
|
||||||
|
key={incident.id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #f87171",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
background: "#44275a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: "#f5f5f5" }}>{incident.title}</strong>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#f87171",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{incident.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, marginTop: 4, color: "#d4a5c7" }}>
|
||||||
|
{incident.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categories.map((category) => (
|
||||||
|
<section key={category} style={{ marginBottom: 32 }}>
|
||||||
|
<h2 style={{ fontSize: 18, marginBottom: 12, color: "#d4a5c7" }}>
|
||||||
|
{category}
|
||||||
|
</h2>
|
||||||
|
{grouped[category].map((monitor) => (
|
||||||
|
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export type MonitorType = "https" | "https_keyword" | "https_status" | "mongodb" | "port";
|
||||||
|
export type MonitorStatus = "up" | "down" | "unknown";
|
||||||
|
export type IncidentStatus =
|
||||||
|
| "investigating"
|
||||||
|
| "identified"
|
||||||
|
| "monitoring"
|
||||||
|
| "resolved";
|
||||||
|
|
||||||
|
export interface MonitorSummary {
|
||||||
|
category: string;
|
||||||
|
id: string;
|
||||||
|
lastChecked: string | null;
|
||||||
|
message: string | null;
|
||||||
|
name: string;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
status: MonitorStatus;
|
||||||
|
statusCode: number | null;
|
||||||
|
type: MonitorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Monitor {
|
||||||
|
allowedStatusCodes: string | null;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
deniedStatusCodes: string | null;
|
||||||
|
enabled: number;
|
||||||
|
host: string | null;
|
||||||
|
id: string;
|
||||||
|
intervalSeconds: number;
|
||||||
|
isPublic: number;
|
||||||
|
keyword: string | null;
|
||||||
|
name: string;
|
||||||
|
port: number | null;
|
||||||
|
type: MonitorType;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorHistory {
|
||||||
|
checkedAt: string;
|
||||||
|
id: string;
|
||||||
|
message: string | null;
|
||||||
|
monitorId: string;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
status: "up" | "down";
|
||||||
|
statusCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Incident {
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
status: IncidentStatus;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceWindow {
|
||||||
|
createdAt: string;
|
||||||
|
endTime: string;
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
startTime: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
monitors: MonitorSummary[];
|
||||||
|
incidents: Incident[];
|
||||||
|
maintenance: MaintenanceWindow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
globalName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
"/auth": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import nhcarrigan from "@nhcarrigan/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ["prod/**", "client/**"] },
|
||||||
|
...nhcarrigan,
|
||||||
|
];
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "oriana",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Custom uptime monitoring tool",
|
||||||
|
"main": "prod/src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --prefix client build && op run --env-file=prod.env -- tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "op run --env-file=prod.env -- node prod/src/index.js",
|
||||||
|
"lint": "eslint --max-warnings 0 .",
|
||||||
|
"test": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "1.19.11",
|
||||||
|
"better-sqlite3": "12.6.2",
|
||||||
|
"hono": "4.12.5",
|
||||||
|
"mongodb": "^7.1.0",
|
||||||
|
"node-cron": "4.2.1",
|
||||||
|
"uuid": "13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
|
"@types/better-sqlite3": "7.6.13",
|
||||||
|
"@types/node": "22.14.0",
|
||||||
|
"@types/node-cron": "3.0.11",
|
||||||
|
"@types/uuid": "11.0.0",
|
||||||
|
"@vitest/coverage-v8": "4.0.18",
|
||||||
|
"eslint": "9.39.3",
|
||||||
|
"tsx": "4.21.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "4.0.18"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
DISCORD_CLIENT_ID="op://Private/Oriana/discord client id"
|
||||||
|
DISCORD_CLIENT_SECRET="op://Private/Oriana/discord client secret"
|
||||||
|
DISCORD_OWNER_ID="op://Private/Oriana/naomi id"
|
||||||
|
WEBHOOK_URL="op://Private/Oriana/hook"
|
||||||
|
SESSION_SECRET="op://Private/Oriana/session"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
const port = 3000;
|
||||||
|
|
||||||
|
const discordRedirectUri
|
||||||
|
= process.env.DISCORD_REDIRECT_URI ?? `http://localhost:${String(port)}/auth/discord/callback`;
|
||||||
|
|
||||||
|
export { discordRedirectUri, port };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Third-party class uses PascalCase
|
||||||
|
import BetterSqlite3 from "better-sqlite3";
|
||||||
|
import { applySchema } from "./schema.js";
|
||||||
|
|
||||||
|
let database: BetterSqlite3.Database | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active SQLite database connection.
|
||||||
|
* @returns The database instance.
|
||||||
|
* @throws If the database has not been initialised.
|
||||||
|
*/
|
||||||
|
const getDatabase = (): BetterSqlite3.Database => {
|
||||||
|
if (database === null) {
|
||||||
|
throw new Error(
|
||||||
|
"Database has not been initialised. Call initialiseDatabase() first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return database;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the SQLite database file and applies the schema.
|
||||||
|
* @param filePath - Path to the SQLite file. Defaults to oriana.db.
|
||||||
|
*/
|
||||||
|
const initialiseDatabase = (filePath = "oriana.db"): void => {
|
||||||
|
database = new BetterSqlite3(filePath);
|
||||||
|
database.pragma("journal_mode = WAL");
|
||||||
|
database.pragma("foreign_keys = ON");
|
||||||
|
applySchema(database);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getDatabase, initialiseDatabase };
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { Database } from "better-sqlite3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreates the monitors table with an updated CHECK constraint that includes
|
||||||
|
* all current monitor types. Used when an existing database has an outdated constraint.
|
||||||
|
* @param database - The SQLite database connection.
|
||||||
|
*/
|
||||||
|
const recreateMonitorsTable = (database: Database): void => {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE monitors_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('https', 'https_keyword', 'https_status', 'port', 'mongodb')),
|
||||||
|
url TEXT,
|
||||||
|
host TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
keyword TEXT,
|
||||||
|
allowed_status_codes TEXT,
|
||||||
|
denied_status_codes TEXT,
|
||||||
|
interval_seconds INTEGER NOT NULL DEFAULT 60,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
category TEXT NOT NULL DEFAULT 'General',
|
||||||
|
is_public INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO monitors_new
|
||||||
|
SELECT id, name, type, url, host, port, keyword,
|
||||||
|
allowed_status_codes, denied_status_codes, interval_seconds,
|
||||||
|
enabled, category, is_public, created_at
|
||||||
|
FROM monitors;
|
||||||
|
DROP TABLE monitors;
|
||||||
|
ALTER TABLE monitors_new RENAME TO monitors;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies additive column and constraint migrations for existing databases.
|
||||||
|
* @param database - The SQLite database connection.
|
||||||
|
*/
|
||||||
|
const applyMigrations = (database: Database): void => {
|
||||||
|
try {
|
||||||
|
database.exec(
|
||||||
|
`ALTER TABLE monitors ADD COLUMN category TEXT NOT NULL DEFAULT 'General'`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
database.exec(
|
||||||
|
`ALTER TABLE monitors ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const schema = database.
|
||||||
|
prepare<[], { sql: string }>(
|
||||||
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'`,
|
||||||
|
).
|
||||||
|
get();
|
||||||
|
if (schema !== undefined && !schema.sql.includes("'mongodb'")) {
|
||||||
|
recreateMonitorsTable(database);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Migration already applied
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates all tables if they do not already exist, then applies migrations.
|
||||||
|
* @param database - The SQLite database connection.
|
||||||
|
*/
|
||||||
|
const applySchema = (database: Database): void => {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitors (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('https', 'https_keyword', 'https_status', 'port', 'mongodb')),
|
||||||
|
url TEXT,
|
||||||
|
host TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
keyword TEXT,
|
||||||
|
allowed_status_codes TEXT,
|
||||||
|
denied_status_codes TEXT,
|
||||||
|
interval_seconds INTEGER NOT NULL DEFAULT 60,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
category TEXT NOT NULL DEFAULT 'General',
|
||||||
|
is_public INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('up', 'down')),
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
status_code INTEGER,
|
||||||
|
message TEXT,
|
||||||
|
checked_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS incidents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
applyMigrations(database);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { applySchema };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { port } from "./config.js";
|
||||||
|
import { initialiseDatabase } from "./database/index.js";
|
||||||
|
import { startEngine } from "./monitors/engine.js";
|
||||||
|
import { createApp } from "./server.js";
|
||||||
|
|
||||||
|
initialiseDatabase();
|
||||||
|
startEngine();
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const { fetch } = app;
|
||||||
|
|
||||||
|
serve({ fetch, port }, () => {
|
||||||
|
process.stdout.write(`Oriana is running on http://localhost:${String(port)}\n`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { schedule, type ScheduledTask } from "node-cron";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { getDatabase } from "../database/index.js";
|
||||||
|
import { sendWebhook } from "../notifications/webhook.js";
|
||||||
|
import { checkHttps, checkHttpsKeyword, checkHttpsStatus } from "./https.js";
|
||||||
|
import { checkMongoDB } from "./mongodb.js";
|
||||||
|
import { checkPort } from "./port.js";
|
||||||
|
import type { CheckResult, Monitor, MonitorHistory } from "../types/index.js";
|
||||||
|
|
||||||
|
const activeTasks = new Map<string, ScheduledTask>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the most recent status for a monitor from history.
|
||||||
|
* @param monitorId - The monitor ID to query.
|
||||||
|
* @returns The last recorded status string, or null if no history.
|
||||||
|
*/
|
||||||
|
const getLatestStatus = (monitorId: string): string | null => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const row = database.
|
||||||
|
prepare<[string], { status: string }>(
|
||||||
|
`SELECT status
|
||||||
|
FROM monitor_history
|
||||||
|
WHERE monitor_id = ?
|
||||||
|
ORDER BY checked_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
).
|
||||||
|
get(monitorId);
|
||||||
|
return row?.status ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a check result to the monitor_history table.
|
||||||
|
* @param monitorId - The ID of the monitor that was checked.
|
||||||
|
* @param result - The outcome of the check.
|
||||||
|
* @returns The inserted MonitorHistory record.
|
||||||
|
*/
|
||||||
|
const recordResult = (
|
||||||
|
monitorId: string,
|
||||||
|
result: CheckResult,
|
||||||
|
): MonitorHistory => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
const id = uuidv4();
|
||||||
|
const { message, responseTimeMs, status, statusCode } = result;
|
||||||
|
const entry: MonitorHistory = {
|
||||||
|
checkedAt,
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
monitorId,
|
||||||
|
responseTimeMs,
|
||||||
|
status,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`INSERT INTO monitor_history
|
||||||
|
(id, monitor_id, status, response_time_ms, status_code, message, checked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
entry.id,
|
||||||
|
entry.monitorId,
|
||||||
|
entry.status,
|
||||||
|
entry.responseTimeMs,
|
||||||
|
entry.statusCode,
|
||||||
|
entry.message,
|
||||||
|
entry.checkedAt,
|
||||||
|
);
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the appropriate check function for the monitor type.
|
||||||
|
* @param monitor - The monitor to check.
|
||||||
|
* @returns A promise resolving to the check result.
|
||||||
|
*/
|
||||||
|
const runCheckByType = async(monitor: Monitor): Promise<CheckResult> => {
|
||||||
|
if (monitor.type === "https") {
|
||||||
|
return await checkHttps(monitor);
|
||||||
|
}
|
||||||
|
if (monitor.type === "https_keyword") {
|
||||||
|
return await checkHttpsKeyword(monitor);
|
||||||
|
}
|
||||||
|
if (monitor.type === "https_status") {
|
||||||
|
return await checkHttpsStatus(monitor);
|
||||||
|
}
|
||||||
|
if (monitor.type === "mongodb") {
|
||||||
|
return await checkMongoDB(monitor);
|
||||||
|
}
|
||||||
|
return await checkPort(monitor);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the appropriate check for a monitor, records the result, and fires a
|
||||||
|
* webhook notification on status changes.
|
||||||
|
* @param monitor - The monitor to check.
|
||||||
|
*/
|
||||||
|
const runCheck = async(monitor: Monitor): Promise<void> => {
|
||||||
|
const result = await runCheckByType(monitor);
|
||||||
|
const previousStatus = getLatestStatus(monitor.id);
|
||||||
|
recordResult(monitor.id, result);
|
||||||
|
|
||||||
|
if (previousStatus !== null && previousStatus !== result.status) {
|
||||||
|
const webhookMessage = result.status === "up"
|
||||||
|
? "Monitor recovered."
|
||||||
|
: result.message ?? "Status changed to down.";
|
||||||
|
await sendWebhook({
|
||||||
|
application: monitor.name,
|
||||||
|
message: webhookMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an interval in seconds to a cron expression (minimum one minute).
|
||||||
|
* @param intervalSeconds - The desired check interval in seconds.
|
||||||
|
* @returns A cron expression string.
|
||||||
|
*/
|
||||||
|
const buildCronExpression = (intervalSeconds: number): string => {
|
||||||
|
const minutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
||||||
|
return `*/${String(minutes)} * * * *`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules (or reschedules) a monitor's periodic check.
|
||||||
|
* @param monitor - The monitor to schedule.
|
||||||
|
*/
|
||||||
|
const scheduleMonitor = (monitor: Monitor): void => {
|
||||||
|
const existing = activeTasks.get(monitor.id);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
void existing.stop();
|
||||||
|
activeTasks.delete(monitor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.enabled === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = buildCronExpression(monitor.intervalSeconds);
|
||||||
|
|
||||||
|
const task = schedule(expression, () => {
|
||||||
|
runCheck(monitor).catch(() => {
|
||||||
|
// Monitoring continues regardless of individual check errors
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
activeTasks.set(monitor.id, task);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a monitor's scheduled task.
|
||||||
|
* @param monitorId - The ID of the monitor to unschedule.
|
||||||
|
*/
|
||||||
|
const unscheduleMonitor = (monitorId: string): void => {
|
||||||
|
const task = activeTasks.get(monitorId);
|
||||||
|
if (task !== undefined) {
|
||||||
|
void task.stop();
|
||||||
|
activeTasks.delete(monitorId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all enabled monitors from the database and starts their scheduled checks.
|
||||||
|
*/
|
||||||
|
const startEngine = (): void => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const monitors = database.
|
||||||
|
prepare<[], Monitor>(
|
||||||
|
`SELECT id, name, type, url, host, port, keyword,
|
||||||
|
allowed_status_codes AS allowedStatusCodes,
|
||||||
|
denied_status_codes AS deniedStatusCodes,
|
||||||
|
interval_seconds AS intervalSeconds,
|
||||||
|
enabled,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM monitors
|
||||||
|
WHERE enabled = 1`,
|
||||||
|
).
|
||||||
|
all();
|
||||||
|
|
||||||
|
for (const monitor of monitors) {
|
||||||
|
scheduleMonitor(monitor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops all active scheduled tasks and clears the task registry.
|
||||||
|
*/
|
||||||
|
const stopEngine = (): void => {
|
||||||
|
for (const task of activeTasks.values()) {
|
||||||
|
void task.stop();
|
||||||
|
}
|
||||||
|
activeTasks.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { scheduleMonitor, startEngine, stopEngine, unscheduleMonitor };
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { CheckResult, Monitor } from "../types/index.js";
|
||||||
|
|
||||||
|
const requestTimeoutMs = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an HTTP GET request and returns the status, body, and response time.
|
||||||
|
* @param url - The URL to fetch.
|
||||||
|
* @returns The HTTP status code, response body, and time taken.
|
||||||
|
*/
|
||||||
|
const performHttpsRequest = async(
|
||||||
|
url: string,
|
||||||
|
): Promise<{ body: string; responseTimeMs: number; status: number }> => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, requestTimeoutMs);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "follow",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
const responseTimeMs = Date.now() - start;
|
||||||
|
const { status } = response;
|
||||||
|
return { body, responseTimeMs, status };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a JSON-encoded array of status codes, returning an empty array for null.
|
||||||
|
* @param json - The JSON string or null.
|
||||||
|
* @returns An array of status code numbers.
|
||||||
|
*/
|
||||||
|
const parseStatusCodes = (json: string | null): Array<number> => {
|
||||||
|
if (json === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External data parsed at runtime
|
||||||
|
return JSON.parse(json) as Array<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an HTTPS endpoint returns a 2xx status code.
|
||||||
|
* @param monitor - The monitor configuration.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const checkHttps = async(monitor: Monitor): Promise<CheckResult> => {
|
||||||
|
if (monitor.url === null) {
|
||||||
|
return {
|
||||||
|
message: "Monitor has no URL configured.",
|
||||||
|
responseTimeMs: null,
|
||||||
|
status: "down",
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await performHttpsRequest(monitor.url);
|
||||||
|
const { status: httpStatus, responseTimeMs } = result;
|
||||||
|
const isUp = httpStatus >= 200 && httpStatus < 300;
|
||||||
|
const message = isUp
|
||||||
|
? null
|
||||||
|
: `HTTP ${String(httpStatus)}`;
|
||||||
|
const status = isUp
|
||||||
|
? "up"
|
||||||
|
: "down";
|
||||||
|
const statusCode = httpStatus;
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
responseTimeMs,
|
||||||
|
status,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error occurred.";
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
responseTimeMs,
|
||||||
|
status,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an HTTPS endpoint response body contains a keyword.
|
||||||
|
* @param monitor - The monitor configuration.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const checkHttpsKeyword = async(monitor: Monitor): Promise<CheckResult> => {
|
||||||
|
if (monitor.url === null) {
|
||||||
|
return {
|
||||||
|
message: "Monitor has no URL configured.",
|
||||||
|
responseTimeMs: null,
|
||||||
|
status: "down",
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (monitor.keyword === null) {
|
||||||
|
return {
|
||||||
|
message: "Monitor has no keyword configured.",
|
||||||
|
responseTimeMs: null,
|
||||||
|
status: "down",
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await performHttpsRequest(monitor.url);
|
||||||
|
const { body, responseTimeMs, status: httpStatus } = result;
|
||||||
|
const keywordFound = body.includes(monitor.keyword);
|
||||||
|
const message = keywordFound
|
||||||
|
? null
|
||||||
|
: `Keyword "${monitor.keyword}" not found in response.`;
|
||||||
|
const status = keywordFound
|
||||||
|
? "up"
|
||||||
|
: "down";
|
||||||
|
const statusCode = httpStatus;
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
responseTimeMs,
|
||||||
|
status,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error occurred.";
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
responseTimeMs,
|
||||||
|
status,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the HTTP status code against allowed and denied lists.
|
||||||
|
* @param httpStatus - The HTTP status code returned.
|
||||||
|
* @param responseTimeMs - The response time in milliseconds.
|
||||||
|
* @param codes - The allowed and denied status code lists.
|
||||||
|
* @param codes.allowed - Status codes that indicate up.
|
||||||
|
* @param codes.denied - Status codes that indicate down.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const evaluateStatusCode = (
|
||||||
|
httpStatus: number,
|
||||||
|
responseTimeMs: number,
|
||||||
|
{ allowed, denied }: { allowed: Array<number>; denied: Array<number> },
|
||||||
|
): CheckResult => {
|
||||||
|
const codeString = String(httpStatus);
|
||||||
|
|
||||||
|
if (denied.includes(httpStatus)) {
|
||||||
|
const message = `HTTP ${codeString} is in the denied status codes list.`;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = httpStatus;
|
||||||
|
return { message, responseTimeMs, status, statusCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.length > 0 && !allowed.includes(httpStatus)) {
|
||||||
|
const message = `HTTP ${codeString} is not in the allowed status codes list.`;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = httpStatus;
|
||||||
|
return { message, responseTimeMs, status, statusCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = null;
|
||||||
|
const status = "up";
|
||||||
|
const statusCode = httpStatus;
|
||||||
|
return { message, responseTimeMs, status, statusCode };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the HTTP request and evaluates the status code result.
|
||||||
|
* @param url - The URL to fetch.
|
||||||
|
* @param allowed - Status codes that indicate up.
|
||||||
|
* @param denied - Status codes that indicate down.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const checkHttpsStatusRequest = async(
|
||||||
|
url: string,
|
||||||
|
allowed: Array<number>,
|
||||||
|
denied: Array<number>,
|
||||||
|
): Promise<CheckResult> => {
|
||||||
|
try {
|
||||||
|
const result = await performHttpsRequest(url);
|
||||||
|
const { responseTimeMs, status: httpStatus } = result;
|
||||||
|
return evaluateStatusCode(httpStatus, responseTimeMs, { allowed, denied });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error occurred.";
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
responseTimeMs,
|
||||||
|
status,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an HTTPS endpoint returns an allowed (or non-denied) status code.
|
||||||
|
* @param monitor - The monitor configuration.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const checkHttpsStatus = async(monitor: Monitor): Promise<CheckResult> => {
|
||||||
|
if (monitor.url === null) {
|
||||||
|
return {
|
||||||
|
message: "Monitor has no URL configured.",
|
||||||
|
responseTimeMs: null,
|
||||||
|
status: "down",
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = parseStatusCodes(monitor.allowedStatusCodes);
|
||||||
|
const denied = parseStatusCodes(monitor.deniedStatusCodes);
|
||||||
|
return await checkHttpsStatusRequest(monitor.url, allowed, denied);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { checkHttps, checkHttpsKeyword, checkHttpsStatus };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
import type { CheckResult, Monitor } from "../types/index.js";
|
||||||
|
|
||||||
|
const connectionTimeoutMs = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a MongoDB connection, sends an admin ping, then closes the connection.
|
||||||
|
* @param connectionString - The MongoDB connection URI.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const performMongoDatabasePing = async(
|
||||||
|
connectionString: string,
|
||||||
|
): Promise<CheckResult> => {
|
||||||
|
const client = new MongoClient(connectionString, {
|
||||||
|
connectTimeoutMS: connectionTimeoutMs,
|
||||||
|
serverSelectionTimeoutMS: connectionTimeoutMs,
|
||||||
|
});
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.db("admin").command({ ping: 1 });
|
||||||
|
const responseTimeMs = Date.now() - start;
|
||||||
|
const message = null;
|
||||||
|
const status = "up";
|
||||||
|
const statusCode = null;
|
||||||
|
return { message, responseTimeMs, status, statusCode };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error occurred.";
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
return { message, responseTimeMs, status, statusCode };
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a MongoDB Atlas cluster responds to an admin ping.
|
||||||
|
* @param monitor - The monitor configuration.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const checkMongoDB = async(monitor: Monitor): Promise<CheckResult> => {
|
||||||
|
if (monitor.url === null) {
|
||||||
|
const message = "Monitor has no connection string configured.";
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
return { message, responseTimeMs, status, statusCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await performMongoDatabasePing(monitor.url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { checkMongoDB };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { createConnection } from "node:net";
|
||||||
|
import type { CheckResult, Monitor } from "../types/index.js";
|
||||||
|
|
||||||
|
const connectionTimeoutMs = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a TCP connection and resolves with the result when it connects,
|
||||||
|
* times out, or errors.
|
||||||
|
* @param host - The hostname to connect to.
|
||||||
|
* @param port - The port to connect to.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Wraps a callback-based API
|
||||||
|
const performPortCheck = (host: string, port: number): Promise<CheckResult> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const timeout = connectionTimeoutMs;
|
||||||
|
|
||||||
|
const socket = createConnection({ host, port, timeout });
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
const responseTimeMs = Date.now() - start;
|
||||||
|
socket.destroy();
|
||||||
|
const message = null;
|
||||||
|
const status = "up";
|
||||||
|
const statusCode = null;
|
||||||
|
resolve({ message, responseTimeMs, status, statusCode });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
const message = `Connection timed out after ${String(connectionTimeoutMs)}ms.`;
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
resolve({ message, responseTimeMs, status, statusCode });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
const { message } = error;
|
||||||
|
const responseTimeMs = null;
|
||||||
|
const status = "down";
|
||||||
|
const statusCode = null;
|
||||||
|
resolve({ message, responseTimeMs, status, statusCode });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a TCP port is reachable on the configured host.
|
||||||
|
* @param monitor - The monitor configuration.
|
||||||
|
* @returns The check result.
|
||||||
|
*/
|
||||||
|
const checkPort = async(monitor: Monitor): Promise<CheckResult> => {
|
||||||
|
if (monitor.host === null || monitor.port === null) {
|
||||||
|
return {
|
||||||
|
message: "Monitor has no host or port configured.",
|
||||||
|
responseTimeMs: null,
|
||||||
|
status: "down",
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await performPortCheck(monitor.host, monitor.port);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { checkPort };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { WebhookPayload } from "../types/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSTs a status-change payload to the configured webhook URL.
|
||||||
|
* Failures are silently swallowed — monitoring continues regardless.
|
||||||
|
* @param payload - The data to send.
|
||||||
|
*/
|
||||||
|
const sendWebhook = async(payload: WebhookPayload): Promise<void> => {
|
||||||
|
const webhookUrl = process.env.WEBHOOK_URL;
|
||||||
|
if (webhookUrl === undefined || webhookUrl === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(webhookUrl, {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard HTTP header name
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Webhook delivery failure is non-fatal — monitoring continues regardless
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { sendWebhook };
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
|
import { sign, verify } from "hono/jwt";
|
||||||
|
import { discordRedirectUri } from "../config.js";
|
||||||
|
|
||||||
|
const discordApi = "https://discord.com/api/v10";
|
||||||
|
const sessionCookie = "oriana_session";
|
||||||
|
// 7 days
|
||||||
|
const sessionDurationSeconds = 60 * 60 * 24 * 7;
|
||||||
|
const jwtAlgorithm = "HS256";
|
||||||
|
|
||||||
|
interface DiscordConfig {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordTokenResponse {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API field name
|
||||||
|
access_token: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API field name
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordUser {
|
||||||
|
avatar: string | null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API field name
|
||||||
|
global_name: string | null;
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionPayload {
|
||||||
|
avatar: string | null;
|
||||||
|
exp: number;
|
||||||
|
globalName: string | null;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and validates the SESSION_SECRET environment variable.
|
||||||
|
* @returns The session secret string.
|
||||||
|
* @throws If the secret is not configured.
|
||||||
|
*/
|
||||||
|
const getSessionSecret = (): string => {
|
||||||
|
const secret = process.env.SESSION_SECRET;
|
||||||
|
if (secret === undefined || secret === "") {
|
||||||
|
throw new Error("SESSION_SECRET is not set.");
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads Discord OAuth environment variables, returning null if any are missing.
|
||||||
|
* @returns The Discord config object, or null if not configured.
|
||||||
|
*/
|
||||||
|
const getDiscordConfig = (): DiscordConfig | null => {
|
||||||
|
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||||
|
const ownerId = process.env.DISCORD_OWNER_ID;
|
||||||
|
if (
|
||||||
|
clientId === undefined
|
||||||
|
|| clientSecret === undefined
|
||||||
|
|| ownerId === undefined
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { clientId, clientSecret, ownerId };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a session JWT and returns the decoded payload if valid.
|
||||||
|
* @param cookieValue - The raw cookie string from the request.
|
||||||
|
* @returns The session payload, or null if missing or invalid.
|
||||||
|
*/
|
||||||
|
const getSession = async(
|
||||||
|
cookieValue: string | undefined,
|
||||||
|
): Promise<SessionPayload | null> => {
|
||||||
|
if (cookieValue === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await verify(cookieValue, getSessionSecret(), jwtAlgorithm);
|
||||||
|
const { userId } = raw;
|
||||||
|
const { username } = raw;
|
||||||
|
if (typeof userId !== "string" || typeof username !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawAvatar = raw.avatar;
|
||||||
|
const rawGlobalName = raw.globalName;
|
||||||
|
const avatar = typeof rawAvatar === "string"
|
||||||
|
? rawAvatar
|
||||||
|
: null;
|
||||||
|
const globalName = typeof rawGlobalName === "string"
|
||||||
|
? rawGlobalName
|
||||||
|
: null;
|
||||||
|
const exp = raw.exp ?? 0;
|
||||||
|
return {
|
||||||
|
avatar,
|
||||||
|
exp,
|
||||||
|
globalName,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the session payload only if the authenticated user is the owner.
|
||||||
|
* @param cookieValue - The raw cookie string from the request.
|
||||||
|
* @returns The session payload for the owner, or null.
|
||||||
|
*/
|
||||||
|
const requireAuth = async(
|
||||||
|
cookieValue: string | undefined,
|
||||||
|
): Promise<SessionPayload | null> => {
|
||||||
|
const session = await getSession(cookieValue);
|
||||||
|
const ownerId = process.env.DISCORD_OWNER_ID;
|
||||||
|
if (session === null || session.userId !== ownerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges a Discord OAuth2 code for an access token.
|
||||||
|
* @param code - The authorisation code from Discord.
|
||||||
|
* @param clientId - The Discord application client ID.
|
||||||
|
* @param clientSecret - The Discord application client secret.
|
||||||
|
* @returns The access token string, or null on failure.
|
||||||
|
*/
|
||||||
|
const exchangeCodeForToken = async(
|
||||||
|
code: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const tokenResponse = await fetch(`${discordApi}/oauth2/token`, {
|
||||||
|
body: new URLSearchParams({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
client_id: clientId,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code: code,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
redirect_uri: discordRedirectUri,
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard HTTP header name
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response
|
||||||
|
const data = (await tokenResponse.json()) as DiscordTokenResponse;
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the authenticated Discord user's profile.
|
||||||
|
* @param accessToken - The OAuth2 bearer token.
|
||||||
|
* @returns The user profile, or null on failure.
|
||||||
|
*/
|
||||||
|
const fetchDiscordUser = async(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<DiscordUser | null> => {
|
||||||
|
const userResponse = await fetch(`${discordApi}/users/@me`, {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard HTTP header name
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!userResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response
|
||||||
|
return (await userResponse.json()) as DiscordUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const authRoutes = new Hono();
|
||||||
|
|
||||||
|
authRoutes.get("/discord", (c) => {
|
||||||
|
const config = getDiscordConfig();
|
||||||
|
if (config === null) {
|
||||||
|
return c.json({ error: "Discord OAuth not configured." }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters = new URLSearchParams({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
client_id: config.clientId,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
redirect_uri: discordRedirectUri,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- OAuth param name
|
||||||
|
response_type: "code",
|
||||||
|
scope: "identify",
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.redirect(
|
||||||
|
`https://discord.com/oauth2/authorize?${parameters.toString()}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.get("/discord/callback", async(c) => {
|
||||||
|
const code = c.req.query("code");
|
||||||
|
if (code === undefined) {
|
||||||
|
return c.json({ error: "No authorisation code provided." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getDiscordConfig();
|
||||||
|
if (config === null) {
|
||||||
|
return c.json({ error: "Discord OAuth not configured." }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, clientSecret, ownerId } = config;
|
||||||
|
const accessToken = await exchangeCodeForToken(code, clientId, clientSecret);
|
||||||
|
if (accessToken === null) {
|
||||||
|
return c.json({ error: "Failed to exchange code for token." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await fetchDiscordUser(accessToken);
|
||||||
|
if (user === null) {
|
||||||
|
return c.json({ error: "Failed to fetch Discord user." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id !== ownerId) {
|
||||||
|
return c.redirect("/?error=unauthorised");
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + sessionDurationSeconds;
|
||||||
|
const { avatar, global_name: globalName, id: userId, username } = user;
|
||||||
|
const token = await sign(
|
||||||
|
{ avatar, exp, globalName, userId, username },
|
||||||
|
getSessionSecret(),
|
||||||
|
jwtAlgorithm,
|
||||||
|
);
|
||||||
|
|
||||||
|
setCookie(c, sessionCookie, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: sessionDurationSeconds,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "Lax",
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.redirect("/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.post("/logout", (c) => {
|
||||||
|
deleteCookie(c, sessionCookie, { path: "/" });
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.get("/me", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
return c.json({
|
||||||
|
avatar: session.avatar,
|
||||||
|
globalName: session.globalName,
|
||||||
|
userId: session.userId,
|
||||||
|
username: session.username,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export type { SessionPayload };
|
||||||
|
export { authRoutes, getSession, requireAuth, sessionCookie };
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { getDatabase } from "../database/index.js";
|
||||||
|
import { requireAuth, sessionCookie } from "./auth.js";
|
||||||
|
import type {
|
||||||
|
Incident,
|
||||||
|
IncidentStatus,
|
||||||
|
MaintenanceWindow,
|
||||||
|
} from "../types/index.js";
|
||||||
|
|
||||||
|
// ── Incidents ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const incidentSelect = `
|
||||||
|
SELECT id, title, message, status,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM incidents`;
|
||||||
|
|
||||||
|
const incidentsRoutes = new Hono();
|
||||||
|
|
||||||
|
incidentsRoutes.get("/", (c) => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const incidents = database.
|
||||||
|
prepare<[], Incident>(`${incidentSelect} ORDER BY created_at DESC`).
|
||||||
|
all();
|
||||||
|
return c.json(incidents);
|
||||||
|
});
|
||||||
|
|
||||||
|
incidentsRoutes.post("/", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json<{
|
||||||
|
message: string;
|
||||||
|
status: IncidentStatus;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const incident: Incident = {
|
||||||
|
createdAt: now,
|
||||||
|
id: uuidv4(),
|
||||||
|
message: body.message,
|
||||||
|
status: body.status,
|
||||||
|
title: body.title,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const database = getDatabase();
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`INSERT INTO incidents (id, title, message, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
incident.id,
|
||||||
|
incident.title,
|
||||||
|
incident.message,
|
||||||
|
incident.status,
|
||||||
|
incident.createdAt,
|
||||||
|
incident.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(incident, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
incidentsRoutes.put("/:id", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const database = getDatabase();
|
||||||
|
const existing = database.
|
||||||
|
prepare<[string], Incident>(`${incidentSelect} WHERE id = ?`).
|
||||||
|
get(id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return c.json({ error: "Incident not found." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json<
|
||||||
|
Partial<{ message: string; status: IncidentStatus; title: string }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const updated: Incident = {
|
||||||
|
...existing,
|
||||||
|
message: body.message ?? existing.message,
|
||||||
|
status: body.status ?? existing.status,
|
||||||
|
title: body.title ?? existing.title,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`UPDATE incidents
|
||||||
|
SET title=?, message=?, status=?, updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
updated.title,
|
||||||
|
updated.message,
|
||||||
|
updated.status,
|
||||||
|
updated.updatedAt,
|
||||||
|
updated.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
incidentsRoutes.delete("/:id", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const database = getDatabase();
|
||||||
|
const existing = database.
|
||||||
|
prepare<[string], { id: string }>("SELECT id FROM incidents WHERE id = ?").
|
||||||
|
get(id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return c.json({ error: "Incident not found." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare("DELETE FROM incidents WHERE id = ?").run(id);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Maintenance Windows ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const maintenanceSelect = `
|
||||||
|
SELECT id, title, message,
|
||||||
|
start_time AS startTime,
|
||||||
|
end_time AS endTime,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM maintenance_windows`;
|
||||||
|
|
||||||
|
const maintenanceRoutes = new Hono();
|
||||||
|
|
||||||
|
maintenanceRoutes.get("/", (c) => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const windows = database.
|
||||||
|
prepare<[], MaintenanceWindow>(
|
||||||
|
`${maintenanceSelect} ORDER BY start_time ASC`,
|
||||||
|
).
|
||||||
|
all();
|
||||||
|
return c.json(windows);
|
||||||
|
});
|
||||||
|
|
||||||
|
maintenanceRoutes.post("/", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json<{
|
||||||
|
endTime: string;
|
||||||
|
message: string;
|
||||||
|
startTime: string;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const window: MaintenanceWindow = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
endTime: body.endTime,
|
||||||
|
id: uuidv4(),
|
||||||
|
message: body.message,
|
||||||
|
startTime: body.startTime,
|
||||||
|
title: body.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
const database = getDatabase();
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`INSERT INTO maintenance_windows (id, title, message, start_time, end_time, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
window.id,
|
||||||
|
window.title,
|
||||||
|
window.message,
|
||||||
|
window.startTime,
|
||||||
|
window.endTime,
|
||||||
|
window.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(window, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
maintenanceRoutes.put("/:id", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const database = getDatabase();
|
||||||
|
const existing = database.
|
||||||
|
prepare<[string], MaintenanceWindow>(
|
||||||
|
`${maintenanceSelect} WHERE id = ?`,
|
||||||
|
).
|
||||||
|
get(id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return c.json({ error: "Maintenance window not found." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json<
|
||||||
|
Partial<{
|
||||||
|
endTime: string;
|
||||||
|
message: string;
|
||||||
|
startTime: string;
|
||||||
|
title: string;
|
||||||
|
}>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const updated: MaintenanceWindow = {
|
||||||
|
...existing,
|
||||||
|
endTime: body.endTime ?? existing.endTime,
|
||||||
|
message: body.message ?? existing.message,
|
||||||
|
startTime: body.startTime ?? existing.startTime,
|
||||||
|
title: body.title ?? existing.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`UPDATE maintenance_windows
|
||||||
|
SET title=?, message=?, start_time=?, end_time=?
|
||||||
|
WHERE id=?`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
updated.title,
|
||||||
|
updated.message,
|
||||||
|
updated.startTime,
|
||||||
|
updated.endTime,
|
||||||
|
updated.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
maintenanceRoutes.delete("/:id", async(c) => {
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const database = getDatabase();
|
||||||
|
const existing = database.
|
||||||
|
prepare<[string], { id: string }>(
|
||||||
|
"SELECT id FROM maintenance_windows WHERE id = ?",
|
||||||
|
).
|
||||||
|
get(id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return c.json({ error: "Maintenance window not found." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare("DELETE FROM maintenance_windows WHERE id = ?").run(id);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { incidentsRoutes, maintenanceRoutes };
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { getDatabase } from "../database/index.js";
|
||||||
|
import { scheduleMonitor, unscheduleMonitor } from "../monitors/engine.js";
|
||||||
|
import { requireAuth, sessionCookie } from "./auth.js";
|
||||||
|
import type { Monitor, MonitorHistory, MonitorType } from "../types/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SELECT clause that applies camelCase aliases for snake_case DB columns.
|
||||||
|
*/
|
||||||
|
const monitorSelect = `
|
||||||
|
SELECT id, name, type, url, host, port, keyword,
|
||||||
|
allowed_status_codes AS allowedStatusCodes,
|
||||||
|
denied_status_codes AS deniedStatusCodes,
|
||||||
|
interval_seconds AS intervalSeconds,
|
||||||
|
category,
|
||||||
|
is_public AS isPublic,
|
||||||
|
enabled,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM monitors`;
|
||||||
|
|
||||||
|
type MonitorUpdateBody = Partial<{
|
||||||
|
allowedStatusCodes: Array<number>;
|
||||||
|
category: string;
|
||||||
|
deniedStatusCodes: Array<number>;
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
intervalSeconds: number;
|
||||||
|
isPublic: boolean;
|
||||||
|
keyword: string;
|
||||||
|
name: string;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
interface MonitorCreateBody {
|
||||||
|
allowedStatusCodes?: Array<number>;
|
||||||
|
category?: string;
|
||||||
|
deniedStatusCodes?: Array<number>;
|
||||||
|
host?: string;
|
||||||
|
intervalSeconds?: number;
|
||||||
|
isPublic?: boolean;
|
||||||
|
keyword?: string;
|
||||||
|
name: string;
|
||||||
|
port?: number;
|
||||||
|
type: MonitorType;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises a status-code list for DB storage, or returns the existing value when unchanged.
|
||||||
|
* @param codes - The new codes array, or undefined if not being updated.
|
||||||
|
* @param existing - The current serialised value.
|
||||||
|
* @returns The serialised JSON string, or the existing value.
|
||||||
|
*/
|
||||||
|
const serializeStatusCodes = (
|
||||||
|
codes: Array<number> | undefined,
|
||||||
|
existing: string | null,
|
||||||
|
): string | null => {
|
||||||
|
if (codes === undefined) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return JSON.stringify(codes);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges an existing monitor record with an update body.
|
||||||
|
* @param existing - The current monitor from the database.
|
||||||
|
* @param body - The partial update fields from the request.
|
||||||
|
* @returns A new Monitor object with the updates applied.
|
||||||
|
*/
|
||||||
|
const buildUpdatedMonitor = (
|
||||||
|
existing: Monitor,
|
||||||
|
body: MonitorUpdateBody,
|
||||||
|
): Monitor => {
|
||||||
|
const allowedStatusCodes = serializeStatusCodes(
|
||||||
|
body.allowedStatusCodes,
|
||||||
|
existing.allowedStatusCodes,
|
||||||
|
);
|
||||||
|
const category = body.category ?? existing.category;
|
||||||
|
const deniedStatusCodes = serializeStatusCodes(
|
||||||
|
body.deniedStatusCodes,
|
||||||
|
existing.deniedStatusCodes,
|
||||||
|
);
|
||||||
|
const enabled = body.enabled === undefined
|
||||||
|
? existing.enabled
|
||||||
|
: Number(body.enabled);
|
||||||
|
const host = body.host ?? existing.host;
|
||||||
|
const intervalSeconds = body.intervalSeconds ?? existing.intervalSeconds;
|
||||||
|
const isPublic = body.isPublic === undefined
|
||||||
|
? existing.isPublic
|
||||||
|
: Number(body.isPublic);
|
||||||
|
const keyword = body.keyword ?? existing.keyword;
|
||||||
|
const name = body.name ?? existing.name;
|
||||||
|
const port = body.port ?? existing.port;
|
||||||
|
const url = body.url ?? existing.url;
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
allowedStatusCodes,
|
||||||
|
category,
|
||||||
|
deniedStatusCodes,
|
||||||
|
enabled,
|
||||||
|
host,
|
||||||
|
intervalSeconds,
|
||||||
|
isPublic,
|
||||||
|
keyword,
|
||||||
|
name,
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new Monitor object from a creation request body.
|
||||||
|
* @param body - The request body fields.
|
||||||
|
* @returns The new Monitor object.
|
||||||
|
*/
|
||||||
|
const buildNewMonitor = (body: MonitorCreateBody): Monitor => {
|
||||||
|
const allowedStatusCodes = body.allowedStatusCodes === undefined
|
||||||
|
? null
|
||||||
|
: JSON.stringify(body.allowedStatusCodes);
|
||||||
|
const category = body.category ?? "General";
|
||||||
|
const deniedStatusCodes = body.deniedStatusCodes === undefined
|
||||||
|
? null
|
||||||
|
: JSON.stringify(body.deniedStatusCodes);
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const enabled = 1;
|
||||||
|
const host = body.host ?? null;
|
||||||
|
const id = uuidv4();
|
||||||
|
const intervalSeconds = body.intervalSeconds ?? 60;
|
||||||
|
const isPublic = body.isPublic === false
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
|
const keyword = body.keyword ?? null;
|
||||||
|
const { name } = body;
|
||||||
|
const port = body.port ?? null;
|
||||||
|
const { type } = body;
|
||||||
|
const url = body.url ?? null;
|
||||||
|
return {
|
||||||
|
allowedStatusCodes,
|
||||||
|
category,
|
||||||
|
createdAt,
|
||||||
|
deniedStatusCodes,
|
||||||
|
enabled,
|
||||||
|
host,
|
||||||
|
id,
|
||||||
|
intervalSeconds,
|
||||||
|
isPublic,
|
||||||
|
keyword,
|
||||||
|
name,
|
||||||
|
port,
|
||||||
|
type,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const monitorsRoutes = new Hono();
|
||||||
|
|
||||||
|
monitorsRoutes.use("*", async(c, next) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Hono cookie API
|
||||||
|
const token = getCookie(c, sessionCookie);
|
||||||
|
const session = await requireAuth(token);
|
||||||
|
if (session === null) {
|
||||||
|
return c.json({ error: "Unauthorised." }, 401);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
return c.res;
|
||||||
|
});
|
||||||
|
|
||||||
|
monitorsRoutes.get("/", (c) => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const monitors = database.
|
||||||
|
prepare<[], Monitor>(`${monitorSelect} ORDER BY name ASC`).
|
||||||
|
all();
|
||||||
|
return c.json(monitors);
|
||||||
|
});
|
||||||
|
|
||||||
|
monitorsRoutes.post("/", async(c) => {
|
||||||
|
const body = await c.req.json<MonitorCreateBody>();
|
||||||
|
const monitor = buildNewMonitor(body);
|
||||||
|
|
||||||
|
const database = getDatabase();
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`INSERT INTO monitors
|
||||||
|
(id, name, type, url, host, port, keyword,
|
||||||
|
allowed_status_codes, denied_status_codes, interval_seconds,
|
||||||
|
category, is_public, enabled, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
monitor.id,
|
||||||
|
monitor.name,
|
||||||
|
monitor.type,
|
||||||
|
monitor.url,
|
||||||
|
monitor.host,
|
||||||
|
monitor.port,
|
||||||
|
monitor.keyword,
|
||||||
|
monitor.allowedStatusCodes,
|
||||||
|
monitor.deniedStatusCodes,
|
||||||
|
monitor.intervalSeconds,
|
||||||
|
monitor.category,
|
||||||
|
monitor.isPublic,
|
||||||
|
monitor.enabled,
|
||||||
|
monitor.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleMonitor(monitor);
|
||||||
|
return c.json(monitor, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
monitorsRoutes.put("/:id", async(c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const database = getDatabase();
|
||||||
|
const existing = database.
|
||||||
|
prepare<[string], Monitor>(`${monitorSelect} WHERE id = ?`).
|
||||||
|
get(id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return c.json({ error: "Monitor not found." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json<MonitorUpdateBody>();
|
||||||
|
const updated = buildUpdatedMonitor(existing, body);
|
||||||
|
|
||||||
|
database.
|
||||||
|
prepare(
|
||||||
|
`UPDATE monitors
|
||||||
|
SET name=?, url=?, host=?, port=?, keyword=?,
|
||||||
|
allowed_status_codes=?, denied_status_codes=?,
|
||||||
|
interval_seconds=?, category=?, is_public=?, enabled=?
|
||||||
|
WHERE id=?`,
|
||||||
|
).
|
||||||
|
run(
|
||||||
|
updated.name,
|
||||||
|
updated.url,
|
||||||
|
updated.host,
|
||||||
|
updated.port,
|
||||||
|
updated.keyword,
|
||||||
|
updated.allowedStatusCodes,
|
||||||
|
updated.deniedStatusCodes,
|
||||||
|
updated.intervalSeconds,
|
||||||
|
updated.category,
|
||||||
|
updated.isPublic,
|
||||||
|
updated.enabled,
|
||||||
|
updated.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleMonitor(updated);
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
monitorsRoutes.delete("/:id", (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const database = getDatabase();
|
||||||
|
const existing = database.
|
||||||
|
prepare<[string], { id: string }>("SELECT id FROM monitors WHERE id = ?").
|
||||||
|
get(id);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return c.json({ error: "Monitor not found." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
unscheduleMonitor(id);
|
||||||
|
database.prepare("DELETE FROM monitors WHERE id = ?").run(id);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
monitorsRoutes.get("/:id/history", (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const limitRaw = c.req.query("limit") ?? "100";
|
||||||
|
const limit = Number(limitRaw);
|
||||||
|
const database = getDatabase();
|
||||||
|
const history = database.
|
||||||
|
prepare<[string, number], MonitorHistory>(
|
||||||
|
`SELECT id,
|
||||||
|
monitor_id AS monitorId,
|
||||||
|
status,
|
||||||
|
response_time_ms AS responseTimeMs,
|
||||||
|
status_code AS statusCode,
|
||||||
|
message,
|
||||||
|
checked_at AS checkedAt
|
||||||
|
FROM monitor_history
|
||||||
|
WHERE monitor_id = ?
|
||||||
|
ORDER BY checked_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
).
|
||||||
|
all(id, limit);
|
||||||
|
return c.json(history);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { monitorsRoutes };
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { getDatabase } from "../database/index.js";
|
||||||
|
import type {
|
||||||
|
Incident,
|
||||||
|
MaintenanceWindow,
|
||||||
|
Monitor,
|
||||||
|
MonitorHistory,
|
||||||
|
} from "../types/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the most recent history entry for a given monitor.
|
||||||
|
* @param monitorId - Restricts the query to entries belonging to this monitor.
|
||||||
|
* @returns The latest history record, or undefined if none exists.
|
||||||
|
*/
|
||||||
|
const fetchLatestHistory = (monitorId: string): MonitorHistory | undefined => {
|
||||||
|
const database = getDatabase();
|
||||||
|
return database.
|
||||||
|
prepare<[string], MonitorHistory>(
|
||||||
|
`SELECT id,
|
||||||
|
monitor_id AS monitorId,
|
||||||
|
status,
|
||||||
|
response_time_ms AS responseTimeMs,
|
||||||
|
status_code AS statusCode,
|
||||||
|
message,
|
||||||
|
checked_at AS checkedAt
|
||||||
|
FROM monitor_history
|
||||||
|
WHERE monitor_id = ?
|
||||||
|
ORDER BY checked_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
).
|
||||||
|
get(monitorId);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HistoryFields {
|
||||||
|
lastChecked: string | null;
|
||||||
|
message: string | null;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
status: string;
|
||||||
|
statusCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts summary fields from a history record, providing defaults when absent.
|
||||||
|
* @param latest - The latest history record, or undefined if none exists.
|
||||||
|
* @returns Flat summary fields with null/unknown defaults.
|
||||||
|
*/
|
||||||
|
const extractHistoryFields = (
|
||||||
|
latest: MonitorHistory | undefined,
|
||||||
|
): HistoryFields => {
|
||||||
|
if (latest === undefined) {
|
||||||
|
return {
|
||||||
|
lastChecked: null,
|
||||||
|
message: null,
|
||||||
|
responseTimeMs: null,
|
||||||
|
status: "unknown",
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lastChecked: latest.checkedAt,
|
||||||
|
message: latest.message,
|
||||||
|
responseTimeMs: latest.responseTimeMs,
|
||||||
|
status: latest.status,
|
||||||
|
statusCode: latest.statusCode,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches each enabled, publicly visible monitor's latest status from history.
|
||||||
|
* @returns An array of monitor summary objects.
|
||||||
|
*/
|
||||||
|
interface MonitorSummary extends HistoryFields {
|
||||||
|
category: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMonitorStatuses = (): Array<MonitorSummary> => {
|
||||||
|
const database = getDatabase();
|
||||||
|
const monitors = database.
|
||||||
|
prepare<[], Monitor>(
|
||||||
|
`SELECT id, name, type, url, host, port, keyword,
|
||||||
|
allowed_status_codes AS allowedStatusCodes,
|
||||||
|
denied_status_codes AS deniedStatusCodes,
|
||||||
|
interval_seconds AS intervalSeconds,
|
||||||
|
category,
|
||||||
|
is_public AS isPublic,
|
||||||
|
enabled,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM monitors
|
||||||
|
WHERE enabled = 1 AND is_public = 1
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
).
|
||||||
|
all();
|
||||||
|
|
||||||
|
return monitors.map((monitor) => {
|
||||||
|
const fields = extractHistoryFields(fetchLatestHistory(monitor.id));
|
||||||
|
return {
|
||||||
|
category: monitor.category,
|
||||||
|
id: monitor.id,
|
||||||
|
name: monitor.name,
|
||||||
|
type: monitor.type,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all currently active (non-resolved) incidents.
|
||||||
|
* @returns An array of active incidents.
|
||||||
|
*/
|
||||||
|
const fetchActiveIncidents = (): Array<Incident> => {
|
||||||
|
const database = getDatabase();
|
||||||
|
return database.
|
||||||
|
prepare<[], Incident>(
|
||||||
|
`SELECT id, title, message, status,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM incidents
|
||||||
|
WHERE status != 'resolved'
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
).
|
||||||
|
all();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all maintenance windows that overlap the current time.
|
||||||
|
* @param now - The current ISO timestamp.
|
||||||
|
* @returns An array of active maintenance windows.
|
||||||
|
*/
|
||||||
|
const fetchActiveMaintenance = (now: string): Array<MaintenanceWindow> => {
|
||||||
|
const database = getDatabase();
|
||||||
|
return database.
|
||||||
|
prepare<[string, string], MaintenanceWindow>(
|
||||||
|
`SELECT id, title, message,
|
||||||
|
start_time AS startTime,
|
||||||
|
end_time AS endTime,
|
||||||
|
created_at AS createdAt
|
||||||
|
FROM maintenance_windows
|
||||||
|
WHERE start_time <= ? AND end_time >= ?
|
||||||
|
ORDER BY start_time ASC`,
|
||||||
|
).
|
||||||
|
all(now, now);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusRoutes = new Hono();
|
||||||
|
|
||||||
|
statusRoutes.get("/", (c) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const monitors = fetchMonitorStatuses();
|
||||||
|
const incidents = fetchActiveIncidents();
|
||||||
|
const maintenance = fetchActiveMaintenance(now);
|
||||||
|
return c.json({ incidents, maintenance, monitors });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { statusRoutes };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { authRoutes } from "./routes/auth.js";
|
||||||
|
import { incidentsRoutes, maintenanceRoutes } from "./routes/incidents.js";
|
||||||
|
import { monitorsRoutes } from "./routes/monitors.js";
|
||||||
|
import { statusRoutes } from "./routes/status.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and configures the Hono application with all routes and middleware.
|
||||||
|
* @returns The configured Hono app instance.
|
||||||
|
*/
|
||||||
|
const createApp = (): Hono => {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
"/api/*",
|
||||||
|
cors({
|
||||||
|
credentials: true,
|
||||||
|
origin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.route("/auth", authRoutes);
|
||||||
|
app.route("/api/status", statusRoutes);
|
||||||
|
app.route("/api/monitors", monitorsRoutes);
|
||||||
|
app.route("/api/incidents", incidentsRoutes);
|
||||||
|
app.route("/api/maintenance", maintenanceRoutes);
|
||||||
|
|
||||||
|
app.use("/*", serveStatic({ root: "./client/dist" }));
|
||||||
|
app.get("*", serveStatic({ path: "./client/dist/index.html" }));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { createApp };
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MonitorType =
|
||||||
|
| "https"
|
||||||
|
| "https_keyword"
|
||||||
|
| "https_status"
|
||||||
|
| "mongodb"
|
||||||
|
| "port";
|
||||||
|
|
||||||
|
type MonitorStatus = "up" | "down";
|
||||||
|
|
||||||
|
type IncidentStatus =
|
||||||
|
| "investigating"
|
||||||
|
| "identified"
|
||||||
|
| "monitoring"
|
||||||
|
| "resolved";
|
||||||
|
|
||||||
|
interface Monitor {
|
||||||
|
allowedStatusCodes: string | null;
|
||||||
|
category: string;
|
||||||
|
createdAt: string;
|
||||||
|
deniedStatusCodes: string | null;
|
||||||
|
enabled: number;
|
||||||
|
host: string | null;
|
||||||
|
id: string;
|
||||||
|
intervalSeconds: number;
|
||||||
|
isPublic: number;
|
||||||
|
keyword: string | null;
|
||||||
|
name: string;
|
||||||
|
port: number | null;
|
||||||
|
type: MonitorType;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonitorHistory {
|
||||||
|
checkedAt: string;
|
||||||
|
id: string;
|
||||||
|
message: string | null;
|
||||||
|
monitorId: string;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
status: MonitorStatus;
|
||||||
|
statusCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Incident {
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
status: IncidentStatus;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaintenanceWindow {
|
||||||
|
createdAt: string;
|
||||||
|
endTime: string;
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
startTime: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckResult {
|
||||||
|
message: string | null;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
status: MonitorStatus;
|
||||||
|
statusCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookPayload {
|
||||||
|
application: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CheckResult,
|
||||||
|
Incident,
|
||||||
|
IncidentStatus,
|
||||||
|
MaintenanceWindow,
|
||||||
|
Monitor,
|
||||||
|
MonitorHistory,
|
||||||
|
MonitorStatus,
|
||||||
|
MonitorType,
|
||||||
|
WebhookPayload,
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./prod",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"exclude": ["test/**/*.ts", "client/**/*.ts", "client/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
include: ["src/**/*.ts"],
|
||||||
|
exclude: ["src/types/**/*.ts"],
|
||||||
|
thresholds: {
|
||||||
|
statements: 100,
|
||||||
|
branches: 100,
|
||||||
|
functions: 100,
|
||||||
|
lines: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: ["test/**/*.spec.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user