Add an Uptime Kuma integration: monitor status and which server each one watches
New integration, following the existing pattern: config in-app (URL + API key, credentials encrypted at rest), its own Uptime Kuma page, an Integrations list entry, a Dashboard widget, and diagnostic-log/ integration-down-alert coverage for free via the shared withDiagLogging wrapper. Read-only -- no start/stop equivalent exists for a monitor. Uptime Kuma has no conventional REST API (the dashboard talks to it over Socket.IO); researched before writing any code, since guessing wrong here would have cost real time. The one machine-readable, authenticated endpoint that lists every monitor is its Prometheus exporter at GET /metrics, gated by HTTP Basic auth -- an API key as the password with the username left blank on current installs, or the real dashboard login on installs from before the API-key feature existed. This adapter authenticates the same way and parses that endpoint's text-exposition format itself (metrics: monitor_status, monitor_response_time, monitor_cert_days_remaining, monitor_uptime_ratio; labels: monitor_id, monitor_name, monitor_type, monitor_url, monitor_hostname, monitor_port), verified against the documented metric/label set and the actual upstream source (server/prometheus.js). A malformed line is skipped rather than failing the whole scrape. "What server is being monitored for what": each monitor's target (an IP for TCP checks, or the hostname out of the URL for HTTP/keyword checks) is matched against your servers' own IPs and hostnames -- reusing the same kind of match already used in the consistency report -- and linked to that server's page. Monitors with no single network target (groups, push monitors, DNS/keyword checks with a complex URL) are left unmatched rather than guessed at. Uptime Kuma's tags aren't read, since the Prometheus endpoint doesn't reliably distinguish a tag label from any other label it might add later. The username field is the first genuinely optional integration config field this app has had; IntegrationField gained an `optional` flag (server validation and both the add/edit web forms honor it) rather than special-casing Uptime Kuma. Verified with 48 backend checks (Prometheus text parsing including escaped quotes, decimals, negative numbers, and malformed lines; TCP vs. HTTP target/port extraction; every documented status code; server matching by IP, hostname, and short name, including no-match cases; the route's real HTTP round trip against a fake Uptime Kuma server, wrong credentials, upstream failures, roles, wrong/disabled/missing integration, diagnostic-log entries; the optional-field validation rule) and by driving the real page and the real Dashboard widget in a browser against the real routers, including CSV export and column sorting. Real dev database mtime untouched. Not verified: a real Uptime Kuma instance. Everything here was checked against Uptime Kuma's documented metric format, its actual upstream source, and a fake server built to match both -- not against a live installation. If your instance's /metrics output differs from what's documented (older version, unusual monitor types), the parser should degrade to an empty or partial monitor list rather than error, but that degradation itself hasn't been observed against the real thing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ada2e648e9
commit
1a2dd19736
19 files changed
+706
-15
No files matched your search
@@ -19,6 +19,7 @@ import Semaphore from "./pages/Semaphore";
|
||||
import Gitea from "./pages/Gitea";
|
||||
import Proxmox from "./pages/Proxmox";
|
||||
import Synology from "./pages/Synology";
|
||||
import UptimeKuma from "./pages/UptimeKuma";
|
||||
import Generator from "./pages/Generator";
|
||||
import Maintenance from "./pages/Maintenance";
|
||||
import Domains from "./pages/Domains";
|
||||
@@ -103,6 +104,7 @@ export default function App() {
|
||||
<Route path="/gitea" element={<Gitea user={user} />} />
|
||||
<Route path="/proxmox" element={<Proxmox user={user} />} />
|
||||
<Route path="/synology" element={<Synology user={user} />} />
|
||||
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
|
||||
+28
-1
@@ -428,7 +428,7 @@ export interface ManualTaskInput {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand";
|
||||
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma";
|
||||
|
||||
export interface IntegrationField {
|
||||
key: string;
|
||||
@@ -436,6 +436,7 @@ export interface IntegrationField {
|
||||
secret: boolean;
|
||||
type?: "text" | "password" | "checkbox";
|
||||
placeholder?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrationSummary {
|
||||
@@ -606,6 +607,28 @@ export interface DockhandContainersResponse {
|
||||
summary: { total: number; running: number };
|
||||
}
|
||||
|
||||
export type UptimeKumaMonitorStatus = "up" | "down" | "pending" | "maintenance" | "unknown";
|
||||
|
||||
export interface UptimeKumaMonitor {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
target: string | null;
|
||||
port: number | null;
|
||||
status: UptimeKumaMonitorStatus;
|
||||
responseTimeMs: number | null;
|
||||
certDaysRemaining: number | null;
|
||||
uptime24h: number | null;
|
||||
uptime30d: number | null;
|
||||
uptime1y: number | null;
|
||||
matchedServer: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export interface UptimeKumaMonitorsResponse {
|
||||
monitors: UptimeKumaMonitor[];
|
||||
summary: { total: number; up: number; down: number; pending: number; maintenance: number; unknown: number };
|
||||
}
|
||||
|
||||
export type SemaphoreTaskStatus =
|
||||
| "waiting"
|
||||
| "starting"
|
||||
@@ -1079,6 +1102,10 @@ export const api = {
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
uptimekuma: {
|
||||
monitors: (integrationId: number) =>
|
||||
request<UptimeKumaMonitorsResponse>(`/api/integrations/${integrationId}/uptimekuma/monitors`),
|
||||
},
|
||||
semaphore: {
|
||||
templates: (integrationId: number) =>
|
||||
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
|
||||
|
||||
@@ -17,6 +17,7 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
};
|
||||
|
||||
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
};
|
||||
|
||||
export default function IntegrationEditForm({
|
||||
@@ -132,7 +133,7 @@ export default function IntegrationEditForm({
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
className="form-control"
|
||||
placeholder={field.secret ? "Leave blank to keep the current value" : field.placeholder}
|
||||
required={!field.secret}
|
||||
required={!field.secret && !field.optional}
|
||||
value={(values[field.key] as string) ?? ""}
|
||||
onChange={(e) => setField(field.key, e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
};
|
||||
|
||||
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
@@ -135,7 +136,7 @@ export default function IntegrationForm({ onCreated, onCancel }: { onCreated: ()
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
className="form-control"
|
||||
placeholder={field.placeholder}
|
||||
required
|
||||
required={!field.optional}
|
||||
value={(values[field.key] as string) ?? ""}
|
||||
onChange={(e) => setField(field.key, e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconListCheck,
|
||||
IconShieldLock,
|
||||
IconRefresh,
|
||||
IconActivityHeartbeat,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
@@ -66,6 +67,7 @@ const NAV: NavEntry[] = [
|
||||
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
||||
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
||||
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -51,6 +51,14 @@ const SERVER_STATE_COLORS: Record<string, string> = {
|
||||
"No data": "#94a3b8",
|
||||
};
|
||||
|
||||
const MONITOR_STATE_COLORS: Record<string, string> = {
|
||||
Up: "#4ade80",
|
||||
Down: "#f87171",
|
||||
Pending: "#fbbf24",
|
||||
Maintenance: "#60a5fa",
|
||||
Unknown: "#94a3b8",
|
||||
};
|
||||
|
||||
const CONTAINER_STATE_COLORS: Record<string, string> = {
|
||||
running: "#4ade80",
|
||||
exited: "#f87171",
|
||||
@@ -193,8 +201,17 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
|
||||
{ type: "tailscale", label: "Tailscale" },
|
||||
{ type: "gitea", label: "Gitea" },
|
||||
{ type: "dockhand", label: "Dockhand / Docker" },
|
||||
{ type: "uptimekuma", label: "Uptime Kuma" },
|
||||
];
|
||||
|
||||
interface UptimeKumaSummary {
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
matched: number;
|
||||
statusBreakdown: { label: string; count: number }[];
|
||||
}
|
||||
|
||||
interface GiteaSummary {
|
||||
repoCount: number;
|
||||
privateCount: number;
|
||||
@@ -259,6 +276,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
|
||||
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
|
||||
const [uptimeKumaSummary, setUptimeKumaSummary] = useState<UptimeKumaSummary | null>(null);
|
||||
|
||||
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
|
||||
const [dnsError, setDnsError] = useState<string | null>(null);
|
||||
@@ -421,6 +439,27 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
.catch(() => setSynologySummary(null));
|
||||
}, [integrations]);
|
||||
|
||||
useEffect(() => {
|
||||
const uptimeKuma = integrations?.find((i) => i.type === "uptimekuma" && i.enabled);
|
||||
if (!uptimeKuma) {
|
||||
setUptimeKumaSummary(null);
|
||||
return;
|
||||
}
|
||||
api.integrations.uptimekuma
|
||||
.monitors(uptimeKuma.id)
|
||||
.then((res) => {
|
||||
const labelFor = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
setUptimeKumaSummary({
|
||||
up: res.summary.up,
|
||||
down: res.summary.down,
|
||||
total: res.summary.total,
|
||||
matched: res.monitors.filter((m) => m.matchedServer).length,
|
||||
statusBreakdown: breakdownFrom(res.monitors, (m) => labelFor(m.status)),
|
||||
});
|
||||
})
|
||||
.catch(() => setUptimeKumaSummary(null));
|
||||
}, [integrations]);
|
||||
|
||||
const domains = domainList?.domains ?? [];
|
||||
const expiredDomains = domains.filter((d) => d.status === "expired");
|
||||
const expiringDomains = domains.filter((d) => d.status === "expiring");
|
||||
@@ -725,7 +764,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
|
||||
<SectionHeader
|
||||
title="Integrations"
|
||||
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each integration is connected."
|
||||
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, and Uptime Kuma will appear here as each integration is connected."
|
||||
/>
|
||||
<div className="row row-cards">
|
||||
{WIDGETS.map(({ type, label }) => {
|
||||
@@ -736,7 +775,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary;
|
||||
const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary;
|
||||
const isLiveSynology = type === "synology" && integration && synologySummary;
|
||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
|
||||
const isLiveUptimeKuma = type === "uptimekuma" && integration && uptimeKumaSummary;
|
||||
const isLive =
|
||||
isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology || isLiveUptimeKuma;
|
||||
|
||||
return (
|
||||
<WidgetCard
|
||||
@@ -895,6 +936,23 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : isLiveUptimeKuma ? (
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Monitors" value={uptimeKumaSummary!.total} />
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<MiniStat label="Down" value={uptimeKumaSummary!.down} accent={uptimeKumaSummary!.down > 0 ? "#ef4444" : undefined} />
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<MiniStat label="Matched to a server" value={`${uptimeKumaSummary!.matched}/${uptimeKumaSummary!.total}`} />
|
||||
</div>
|
||||
</div>
|
||||
{uptimeKumaSummary!.statusBreakdown.length > 0 && (
|
||||
<BreakdownBar data={uptimeKumaSummary!.statusBreakdown} colors={MONITOR_STATE_COLORS} compact />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-secondary mt-2">
|
||||
{integration ? (
|
||||
|
||||
@@ -14,6 +14,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
};
|
||||
|
||||
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
|
||||
@@ -101,8 +102,8 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
<div>
|
||||
<h2 className="page-title mb-0">Integrations</h2>
|
||||
<div className="text-secondary small mt-1">
|
||||
Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand each get their own page once
|
||||
connected — add and manage credentials here.
|
||||
Tailscale, Proxmox, Synology, Semaphore, Gitea, Dockhand, and Uptime Kuma each get their own page
|
||||
once connected — add and manage credentials here.
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !adding && !editingIntegration && (
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type CurrentUser, type IntegrationSummary, type UptimeKumaMonitor, type UptimeKumaMonitorsResponse } from "../api/client";
|
||||
import { useSortable } from "../hooks/useSortable";
|
||||
import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { readableError } from "../utils/errors";
|
||||
|
||||
function statusBadge(monitor: UptimeKumaMonitor) {
|
||||
switch (monitor.status) {
|
||||
case "up":
|
||||
return <span className="badge bg-green-lt text-green">Up</span>;
|
||||
case "down":
|
||||
return <span className="badge bg-red-lt text-red">Down</span>;
|
||||
case "pending":
|
||||
return <span className="badge bg-yellow-lt text-yellow">Pending</span>;
|
||||
case "maintenance":
|
||||
return <span className="badge bg-blue-lt text-blue">Maintenance</span>;
|
||||
default:
|
||||
return <span className="badge bg-secondary-lt text-secondary">Unknown</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPercent(v: number | null): string {
|
||||
return v === null ? "—" : `${v}%`;
|
||||
}
|
||||
|
||||
function formatMs(v: number | null): string {
|
||||
if (v === null || v < 0) return "—";
|
||||
return `${Math.round(v)} ms`;
|
||||
}
|
||||
|
||||
export default function UptimeKuma({ user: _user }: { user: CurrentUser }) {
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [data, setData] = useState<UptimeKumaMonitorsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.integrations
|
||||
.list()
|
||||
.then((res) => {
|
||||
const kumaIntegrations = res.integrations.filter((i) => i.type === "uptimekuma");
|
||||
setIntegrations(kumaIntegrations);
|
||||
if (!selectedId && kumaIntegrations.length > 0) setSelectedId(kumaIntegrations[0].id);
|
||||
})
|
||||
.catch((err) => setError(readableError(err)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
function loadMonitors(id: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.integrations.uptimekuma
|
||||
.monitors(id)
|
||||
.then(setData)
|
||||
.catch((err) => setError(readableError(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.enabled) {
|
||||
loadMonitors(selected.id);
|
||||
} else {
|
||||
setData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.monitors, "name");
|
||||
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
|
||||
|
||||
function exportCsv() {
|
||||
if (!sorted) return;
|
||||
downloadCsv(
|
||||
"uptime-kuma-monitors.csv",
|
||||
["Monitor", "Type", "Target", "Status", "Response (ms)", "Uptime 24h", "Uptime 30d", "Cert days left", "Server"],
|
||||
sorted.map((m) => [
|
||||
m.name,
|
||||
m.type,
|
||||
m.target ? `${m.target}${m.port ? `:${m.port}` : ""}` : "",
|
||||
m.status,
|
||||
m.responseTimeMs !== null && m.responseTimeMs >= 0 ? Math.round(m.responseTimeMs) : "",
|
||||
m.uptime24h ?? "",
|
||||
m.uptime30d ?? "",
|
||||
m.certDaysRemaining ?? "",
|
||||
m.matchedServer?.name ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Uptime Kuma</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{integrations?.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
No Uptime Kuma integration configured yet. <Link to="/integrations">Add one under Integrations</Link>.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{integrations && integrations.length > 1 && (
|
||||
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||
<select className="form-select" value={selectedId ?? ""} onChange={(e) => setSelectedId(Number(e.target.value))}>
|
||||
{integrations.map((i) => (
|
||||
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||
{i.name}
|
||||
{!i.enabled ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && !selected.enabled ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its monitors.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
Monitors
|
||||
{data && data.summary.down > 0 && <span className="text-red fw-normal ms-2">{data.summary.down} down</span>}
|
||||
{data && data.summary.down === 0 && data.summary.pending > 0 && (
|
||||
<span className="text-yellow fw-normal ms-2">{data.summary.pending} pending</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
|
||||
Export CSV
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => selected && loadMonitors(selected.id)} disabled={loading}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableTh<UptimeKumaMonitor> label="Monitor" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<UptimeKumaMonitor> label="Target" sortKeyName="target" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<th>Server</th>
|
||||
<SortableTh<UptimeKumaMonitor> label="Status" sortKeyName="status" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<UptimeKumaMonitor>
|
||||
label="Response"
|
||||
sortKeyName="responseTimeMs"
|
||||
activeKey={sortKey}
|
||||
direction={sortDir}
|
||||
onSort={requestSort}
|
||||
/>
|
||||
<SortableTh<UptimeKumaMonitor> label="Uptime 24h" sortKeyName="uptime24h" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
<SortableTh<UptimeKumaMonitor> label="Uptime 30d" sortKeyName="uptime30d" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems?.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
{m.name}
|
||||
<div className="text-secondary small">{m.type}</div>
|
||||
</td>
|
||||
<td className="text-secondary">
|
||||
{m.target ? `${m.target}${m.port ? `:${m.port}` : ""}` : "—"}
|
||||
{m.certDaysRemaining !== null && (
|
||||
<div className={`small ${m.certDaysRemaining <= 14 ? "text-red" : "text-secondary"}`}>
|
||||
cert: {m.certDaysRemaining}d left
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{m.matchedServer ? (
|
||||
<Link to={`/servers/${m.matchedServer.id}`}>{m.matchedServer.name}</Link>
|
||||
) : (
|
||||
<span className="text-secondary">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{statusBadge(m)}</td>
|
||||
<td className="text-secondary">{formatMs(m.responseTimeMs)}</td>
|
||||
<td className="text-secondary">{formatPercent(m.uptime24h)}</td>
|
||||
<td className="text-secondary">{formatPercent(m.uptime30d)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-secondary text-center">
|
||||
No monitors reported by this Uptime Kuma instance.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
|
||||
<div className="card-footer text-secondary small">
|
||||
"Server" links a monitor to one of your servers when its target (an IP, or a TCP/HTTP hostname) matches a
|
||||
server's own address or hostname — monitors of other kinds (groups, pushes, keyword/DNS checks without a
|
||||
plain host) are left unmatched rather than guessed at.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
};
|
||||
|
||||
function ColorList({
|
||||
|
||||
Reference in new issue
Block a user