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