272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { Activity, Copy, Download } from "lucide-react";
|
|
import { api } from "@/lib/client-api";
|
|
import { prometheusConfig, type PrometheusStatus } from "@/shared/prometheus";
|
|
import { grafanaDashboard } from "@/shared/grafana-dashboard";
|
|
import { useI18n } from "./I18nProvider";
|
|
|
|
export function PrometheusIntegration() {
|
|
const { t, locale } = useI18n();
|
|
const [status, setStatus] = useState<PrometheusStatus | null>(null);
|
|
const [token, setToken] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [message, setMessage] = useState("");
|
|
const [confirm, setConfirm] = useState<"rotate" | "disable" | null>(null);
|
|
async function load() {
|
|
try {
|
|
setStatus(await api<PrometheusStatus>("moderation/prometheus"));
|
|
setError("");
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
void load();
|
|
}, []);
|
|
async function configure(action: "rotate" | "disable") {
|
|
setBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
const result = await api<PrometheusStatus & { token: string | null }>(
|
|
"moderation/prometheus",
|
|
{ action },
|
|
);
|
|
const { token: nextToken, ...nextStatus } = result;
|
|
setStatus(nextStatus);
|
|
setToken(nextToken);
|
|
setConfirm(null);
|
|
setMessage(
|
|
action === "rotate"
|
|
? "New metrics token generated. Copy the config now; it cannot be recovered later."
|
|
: "Metrics access disabled. Existing scrape credentials no longer work.",
|
|
);
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
async function copy(value: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
setMessage("Copied to clipboard.");
|
|
setError("");
|
|
} catch {
|
|
setError("Could not copy. Select the text and copy it manually.");
|
|
}
|
|
}
|
|
function dashboard() {
|
|
const url = URL.createObjectURL(
|
|
new Blob([JSON.stringify(grafanaDashboard(), null, 2)], {
|
|
type: "application/json",
|
|
}),
|
|
);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = "minegeo-grafana-dashboard.json";
|
|
link.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}
|
|
const config =
|
|
status && token && status.endpoint.startsWith("https:")
|
|
? prometheusConfig(status.endpoint, token)
|
|
: "";
|
|
return (
|
|
<section className="tool-card" aria-labelledby="prometheus-title">
|
|
<div className="settings-heading">
|
|
<Activity size={24} aria-hidden="true" />
|
|
<h2 id="prometheus-title">{t("Prometheus & Grafana")}</h2>
|
|
</div>
|
|
<p>
|
|
{t(
|
|
"Track games, maps, multiplayer, uploads, and reports in your own Grafana dashboard. Admin permission is required to manage access.",
|
|
)}
|
|
</p>
|
|
<p className="muted">
|
|
{t(
|
|
"Only aggregate counts are exported. Usernames, messages, emails, and map identifiers are excluded.",
|
|
)}
|
|
</p>
|
|
{!status && !error && <p role="status">{t("Loading metrics setup…")}</p>}
|
|
{status && (
|
|
<>
|
|
<p>
|
|
<span className="badge">
|
|
{status.enabled ? t("Metrics enabled") : t("Metrics disabled")}
|
|
</span>
|
|
</p>
|
|
<label className="field">
|
|
{t("Metrics endpoint")}
|
|
<input readOnly value={status.endpoint} />
|
|
</label>
|
|
<button className="button" onClick={() => void copy(status.endpoint)}>
|
|
<Copy size={16} aria-hidden="true" />
|
|
{t("Copy endpoint")}
|
|
</button>
|
|
<p className="muted">
|
|
{t("Last successful scrape: {time}", {
|
|
time: status.lastScrapedAt
|
|
? new Date(status.lastScrapedAt).toLocaleString(
|
|
locale === "ru" ? "ru-RU" : "en-GB",
|
|
)
|
|
: t("Not scraped yet"),
|
|
})}
|
|
</p>
|
|
{!status.endpoint.startsWith("https:") && (
|
|
<p className="error-message">
|
|
{t(
|
|
"Use the hosted HTTPS site to generate a production scrape config.",
|
|
)}
|
|
</p>
|
|
)}
|
|
<ol className="metrics-steps">
|
|
<li>
|
|
{t(
|
|
"Generate a token below, then copy the config into prometheus.yml. If scrape_configs already exists, add only the MineGeo job beneath it.",
|
|
)}
|
|
</li>
|
|
<li>
|
|
{t(
|
|
"Reload or restart Prometheus. Open its Targets page and check that the minegeo job is UP.",
|
|
)}
|
|
</li>
|
|
<li>
|
|
{t(
|
|
"In Grafana, add your Prometheus server as a data source. Import the downloaded dashboard JSON and select that data source.",
|
|
)}
|
|
</li>
|
|
</ol>
|
|
<p className="muted">
|
|
{t(
|
|
"History starts with the first scrape. Counts are cached for 30 seconds; room states are not online presence. This integration does not export page views or Vercel CPU statistics.",
|
|
)}
|
|
</p>
|
|
<div className="inline-actions">
|
|
<button
|
|
className="button primary"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
status.enabled ? setConfirm("rotate") : void configure("rotate")
|
|
}
|
|
>
|
|
{status.enabled
|
|
? t("Rotate metrics token")
|
|
: t("Generate metrics token")}
|
|
</button>
|
|
{status.enabled && (
|
|
<button
|
|
className="button"
|
|
disabled={busy}
|
|
onClick={() => setConfirm("disable")}
|
|
>
|
|
{t("Disable metrics access")}
|
|
</button>
|
|
)}
|
|
<button className="button" onClick={dashboard}>
|
|
<Download size={16} aria-hidden="true" />
|
|
{t("Download Grafana dashboard")}
|
|
</button>
|
|
<button
|
|
className="text-link"
|
|
disabled={busy}
|
|
onClick={() => void load()}
|
|
>
|
|
{t("Refresh status")}
|
|
</button>
|
|
</div>
|
|
{confirm && (
|
|
<div
|
|
className="token-box"
|
|
role="group"
|
|
aria-label={t("Confirm metrics access change")}
|
|
>
|
|
<p>
|
|
{confirm === "rotate"
|
|
? t(
|
|
"Rotating immediately invalidates the old token. Update Prometheus with the new config afterward.",
|
|
)
|
|
: t(
|
|
"Disabling immediately stops all authenticated scrapes. Prometheus will report this target as DOWN.",
|
|
)}
|
|
</p>
|
|
<div className="inline-actions">
|
|
<button
|
|
className="button"
|
|
disabled={busy}
|
|
onClick={() => void configure(confirm)}
|
|
>
|
|
{t("Confirm change")}
|
|
</button>
|
|
<button
|
|
className="button"
|
|
disabled={busy}
|
|
onClick={() => setConfirm(null)}
|
|
>
|
|
{t("Cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{token && (
|
|
<div className="token-box">
|
|
<p>
|
|
{t(
|
|
"Keep this config private. The token is shown once and grants read-only access to aggregate metrics. Rotating or disabling it revokes access.",
|
|
)}
|
|
</p>
|
|
{config ? (
|
|
<>
|
|
<label className="field">
|
|
{t("Prometheus scrape config")}
|
|
<textarea
|
|
className="metrics-config"
|
|
readOnly
|
|
spellCheck={false}
|
|
value={config}
|
|
rows={15}
|
|
/>
|
|
</label>
|
|
<button className="button" onClick={() => void copy(config)}>
|
|
<Copy size={16} aria-hidden="true" />
|
|
{t("Copy Prometheus config")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<label className="field">
|
|
{t("Metrics token")}
|
|
<textarea readOnly value={token} />
|
|
</label>
|
|
)}
|
|
<button
|
|
className="text-link"
|
|
onClick={() => {
|
|
setToken(null);
|
|
setMessage("");
|
|
}}
|
|
>
|
|
{t("Hide generated credentials")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{status.enabled && !token && (
|
|
<p className="muted">
|
|
{t(
|
|
"The existing token cannot be retrieved. Use your saved config, or rotate the token to generate a new one.",
|
|
)}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
{error && (
|
|
<p className="error-message" role="alert">
|
|
{t(error)}
|
|
</p>
|
|
)}
|
|
{message && <p role="status">{t(message)}</p>}
|
|
</section>
|
|
);
|
|
}
|