Add developer API, Prometheus metrics, and community links
This commit is contained in:
271
components/PrometheusIntegration.tsx
Normal file
271
components/PrometheusIntegration.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user