From fd90a6bfa6a776b1824081a82d68bdcb61c18c62 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sun, 3 May 2026 09:21:25 +0100 Subject: [PATCH] Add pi extension to get production logs --- .pi/extensions/prod-logs/index.ts | 335 ++++++++++++++++++++++++++ .pi/extensions/prod-logs/package.json | 5 + 2 files changed, 340 insertions(+) create mode 100644 .pi/extensions/prod-logs/index.ts create mode 100644 .pi/extensions/prod-logs/package.json diff --git a/.pi/extensions/prod-logs/index.ts b/.pi/extensions/prod-logs/index.ts new file mode 100644 index 00000000..a989b00a --- /dev/null +++ b/.pi/extensions/prod-logs/index.ts @@ -0,0 +1,335 @@ +/** + * Production Log Viewer + * + * Fetches and displays application logs from the Coolify API. + * + * Credentials are read from environment variables following Hurl conventions: + * HURL_VARIABLE_coolify_host + * HURL_VARIABLE_coolify_app_uuid + * HURL_VARIABLE_coolify_token + * + * Usage: + * /prod-logs + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader, DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { + matchesKey, + Key, + truncateToWidth, +} from "@mariozechner/pi-tui"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface CoolifyLogsResponse { + logs?: string | string[]; +} + +// ── Log Viewer Component ──────────────────────────────────────────────────── + +class LogViewer { + lines: string[]; + private scrollOffset: number = 0; + private cachedWidth?: number; + private cachedLines?: string[]; + private visibleHeight: number; + + public onClose?: () => void; + public onRefresh?: () => void; + + constructor(lines: string[], visibleHeight: number) { + this.lines = lines; + this.visibleHeight = Math.max(1, visibleHeight); + } + + /** Replace lines and reset scroll to top. */ + updateLines(lines: string[], chromeHeight: number): void { + this.lines = lines; + this.scrollOffset = 0; + this.visibleHeight = Math.min(lines.length, Math.max(10, process.stdout.rows - chromeHeight)); + this.invalidate(); + } + + get offset(): number { + return this.scrollOffset; + } + + get total(): number { + return this.lines.length; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { + this.onClose?.(); + return; + } + + if (matchesKey(data, "r")) { + this.onRefresh?.(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 1); + this.invalidate(); + } else if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + const maxOffset = Math.max(0, this.lines.length - this.visibleHeight); + this.scrollOffset = Math.min(maxOffset, this.scrollOffset + 1); + this.invalidate(); + } else if (matchesKey(data, Key.pageUp)) { + this.scrollOffset = Math.max(0, this.scrollOffset - this.visibleHeight); + this.invalidate(); + } else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) { + const maxOffset = Math.max(0, this.lines.length - this.visibleHeight); + this.scrollOffset = Math.min(maxOffset, this.scrollOffset + this.visibleHeight); + this.invalidate(); + } else if (matchesKey(data, Key.ctrl("b"))) { + this.scrollOffset = Math.max(0, this.scrollOffset - this.visibleHeight); + this.invalidate(); + } else if (matchesKey(data, Key.home) || matchesKey(data, "g")) { + // "g" twice = go to top; single "g" handled as first press + this.scrollOffset = 0; + this.invalidate(); + } else if (matchesKey(data, Key.end) || matchesKey(data, "G")) { + this.scrollOffset = Math.max(0, this.lines.length - this.visibleHeight); + this.invalidate(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const padLen = String(this.lines.length).length; + const visibleLines = this.lines.slice( + this.scrollOffset, + this.scrollOffset + this.visibleHeight, + ); + + const result: string[] = []; + for (let i = 0; i < visibleLines.length; i++) { + const lineNum = this.scrollOffset + i + 1; + const numStr = String(lineNum).padStart(padLen, " "); + const prefix = ` ${numStr} │ `; + const content = truncateToWidth( + `${prefix}${visibleLines[i]}`, + width, + "", + ); + result.push(content); + } + + // Pad to visibleHeight if fewer lines + while (result.length < this.visibleHeight) { + result.push("~"); + } + + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function resolveVar(name: string): string | undefined { + return process.env[`HURL_VARIABLE_${name}`]; +} + +async function fetchLogs( + host: string, + appUuid: string, + token: string, + signal?: AbortSignal, +): Promise { + const url = `${host}/api/v1/applications/${appUuid}/logs`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + + if (!response.ok) { + throw new Error( + `Coolify API returned ${response.status} ${response.statusText}`, + ); + } + + const data: CoolifyLogsResponse = await response.json(); + + if (data.logs === undefined || data.logs === null) { + return ["(no logs returned)"]; + } + + if (Array.isArray(data.logs)) { + return data.logs; + } + + if (typeof data.logs === "string") { + return data.logs.split("\n"); + } + + return ["(unexpected log format)"]; +} + +// ── Extension ─────────────────────────────────────────────────────────────── + +export default function prodLogsExtension(pi: ExtensionAPI) { + pi.registerCommand("prod-logs", { + description: "View production application logs via Coolify API", + handler: async (_args, ctx) => { + // ── Validate credentials ────────────────────────────────────────── + + const host = resolveVar("coolify_host"); + const appUuid = resolveVar("coolify_app_uuid"); + const token = resolveVar("coolify_token"); + + const missing: string[] = []; + if (!host) missing.push("HURL_VARIABLE_coolify_host"); + if (!appUuid) missing.push("HURL_VARIABLE_coolify_app_uuid"); + if (!token) missing.push("HURL_VARIABLE_coolify_token"); + + if (missing.length > 0) { + ctx.ui.notify( + `Missing environment variables: ${missing.join(", ")}`, + "error", + ); + return; + } + + // ── Fetch logs with loader UI ───────────────────────────────────── + + const logLines = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const loader = new BorderedLoader( + tui, + theme, + "Fetching production logs...", + ); + loader.onAbort = () => done(null); + + fetchLogs(host!, appUuid!, token!, loader.signal) + .then(done) + .catch((err) => { + console.error("[prod-logs] Fetch failed:", err); + done(null); + }); + + return loader; + }, + ); + + if (logLines === null) { + ctx.ui.notify("Cancelled or fetch failed — check logs for details", "info"); + return; + } + + if (logLines.length === 0) { + ctx.ui.notify("No log entries found", "info"); + return; + } + + // Recent entries first + logLines.reverse(); + + // ── Display logs in scrollable viewer ───────────────────────────── + + // Reserve lines for borders, header, footer, scroll info + const chromeHeight = 10; + + let logLinesMut = logLines; + let loading = false; + + const visibleLogLines = () => + Math.min(logLinesMut.length, Math.max(10, process.stdout.rows - chromeHeight)); + + const viewer = new LogViewer(logLinesMut, visibleLogLines()); + + const doRefresh = (tui: { requestRender: () => void }) => { + if (loading) return; + loading = true; + tui.requestRender(); + + fetchLogs(host!, appUuid!, token!) + .then((lines) => { + lines.reverse(); + logLinesMut = lines; + viewer.updateLines(lines, chromeHeight); + loading = false; + tui.requestRender(); + }) + .catch((err) => { + console.error("[prod-logs] Refresh failed:", err); + loading = false; + tui.requestRender(); + }); + }; + + await ctx.ui.custom((tui, theme, _kb, done) => { + viewer.onClose = () => done(); + viewer.onRefresh = () => doRefresh(tui); + + return { + render(width: number) { + // Update scroll info + const pct = viewer.total > 0 + ? Math.round((viewer.offset / viewer.total) * 100) + : 0; + const from = viewer.offset + 1; + const to = Math.min( + viewer.offset + visibleLogLines(), + viewer.total, + ); + // Build result manually + const result: string[] = []; + const border = theme.fg("accent", "─".repeat(width)); + result.push(border); + result.push(truncateToWidth(theme.fg("accent", theme.bold(" Production Logs")), width, "")); + + const countText = loading + ? " Refreshing..." + : ` ${logLinesMut.length} lines`; + result.push(truncateToWidth(theme.fg("muted", countText), width, "")); + result.push(truncateToWidth(theme.fg("dim", "─".repeat(40)), width, "")); + + const infoText = ` Lines ${from}-${to} of ${logLinesMut.length} (${pct}%)`; + result.push(truncateToWidth(theme.fg("dim", infoText), width, "")); + + // Log lines + const logRendered = viewer.render(width); + result.push(...logRendered); + + const helpLine = loading + ? " Refreshing..." + : " ↑↓/jk scroll · PgUp/PgDn page · Home/End jump · r refresh · Esc close"; + result.push(truncateToWidth( + theme.fg("dim", helpLine), + width, + "", + )); + result.push(border); + + return result; + }, + + invalidate() { + viewer.invalidate(); + }, + + handleInput(data: string) { + viewer.handleInput(data); + tui.requestRender(); + }, + }; + }); + + ctx.ui.notify("Log viewer closed", "info"); + }, + }); +} diff --git a/.pi/extensions/prod-logs/package.json b/.pi/extensions/prod-logs/package.json new file mode 100644 index 00000000..a8a7f596 --- /dev/null +++ b/.pi/extensions/prod-logs/package.json @@ -0,0 +1,5 @@ +{ + "name": "prod-logs", + "private": true, + "description": "View production application logs via Coolify API" +}