Add developer API, Prometheus metrics, and community links
This commit is contained in:
@@ -702,6 +702,35 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
developer_keys_list: { Args: { p_owner: string }; Returns: Json };
|
||||
developer_key_create: {
|
||||
Args: {
|
||||
p_owner: string;
|
||||
p_name: string;
|
||||
p_scopes: string[];
|
||||
p_token_hash: string;
|
||||
p_prefix: string;
|
||||
p_expires_days: number;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
developer_key_revoke: {
|
||||
Args: { p_owner: string; p_key: string };
|
||||
Returns: boolean;
|
||||
};
|
||||
developer_key_consume: { Args: { p_token_hash: string }; Returns: Json };
|
||||
prometheus_status: {
|
||||
Args: { p_reviewer: string };
|
||||
Returns: Json;
|
||||
};
|
||||
configure_prometheus: {
|
||||
Args: { p_reviewer: string; p_token_hash: string | null };
|
||||
Returns: Json;
|
||||
};
|
||||
read_prometheus_metrics: {
|
||||
Args: { p_token_hash: string };
|
||||
Returns: Json;
|
||||
};
|
||||
receive_bug_close: {
|
||||
Args: { p_report: string; p_external: string };
|
||||
Returns: boolean;
|
||||
|
||||
71
shared/developer-api.ts
Normal file
71
shared/developer-api.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const developerScopes = [
|
||||
"maps:read",
|
||||
"account:read",
|
||||
"account:maps:read",
|
||||
"account:games:read",
|
||||
] as const;
|
||||
export type DeveloperScope = (typeof developerScopes)[number];
|
||||
export type DeveloperKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: DeveloperScope[];
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
export const developerKeyInput = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(60),
|
||||
scopes: z
|
||||
.array(z.enum(developerScopes))
|
||||
.min(1)
|
||||
.max(4)
|
||||
.refine(
|
||||
(values) => new Set(values).size === values.length,
|
||||
"Duplicate permissions",
|
||||
),
|
||||
expiresDays: z.number().int().min(1).max(365).default(90),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export function developerPagination(url: URL, search = false) {
|
||||
const allowed = new Set(
|
||||
search ? ["limit", "offset", "q"] : ["limit", "offset"],
|
||||
);
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1)
|
||||
throw new Error("Unsupported or duplicate query parameter");
|
||||
}
|
||||
const integer = (key: string, fallback: number, min: number, max: number) => {
|
||||
const raw = url.searchParams.get(key);
|
||||
if (raw === null) return fallback;
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`Invalid ${key}`);
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < min || value > max)
|
||||
throw new Error(`Invalid ${key}`);
|
||||
return value;
|
||||
};
|
||||
const q = (url.searchParams.get("q") ?? "").trim();
|
||||
if (q.length > 80) throw new Error("Search must be 80 characters or fewer");
|
||||
return {
|
||||
limit: integer("limit", 20, 1, 50),
|
||||
offset: integer("offset", 0, 0, 10000),
|
||||
q,
|
||||
};
|
||||
}
|
||||
export type DeveloperPagination = ReturnType<typeof developerPagination>;
|
||||
export function developerPage(total: number, page: DeveloperPagination) {
|
||||
return {
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
total,
|
||||
nextOffset:
|
||||
page.offset + page.limit < total && page.offset + page.limit <= 10000
|
||||
? page.offset + page.limit
|
||||
: null,
|
||||
};
|
||||
}
|
||||
86
shared/developer-docs.ts
Normal file
86
shared/developer-docs.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const developerScopes = [
|
||||
{
|
||||
id: "maps:read",
|
||||
name: ["Public maps", "Общие карты"],
|
||||
description: [
|
||||
"Read the published map catalog and map details.",
|
||||
"Читать каталог опубликованных карт и сведения о картах.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account:read",
|
||||
name: ["My profile", "Мой профиль"],
|
||||
description: [
|
||||
"Read your account profile and username appearance.",
|
||||
"Читать профиль вашего аккаунта и оформление имени.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account:maps:read",
|
||||
name: ["My maps, including private maps", "Мои карты, включая личные"],
|
||||
description: [
|
||||
"Read your own map records, including unpublished maps.",
|
||||
"Читать ваши карты, в том числе неопубликованные.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account:games:read",
|
||||
name: ["My game history", "Моя история игр"],
|
||||
description: ["Read your game history.", "Читать вашу историю игр."],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const developerEndpoints = [
|
||||
{
|
||||
path: "/maps",
|
||||
scope: "maps:read",
|
||||
description: [
|
||||
"Published maps. Supports q, limit and offset.",
|
||||
"Опубликованные карты. Поддерживает q, limit и offset.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/maps/{id}",
|
||||
scope: "maps:read",
|
||||
description: [
|
||||
"Details of one published map.",
|
||||
"Сведения об одной опубликованной карте.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/me",
|
||||
scope: "account:read",
|
||||
description: [
|
||||
"The key owner's profile and username appearance.",
|
||||
"Профиль и оформление имени владельца ключа.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/me/maps",
|
||||
scope: "account:maps:read",
|
||||
description: [
|
||||
"Your own maps, including private maps. Supports limit and offset.",
|
||||
"Ваши карты, включая личные. Поддерживает limit и offset.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/me/games",
|
||||
scope: "account:games:read",
|
||||
description: [
|
||||
"Your game history. Supports limit and offset.",
|
||||
"Ваша история игр. Поддерживает limit и offset.",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const developerPythonExample = `import os
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
"https://minegeo.vercel.app/api/dev/v1/maps",
|
||||
headers={"Authorization": f"Bearer {os.environ['MINEGEO_API_KEY']}"},
|
||||
params={"limit": 20, "offset": 0},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(response.json())`;
|
||||
208
shared/developer-openapi.ts
Normal file
208
shared/developer-openapi.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
const string = { type: "string" };
|
||||
const date = { type: "string", format: "date-time" };
|
||||
const id = { type: "string", format: "uuid" };
|
||||
const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` });
|
||||
const pagination = [
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 1, maximum: 50, default: 20 },
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 0, maximum: 10000, default: 0 },
|
||||
},
|
||||
];
|
||||
const success = (schema: unknown) => ({
|
||||
description: "Success",
|
||||
content: { "application/json": { schema } },
|
||||
});
|
||||
const errors = Object.fromEntries(
|
||||
[
|
||||
[400, "Invalid query or map ID"],
|
||||
[401, "Missing, invalid, expired or revoked key"],
|
||||
[403, "Required permission missing"],
|
||||
[404, "Resource or endpoint not found"],
|
||||
[429, "Quota exceeded; wait Retry-After seconds"],
|
||||
[503, "Temporarily unavailable"],
|
||||
].map(([status, description]) => [
|
||||
status,
|
||||
{ description, content: { "application/json": { schema: ref("Error") } } },
|
||||
]),
|
||||
);
|
||||
const list = (name: string, item: string) => ({
|
||||
type: "object",
|
||||
required: [name, "pagination"],
|
||||
properties: {
|
||||
[name]: { type: "array", items: ref(item) },
|
||||
pagination: ref("Pagination"),
|
||||
},
|
||||
});
|
||||
const operation = (
|
||||
summary: string,
|
||||
scope: string,
|
||||
schema: unknown,
|
||||
parameters: unknown[] = [],
|
||||
) => ({
|
||||
summary,
|
||||
description: `Requires the ${scope} key permission.`,
|
||||
"x-required-scope": scope,
|
||||
security: [{ DeveloperKey: [] }],
|
||||
parameters,
|
||||
responses: { 200: success(schema), ...errors },
|
||||
});
|
||||
export const developerOpenApi = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "MineGeo Developer API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Read-only HTTPS API. Bearer keys are managed at /dev/api. 60 requests per key per fixed minute; 180 requests per account per minute shared across keys. All authenticated requests, including validation/permission errors, consume quota. A 429 response includes Retry-After; successful authorized responses include RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until reset). X-RateLimit-Reset is a Unix timestamp. Requests with invalid keys cannot access account data. No upload, publication, gameplay writes or moderator access is granted.",
|
||||
},
|
||||
servers: [{ url: "/api/dev/v1" }],
|
||||
paths: {
|
||||
"/maps": {
|
||||
get: operation("List published maps", "maps:read", list("maps", "Map"), [
|
||||
...pagination,
|
||||
{
|
||||
name: "q",
|
||||
in: "query",
|
||||
schema: { type: "string", maxLength: 80 },
|
||||
description: "Case-insensitive map-name search",
|
||||
},
|
||||
]),
|
||||
},
|
||||
"/maps/{id}": {
|
||||
get: operation(
|
||||
"Get a published map",
|
||||
"maps:read",
|
||||
{
|
||||
type: "object",
|
||||
required: ["map"],
|
||||
properties: { map: ref("Map") },
|
||||
},
|
||||
[{ name: "id", in: "path", required: true, schema: id }],
|
||||
),
|
||||
},
|
||||
"/me": {
|
||||
get: operation("Get the key owner's profile", "account:read", {
|
||||
type: "object",
|
||||
required: ["account"],
|
||||
properties: { account: ref("Account") },
|
||||
}),
|
||||
},
|
||||
"/me/maps": {
|
||||
get: operation(
|
||||
"List the owner's maps, including private maps",
|
||||
"account:maps:read",
|
||||
list("maps", "Map"),
|
||||
[
|
||||
...pagination,
|
||||
{ name: "q", in: "query", schema: { type: "string", maxLength: 80 } },
|
||||
],
|
||||
),
|
||||
},
|
||||
"/me/games": {
|
||||
get: operation(
|
||||
"List the owner's solo-game history",
|
||||
"account:games:read",
|
||||
list("games", "Game"),
|
||||
pagination,
|
||||
),
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
DeveloperKey: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "mg_dev_<64 hex characters>",
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
required: ["error"],
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
required: ["code", "message"],
|
||||
properties: { code: string, message: string },
|
||||
},
|
||||
},
|
||||
},
|
||||
Pagination: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "integer" },
|
||||
offset: { type: "integer" },
|
||||
total: { type: "integer" },
|
||||
nextOffset: { type: ["integer", "null"] },
|
||||
},
|
||||
},
|
||||
Map: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id,
|
||||
name: string,
|
||||
description: string,
|
||||
source: string,
|
||||
state: string,
|
||||
bounds: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minX: { type: "number" },
|
||||
minZ: { type: "number" },
|
||||
maxX: { type: "number" },
|
||||
maxZ: { type: "number" },
|
||||
},
|
||||
},
|
||||
panoramaCount: { type: "integer" },
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
url: {
|
||||
type: "string",
|
||||
description:
|
||||
"Relative MineGeo map page path; private maps still require their owner's browser login.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Account: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id,
|
||||
displayName: string,
|
||||
createdAt: date,
|
||||
appearance: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nameGlow: { type: "boolean" },
|
||||
glowColor: string,
|
||||
bugFinder: { type: "boolean" },
|
||||
customPrefix: { type: ["string", "null"] },
|
||||
customPrefixIcon: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Game: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id,
|
||||
difficulty: { type: "string", enum: ["easy", "normal", "hard"] },
|
||||
createdAt: date,
|
||||
currentRound: { type: "integer", minimum: 0, maximum: 5 },
|
||||
complete: { type: "boolean" },
|
||||
score: {
|
||||
type: ["integer", "null"],
|
||||
minimum: 0,
|
||||
maximum: 25000,
|
||||
description:
|
||||
"Final score only; null until a complete five-round result is available.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
3
shared/forum.json
Normal file
3
shared/forum.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"url": "https://minegeo.discourse.group/"
|
||||
}
|
||||
236
shared/grafana-dashboard.ts
Normal file
236
shared/grafana-dashboard.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Grafana's classic import model prompts for the user's Prometheus data source.
|
||||
// This template contains query definitions only, never credentials or player data.
|
||||
const datasource = { type: "prometheus", uid: "${DS_PROMETHEUS}" };
|
||||
const filter = 'job="minegeo",instance=~"$instance"';
|
||||
const panels = [
|
||||
[
|
||||
"Scrape health",
|
||||
"stat",
|
||||
`min(up{${filter}})`,
|
||||
"short",
|
||||
"Shows whether Prometheus can scrape MineGeo. 1 = healthy, 0 = failed.",
|
||||
],
|
||||
[
|
||||
"Registered accounts",
|
||||
"stat",
|
||||
`sum(minegeo_accounts{${filter}})`,
|
||||
"short",
|
||||
"Currently retained profiles.",
|
||||
],
|
||||
[
|
||||
"Published maps",
|
||||
"stat",
|
||||
`sum(minegeo_maps{${filter},state="published"})`,
|
||||
"short",
|
||||
"Maps available in the public catalog.",
|
||||
],
|
||||
[
|
||||
"Published panoramas",
|
||||
"stat",
|
||||
`sum(minegeo_published_panoramas{${filter}})`,
|
||||
"short",
|
||||
"Playable panorama locations in published maps.",
|
||||
],
|
||||
[
|
||||
"Solo games started · last 24h",
|
||||
"stat",
|
||||
`sum(minegeo_games_started_24h{${filter}})`,
|
||||
"short",
|
||||
"Rolling 24-hour window, not a counter or page-view total.",
|
||||
],
|
||||
[
|
||||
"Parties created · last 24h",
|
||||
"stat",
|
||||
`sum(minegeo_rooms_created_24h{${filter}})`,
|
||||
"short",
|
||||
"Rolling 24-hour count of created multiplayer rooms.",
|
||||
],
|
||||
[
|
||||
"Map catalog over time",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_maps{${filter}})`,
|
||||
"short",
|
||||
"Current retained map inventory. Counts can decrease after deletion.",
|
||||
],
|
||||
[
|
||||
"Solo games by difficulty and completion",
|
||||
"timeseries",
|
||||
`sum by (difficulty,status) (minegeo_games{${filter}})`,
|
||||
"short",
|
||||
"Incomplete includes abandoned games. Complete means all five rounds ended.",
|
||||
],
|
||||
[
|
||||
"Multiplayer room lifecycle",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_rooms{${filter}})`,
|
||||
"short",
|
||||
"Stored room lifecycle states. These are not a count of online users.",
|
||||
],
|
||||
[
|
||||
"Upload processing",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_upload_jobs{${filter}})`,
|
||||
"short",
|
||||
"Queued, running, completed, failed and cancelled jobs.",
|
||||
],
|
||||
[
|
||||
"Player bug reports",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_bug_reports{${filter}})`,
|
||||
"short",
|
||||
"Retained reports. Closed reports are deleted after three days, so counts may fall.",
|
||||
],
|
||||
[
|
||||
"Content moderation reports",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_content_reports{${filter}})`,
|
||||
"short",
|
||||
"Open and resolved map-content reports.",
|
||||
],
|
||||
[
|
||||
"Discord delivery",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_discord_announcements{${filter}})`,
|
||||
"short",
|
||||
"Retained announcement delivery states.",
|
||||
],
|
||||
[
|
||||
"Exporter read time",
|
||||
"timeseries",
|
||||
`max(minegeo_exporter_read_duration_seconds{${filter}})`,
|
||||
"s",
|
||||
"Time spent reading and validating a database snapshot; not site request latency.",
|
||||
],
|
||||
[
|
||||
"Snapshot age",
|
||||
"timeseries",
|
||||
`time() - min(minegeo_snapshot_timestamp_seconds{${filter}})`,
|
||||
"s",
|
||||
"Snapshot age grows when scraping stops. The database caches aggregates for 30 seconds.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
export function grafanaDashboard() {
|
||||
return {
|
||||
__inputs: [
|
||||
{
|
||||
name: "DS_PROMETHEUS",
|
||||
label: "Prometheus",
|
||||
description: "Choose the Prometheus data source that scrapes MineGeo.",
|
||||
type: "datasource",
|
||||
pluginId: "prometheus",
|
||||
pluginName: "Prometheus",
|
||||
},
|
||||
],
|
||||
id: null,
|
||||
uid: "minegeo-community",
|
||||
title: "MineGeo · Community analytics",
|
||||
version: 1,
|
||||
schemaVersion: 39,
|
||||
tags: ["minegeo", "prometheus"],
|
||||
timezone: "browser",
|
||||
editable: true,
|
||||
graphTooltip: 1,
|
||||
refresh: "1m",
|
||||
time: { from: "now-24h", to: "now" },
|
||||
description:
|
||||
"Aggregate MineGeo product metrics. All MineGeo series are gauges; no personal data or per-process counters. History starts when Prometheus begins scraping.",
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
name: "instance",
|
||||
label: "MineGeo instance",
|
||||
type: "query",
|
||||
datasource,
|
||||
query: {
|
||||
query: 'label_values(minegeo_accounts{job="minegeo"}, instance)',
|
||||
refId: "instance",
|
||||
},
|
||||
refresh: 1,
|
||||
multi: false,
|
||||
includeAll: false,
|
||||
sort: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
panels: panels.map(([title, type, expr, unit, description], index) => {
|
||||
const stat = index < 6;
|
||||
return {
|
||||
id: index + 1,
|
||||
title,
|
||||
type,
|
||||
description,
|
||||
datasource,
|
||||
gridPos: stat
|
||||
? { x: (index % 3) * 8, y: Math.floor(index / 3) * 4, w: 8, h: 4 }
|
||||
: {
|
||||
x: ((index - 6) % 2) * 12,
|
||||
y: 8 + Math.floor((index - 6) / 2) * 8,
|
||||
w: 12,
|
||||
h: 8,
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
refId: "A",
|
||||
expr,
|
||||
datasource,
|
||||
legendFormat: "{{state}} {{difficulty}} {{status}}",
|
||||
range: !stat,
|
||||
instant: stat,
|
||||
},
|
||||
],
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
unit,
|
||||
min: 0,
|
||||
decimals: unit === "short" ? 0 : 2,
|
||||
color: { mode: stat ? "thresholds" : "palette-classic" },
|
||||
thresholds: {
|
||||
mode: "absolute",
|
||||
steps:
|
||||
index === 0
|
||||
? [
|
||||
{ color: "red", value: null },
|
||||
{ color: "green", value: 1 },
|
||||
]
|
||||
: [{ color: "green", value: null }],
|
||||
},
|
||||
...(stat
|
||||
? {}
|
||||
: {
|
||||
custom: {
|
||||
drawStyle: "line",
|
||||
lineInterpolation: "smooth",
|
||||
lineWidth: 2,
|
||||
fillOpacity: 12,
|
||||
showPoints: "never",
|
||||
spanNulls: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
options: stat
|
||||
? {
|
||||
colorMode: "value",
|
||||
graphMode: "area",
|
||||
justifyMode: "auto",
|
||||
textMode: "auto",
|
||||
reduceOptions: {
|
||||
calcs: ["lastNotNull"],
|
||||
fields: "",
|
||||
values: false,
|
||||
},
|
||||
}
|
||||
: {
|
||||
legend: {
|
||||
displayMode: "list",
|
||||
placement: "bottom",
|
||||
showLegend: true,
|
||||
},
|
||||
tooltip: { mode: "multi", sort: "desc" },
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Forum": "Форум",
|
||||
"Site language": "Язык сайта",
|
||||
"Automatic": "Автоматически",
|
||||
"MineGeo home": "Главная MineGeo",
|
||||
@@ -849,5 +850,39 @@
|
||||
"Download the portable app, run the EXE, and start exploring. No installation needed.": "Скачайте портативное приложение, запустите EXE и начните исследование. Установка не нужна.",
|
||||
"Windows 10/11 · 64-bit · Version {version} · Internet required": "Windows 10/11 · 64 бита · Версия {version} · Нужен интернет",
|
||||
"The app connects to minegeo.vercel.app and keeps you signed in on this device.": "Приложение подключается к minegeo.vercel.app и сохраняет вход на этом устройстве.",
|
||||
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе."
|
||||
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе.",
|
||||
"Prometheus & Grafana": "Prometheus и Grafana",
|
||||
"Track games, maps, multiplayer, uploads, and reports in your own Grafana dashboard. Admin permission is required to manage access.": "Следите за играми, картами, мультиплеером, загрузками и обращениями на своей панели Grafana. Для управления доступом нужны права администратора.",
|
||||
"Only aggregate counts are exported. Usernames, messages, emails, and map identifiers are excluded.": "Экспортируются только общие показатели. Имена пользователей, сообщения, электронные адреса и идентификаторы карт не передаются.",
|
||||
"Loading metrics setup…": "Загружаем настройки метрик…",
|
||||
"Metrics enabled": "Метрики включены",
|
||||
"Metrics disabled": "Метрики отключены",
|
||||
"Metrics endpoint": "Адрес метрик",
|
||||
"Copy endpoint": "Копировать адрес",
|
||||
"Last successful scrape: {time}": "Последний успешный сбор: {time}",
|
||||
"Not scraped yet": "Сбор ещё не выполнялся",
|
||||
"Use the hosted HTTPS site to generate a production scrape config.": "Откройте опубликованный сайт по HTTPS, чтобы создать конфигурацию сбора метрик.",
|
||||
"Generate a token below, then copy the config into prometheus.yml. If scrape_configs already exists, add only the MineGeo job beneath it.": "Создайте токен ниже и скопируйте конфигурацию в prometheus.yml. Если раздел scrape_configs уже есть, добавьте в него только задание MineGeo.",
|
||||
"Reload or restart Prometheus. Open its Targets page and check that the minegeo job is UP.": "Перезагрузите конфигурацию или перезапустите Prometheus. На странице Targets проверьте, что задание minegeo имеет статус UP.",
|
||||
"In Grafana, add your Prometheus server as a data source. Import the downloaded dashboard JSON and select that data source.": "В Grafana добавьте свой сервер Prometheus как источник данных. Импортируйте скачанный JSON панели и выберите этот источник.",
|
||||
"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.": "История начинается с первого сбора. Показатели кешируются на 30 секунд; состояния комнат не показывают число игроков онлайн. Просмотры страниц и статистика процессора Vercel не экспортируются.",
|
||||
"Rotate metrics token": "Заменить токен метрик",
|
||||
"Generate metrics token": "Создать токен метрик",
|
||||
"Disable metrics access": "Отключить доступ к метрикам",
|
||||
"Download Grafana dashboard": "Скачать панель Grafana",
|
||||
"Confirm metrics access change": "Подтвердить изменение доступа к метрикам",
|
||||
"Rotating immediately invalidates the old token. Update Prometheus with the new config afterward.": "Замена сразу отключит старый токен. После этого обновите конфигурацию Prometheus.",
|
||||
"Disabling immediately stops all authenticated scrapes. Prometheus will report this target as DOWN.": "Отключение сразу остановит сбор метрик. Prometheus покажет для этой цели статус DOWN.",
|
||||
"Confirm change": "Подтвердить изменение",
|
||||
"Keep this config private. The token is shown once and grants read-only access to aggregate metrics. Rotating or disabling it revokes access.": "Храните конфигурацию в тайне. Токен показывается один раз и разрешает только чтение общих метрик. Замена или отключение токена отзывает доступ.",
|
||||
"Prometheus scrape config": "Конфигурация сбора Prometheus",
|
||||
"Copy Prometheus config": "Копировать конфигурацию Prometheus",
|
||||
"Metrics token": "Токен метрик",
|
||||
"Hide generated credentials": "Скрыть созданные данные доступа",
|
||||
"The existing token cannot be retrieved. Use your saved config, or rotate the token to generate a new one.": "Существующий токен нельзя получить повторно. Используйте сохранённую конфигурацию или замените токен.",
|
||||
"New metrics token generated. Copy the config now; it cannot be recovered later.": "Создан новый токен метрик. Скопируйте конфигурацию сейчас: восстановить токен позже нельзя.",
|
||||
"Metrics access disabled. Existing scrape credentials no longer work.": "Доступ к метрикам отключён. Прежние данные доступа больше не работают.",
|
||||
"Copied to clipboard.": "Скопировано в буфер обмена.",
|
||||
"Could not copy. Select the text and copy it manually.": "Не удалось скопировать. Выделите текст и скопируйте его вручную.",
|
||||
"Status": "Статус сервисов"
|
||||
}
|
||||
|
||||
152
shared/prometheus.ts
Normal file
152
shared/prometheus.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const prometheusAction = z
|
||||
.object({ action: z.enum(["rotate", "disable"]) })
|
||||
.strict();
|
||||
export type PrometheusStatus = {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
updatedAt: string | null;
|
||||
lastScrapedAt: string | null;
|
||||
};
|
||||
|
||||
export function prometheusConfig(endpoint: string, token: string) {
|
||||
const url = new URL(endpoint);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
)
|
||||
throw new Error("A public HTTPS metrics endpoint is required");
|
||||
if (!/^mg_metrics_[a-f0-9]{64}$/.test(token))
|
||||
throw new Error("Invalid metrics token");
|
||||
// JSON strings are also YAML quoted scalars; never interpolate raw host input.
|
||||
return `scrape_configs:
|
||||
- job_name: minegeo
|
||||
scrape_interval: 60s
|
||||
scrape_timeout: 20s
|
||||
scheme: https
|
||||
metrics_path: ${JSON.stringify(url.pathname)}
|
||||
follow_redirects: false
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: ${JSON.stringify(token)}
|
||||
static_configs:
|
||||
- targets: [${JSON.stringify(url.host)}]
|
||||
`;
|
||||
}
|
||||
|
||||
export const metricDefinitions = {
|
||||
minegeo_accounts: {
|
||||
help: "Registered MineGeo profiles currently retained.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_maps: {
|
||||
help: "Retained maps by publication state.",
|
||||
labels: {
|
||||
state: [
|
||||
"uploading",
|
||||
"processing",
|
||||
"draft",
|
||||
"review",
|
||||
"published",
|
||||
"rejected",
|
||||
"unpublished",
|
||||
"failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
minegeo_published_panoramas: {
|
||||
help: "Panoramas in currently published maps.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_games: {
|
||||
help: "Retained solo games by difficulty and completion state.",
|
||||
labels: {
|
||||
difficulty: ["easy", "normal", "hard"],
|
||||
status: ["incomplete", "complete"],
|
||||
},
|
||||
},
|
||||
minegeo_games_started_24h: {
|
||||
help: "Solo games created during the rolling last 24 hours.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_rooms: {
|
||||
help: "Retained multiplayer rooms by lifecycle state, not live online presence.",
|
||||
labels: {
|
||||
state: ["lobby", "loading", "playing", "results", "finished", "closed"],
|
||||
},
|
||||
},
|
||||
minegeo_rooms_created_24h: {
|
||||
help: "Multiplayer rooms created during the rolling last 24 hours.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_upload_jobs: {
|
||||
help: "Retained map upload jobs by processing state.",
|
||||
labels: { state: ["queued", "running", "complete", "failed", "cancelled"] },
|
||||
},
|
||||
minegeo_bug_reports: {
|
||||
help: "Retained player bug reports by support state.",
|
||||
labels: { state: ["draft", "pending", "open", "done"] },
|
||||
},
|
||||
minegeo_content_reports: {
|
||||
help: "Retained content reports by moderation state.",
|
||||
labels: { state: ["open", "resolved", "confirmed", "dismissed"] },
|
||||
},
|
||||
minegeo_discord_announcements: {
|
||||
help: "Retained Discord announcements by delivery state.",
|
||||
labels: { state: ["pending", "sending", "sent", "skipped", "failed"] },
|
||||
},
|
||||
minegeo_snapshot_timestamp_seconds: {
|
||||
help: "Unix timestamp when the database snapshot was collected.",
|
||||
labels: {},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const sampleSchema = z
|
||||
.object({
|
||||
metric: z.string(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
value: z.number().finite().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Reject unknown names/labels rather than exporting user-provided dimensions. */
|
||||
export function formatMetrics(input: unknown) {
|
||||
const samples = z.array(sampleSchema).max(100).parse(input);
|
||||
const lines: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const declared = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
if (!Object.hasOwn(metricDefinitions, sample.metric))
|
||||
throw new Error("Unknown metric");
|
||||
const definition =
|
||||
metricDefinitions[sample.metric as keyof typeof metricDefinitions];
|
||||
const allowed: Record<string, readonly string[]> = definition.labels;
|
||||
const keys = Object.keys(sample.labels).sort();
|
||||
if (
|
||||
keys.length !== Object.keys(allowed).length ||
|
||||
keys.some((k) => !allowed[k]?.includes(sample.labels[k]))
|
||||
)
|
||||
throw new Error("Invalid metric labels");
|
||||
const labels = keys
|
||||
.map((k) => `${k}=${JSON.stringify(sample.labels[k])}`)
|
||||
.join(",");
|
||||
const name = sample.metric + (labels ? `{${labels}}` : "");
|
||||
if (seen.has(name)) throw new Error("Duplicate metric sample");
|
||||
seen.add(name);
|
||||
if (!declared.has(sample.metric)) {
|
||||
lines.push(
|
||||
`# HELP ${sample.metric} ${definition.help}`,
|
||||
`# TYPE ${sample.metric} gauge`,
|
||||
);
|
||||
declared.add(sample.metric);
|
||||
}
|
||||
lines.push(`${name} ${sample.value}`);
|
||||
}
|
||||
if (declared.size !== Object.keys(metricDefinitions).length)
|
||||
throw new Error("Incomplete metrics snapshot");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
Reference in New Issue
Block a user