api/src/routes/ring.tsblame
View source
3cbdca61import type { Dirent } from "node:fs";
3cbdca62import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
3cbdca63import { dirname, resolve } from "node:path";
3cbdca64import type { FastifyInstance } from "fastify";
3cbdca65import { z } from "zod";
3cbdca66
3cbdca67interface RingLogEntry {
3cbdca68 ts: string;
3cbdca69 source: string;
3cbdca610 level: string;
3cbdca611 message: string;
3cbdca612 payload: unknown;
3cbdca613 owner?: string;
3cbdca614 repo?: string;
3cbdca615}
3cbdca616
3cbdca617interface RingInstanceSummary {
3cbdca618 owner: string;
3cbdca619 repo: string;
3cbdca620 total: number;
3cbdca621 last_ts: string | null;
3cbdca622 last_level: string | null;
3cbdca623 last_message: string | null;
3cbdca624}
3cbdca625
3cbdca626const querySchema = z.object({
3cbdca627 limit: z.coerce.number().int().min(1).max(1000).default(200),
3cbdca628});
3cbdca629
3cbdca630const repoParamsSchema = z.object({
3cbdca631 owner: z.string().min(1).max(120).regex(/^[A-Za-z0-9._-]+$/),
3cbdca632 repo: z.string().min(1).max(120).regex(/^[A-Za-z0-9._-]+$/),
3cbdca633});
3cbdca634
3cbdca635function toRecord(value: unknown): value is Record<string, unknown> {
3cbdca636 return typeof value === "object" && value !== null && !Array.isArray(value);
3cbdca637}
3cbdca638
3cbdca639function coerceHeaderString(value: string | string[] | undefined): string | null {
3cbdca640 if (typeof value === "string") return value.trim() || null;
3cbdca641 if (Array.isArray(value) && typeof value[0] === "string") {
3cbdca642 const first = value[0].trim();
3cbdca643 return first || null;
3cbdca644 }
3cbdca645 return null;
3cbdca646}
3cbdca647
3cbdca648function safeStringify(value: unknown): string {
3cbdca649 try {
3cbdca650 const serialized = JSON.stringify(value);
3cbdca651 return typeof serialized === "string" ? serialized : String(value);
3cbdca652 } catch {
3cbdca653 return String(value);
3cbdca654 }
3cbdca655}
3cbdca656
3cbdca657function normalizePayload(
3cbdca658 body: unknown,
3cbdca659 fallbackSource: string
3cbdca660): RingLogEntry {
3cbdca661 const now = new Date().toISOString();
3cbdca662
3cbdca663 if (typeof body === "string") {
3cbdca664 return {
3cbdca665 ts: now,
3cbdca666 source: fallbackSource,
3cbdca667 level: "info",
3cbdca668 message: body,
3cbdca669 payload: body,
3cbdca670 };
3cbdca671 }
3cbdca672
3cbdca673 if (typeof body === "number" || typeof body === "boolean" || body === null) {
3cbdca674 return {
3cbdca675 ts: now,
3cbdca676 source: fallbackSource,
3cbdca677 level: "info",
3cbdca678 message: String(body),
3cbdca679 payload: body,
3cbdca680 };
3cbdca681 }
3cbdca682
3cbdca683 if (Array.isArray(body)) {
3cbdca684 return {
3cbdca685 ts: now,
3cbdca686 source: fallbackSource,
3cbdca687 level: "info",
3cbdca688 message: `Array(${body.length})`,
3cbdca689 payload: body,
3cbdca690 };
3cbdca691 }
3cbdca692
3cbdca693 if (toRecord(body)) {
3cbdca694 const source =
3cbdca695 typeof body.source === "string" && body.source.trim()
3cbdca696 ? body.source.trim()
3cbdca697 : fallbackSource;
3cbdca698 const level =
3cbdca699 typeof body.level === "string" && body.level.trim()
3cbdca6100 ? body.level.trim()
3cbdca6101 : "info";
3cbdca6102 const message =
3cbdca6103 typeof body.message === "string" && body.message.trim()
3cbdca6104 ? body.message
3cbdca6105 : safeStringify(body);
3cbdca6106 return {
3cbdca6107 ts: now,
3cbdca6108 source,
3cbdca6109 level,
3cbdca6110 message,
3cbdca6111 payload: body,
3cbdca6112 };
3cbdca6113 }
3cbdca6114
3cbdca6115 return {
3cbdca6116 ts: now,
3cbdca6117 source: fallbackSource,
3cbdca6118 level: "info",
3cbdca6119 message: "Unsupported payload",
3cbdca6120 payload: body,
3cbdca6121 };
3cbdca6122}
3cbdca6123
3cbdca6124async function readLogFile(path: string): Promise<string> {
3cbdca6125 try {
3cbdca6126 return await readFile(path, "utf8");
3cbdca6127 } catch {
3cbdca6128 return "";
3cbdca6129 }
3cbdca6130}
3cbdca6131
3cbdca6132function parseLogLine(line: string): RingLogEntry {
3cbdca6133 try {
3cbdca6134 return JSON.parse(line) as RingLogEntry;
3cbdca6135 } catch {
3cbdca6136 return {
3cbdca6137 ts: new Date().toISOString(),
3cbdca6138 source: "ring",
3cbdca6139 level: "info",
3cbdca6140 message: line,
3cbdca6141 payload: line,
3cbdca6142 };
3cbdca6143 }
3cbdca6144}
3cbdca6145
3cbdca6146async function readEntries(path: string, limit: number): Promise<{ entries: RingLogEntry[]; total: number }> {
3cbdca6147 const content = await readLogFile(path);
3cbdca6148 const lines = content.split("\n").filter(Boolean);
3cbdca6149 const total = lines.length;
3cbdca6150 const entries = lines
3cbdca6151 .slice(Math.max(0, total - limit))
3cbdca6152 .map((line) => parseLogLine(line))
3cbdca6153 .reverse();
3cbdca6154 return { entries, total };
3cbdca6155}
3cbdca6156
3cbdca6157function sortInstancesDescByNewest(a: RingInstanceSummary, b: RingInstanceSummary): number {
3cbdca6158 const aTs = a.last_ts ? new Date(a.last_ts).getTime() : 0;
3cbdca6159 const bTs = b.last_ts ? new Date(b.last_ts).getTime() : 0;
3cbdca6160 if (aTs !== bTs) return bTs - aTs;
3cbdca6161 return b.total - a.total;
3cbdca6162}
3cbdca6163
3cbdca6164export async function ringRoutes(app: FastifyInstance) {
3cbdca6165 // Allow plain text ingestion as well as JSON.
3cbdca6166 app.addContentTypeParser(
3cbdca6167 "text/plain",
3cbdca6168 { parseAs: "string" },
3cbdca6169 (_request, body, done) => done(null, body)
3cbdca6170 );
3cbdca6171
3cbdca6172 const ringDataDir = resolve(process.env.RING_DATA_DIR ?? "./data/ring");
3cbdca6173 const globalLogPath = resolve(
3cbdca6174 process.env.RING_LOG_PATH ?? `${ringDataDir}/logs.ndjson`
3cbdca6175 );
3cbdca6176 const repoLogsRoot = resolve(
3cbdca6177 process.env.RING_REPO_LOG_ROOT ?? `${ringDataDir}/repos`
3cbdca6178 );
3cbdca6179
3cbdca6180 function repoLogPath(owner: string, repo: string): string {
3cbdca6181 return resolve(repoLogsRoot, owner, `${repo}.ndjson`);
3cbdca6182 }
3cbdca6183
3cbdca6184 // Backward-compatible global ingest.
3cbdca6185 app.post("/ring/logs", async (request, reply) => {
3cbdca6186 const source =
3cbdca6187 coerceHeaderString(request.headers["x-ring-source"]) ?? "ingest";
3cbdca6188 const entry = normalizePayload(request.body, source);
3cbdca6189 const serialized = `${JSON.stringify(entry)}\n`;
3cbdca6190
3cbdca6191 try {
3cbdca6192 await mkdir(dirname(globalLogPath), { recursive: true });
3cbdca6193 await appendFile(globalLogPath, serialized, "utf8");
3cbdca6194 return { ok: true, entry };
3cbdca6195 } catch (error) {
3cbdca6196 request.log.error({ error }, "Failed to append Ring log");
3cbdca6197 return reply.code(500).send({ error: "Failed to write log entry" });
3cbdca6198 }
3cbdca6199 });
3cbdca6200
3cbdca6201 // Backward-compatible global log view.
3cbdca6202 app.get<{ Querystring: { limit?: string } }>("/ring/logs", async (request) => {
3cbdca6203 const parsed = querySchema.safeParse(request.query);
3cbdca6204 const limit = parsed.success ? parsed.data.limit : 200;
3cbdca6205 return await readEntries(globalLogPath, limit);
3cbdca6206 });
3cbdca6207
3cbdca6208 // Repo-scoped ingest.
3cbdca6209 app.post<{ Params: { owner: string; repo: string } }>(
3cbdca6210 "/repos/:owner/:repo/ring/logs",
3cbdca6211 async (request, reply) => {
3cbdca6212 const paramsParsed = repoParamsSchema.safeParse(request.params);
3cbdca6213 if (!paramsParsed.success) {
3cbdca6214 return reply.code(400).send({ error: "Invalid owner/repo" });
3cbdca6215 }
3cbdca6216 const { owner, repo } = paramsParsed.data;
3cbdca6217 const source =
3cbdca6218 coerceHeaderString(request.headers["x-ring-source"]) ?? `${owner}/${repo}`;
3cbdca6219 const baseEntry = normalizePayload(request.body, source);
3cbdca6220 const entry: RingLogEntry = { ...baseEntry, owner, repo };
3cbdca6221 const serialized = `${JSON.stringify(entry)}\n`;
3cbdca6222
3cbdca6223 try {
3cbdca6224 const path = repoLogPath(owner, repo);
3cbdca6225 await mkdir(dirname(path), { recursive: true });
3cbdca6226 await appendFile(path, serialized, "utf8");
3cbdca6227 return { ok: true, entry };
3cbdca6228 } catch (error) {
3cbdca6229 request.log.error({ error }, "Failed to append repo Ring log");
3cbdca6230 return reply.code(500).send({ error: "Failed to write log entry" });
3cbdca6231 }
3cbdca6232 }
3cbdca6233 );
3cbdca6234
3cbdca6235 // Repo-scoped log view.
3cbdca6236 app.get<{
3cbdca6237 Params: { owner: string; repo: string };
3cbdca6238 Querystring: { limit?: string };
3cbdca6239 }>("/repos/:owner/:repo/ring/logs", async (request, reply) => {
3cbdca6240 const paramsParsed = repoParamsSchema.safeParse(request.params);
3cbdca6241 if (!paramsParsed.success) {
3cbdca6242 return reply.code(400).send({ error: "Invalid owner/repo" });
3cbdca6243 }
3cbdca6244 const { owner, repo } = paramsParsed.data;
3cbdca6245 const queryParsed = querySchema.safeParse(request.query);
3cbdca6246 const limit = queryParsed.success ? queryParsed.data.limit : 200;
3cbdca6247
3cbdca6248 return await readEntries(repoLogPath(owner, repo), limit);
3cbdca6249 });
3cbdca6250
3cbdca6251 // List all repo Ring instances that have been set up (log file exists).
3cbdca6252 app.get("/ring/instances", async () => {
3cbdca6253 const instances: RingInstanceSummary[] = [];
3cbdca6254 let ownerDirs: Dirent[] = [];
3cbdca6255 try {
3cbdca6256 ownerDirs = await readdir(repoLogsRoot, { withFileTypes: true });
3cbdca6257 } catch {
3cbdca6258 return { instances };
3cbdca6259 }
3cbdca6260
3cbdca6261 for (const ownerDir of ownerDirs) {
3cbdca6262 if (!ownerDir.isDirectory()) continue;
3cbdca6263 const owner = ownerDir.name;
3cbdca6264 const ownerPath = resolve(repoLogsRoot, owner);
3cbdca6265 let files: Dirent[] = [];
3cbdca6266 try {
3cbdca6267 files = await readdir(ownerPath, { withFileTypes: true });
3cbdca6268 } catch {
3cbdca6269 continue;
3cbdca6270 }
3cbdca6271 for (const file of files) {
3cbdca6272 if (!file.isFile() || !file.name.endsWith(".ndjson")) continue;
3cbdca6273 const repo = file.name.slice(0, -7);
3cbdca6274 const path = resolve(ownerPath, file.name);
3cbdca6275 const content = await readLogFile(path);
3cbdca6276 const lines = content.split("\n").filter(Boolean);
3cbdca6277 const total = lines.length;
3cbdca6278 const last = total > 0 ? parseLogLine(lines[total - 1]) : null;
3cbdca6279 instances.push({
3cbdca6280 owner,
3cbdca6281 repo,
3cbdca6282 total,
3cbdca6283 last_ts: last?.ts ?? null,
3cbdca6284 last_level: last?.level ?? null,
3cbdca6285 last_message: last?.message ?? null,
3cbdca6286 });
3cbdca6287 }
3cbdca6288 }
3cbdca6289
3cbdca6290 instances.sort(sortInstancesDescByNewest);
3cbdca6291 return { instances };
3cbdca6292 });
3cbdca6293}