Add pi extension to browse database backups
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* S3 Backup Browser
|
||||
*
|
||||
* Lists backup files in the Litestream S3 bucket as a scrollable TUI.
|
||||
*
|
||||
* Credentials are read from the same environment variables used by
|
||||
* scripts/prod/litestream-backup:
|
||||
* LITESTREAM_ACCESS_KEY_ID
|
||||
* LITESTREAM_SECRET_ACCESS_KEY
|
||||
*
|
||||
* Usage:
|
||||
* /backups
|
||||
*/
|
||||
|
||||
import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { BorderedLoader, DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
// ── S3 configuration (mirrors scripts/prod/litestream-backup) ────────────────
|
||||
const S3_ENDPOINT = "https://nbg1.your-objectstorage.com";
|
||||
const S3_BUCKET = "ffmusiclibrary";
|
||||
const S3_PREFIX = "prod/";
|
||||
const S3_REGION = "nbg1";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function humanSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
// YYYY-MM-DD HH:MM
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the known prefix from an S3 key for cleaner display.
|
||||
*/
|
||||
function displayKey(key: string): string {
|
||||
if (key.startsWith(S3_PREFIX)) return key.slice(S3_PREFIX.length);
|
||||
return key;
|
||||
}
|
||||
|
||||
export interface S3Object {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all objects (handles pagination via ContinuationToken).
|
||||
*/
|
||||
async function listAllObjects(
|
||||
client: S3Client,
|
||||
signal?: AbortSignal,
|
||||
): Promise<S3Object[]> {
|
||||
const results: S3Object[] = [];
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: S3_BUCKET,
|
||||
Prefix: S3_PREFIX,
|
||||
...(continuationToken ? { ContinuationToken: continuationToken } : {}),
|
||||
});
|
||||
|
||||
const response = await client.send(command, { abortSignal: signal });
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
results.push({
|
||||
key: obj.Key ?? "",
|
||||
size: obj.Size ?? 0,
|
||||
lastModified: obj.LastModified ?? new Date(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.IsTruncated
|
||||
? response.NextContinuationToken
|
||||
: undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
// Sort by size descending, then alphabetically descending
|
||||
results.sort((a, b) => {
|
||||
const sizeDiff = b.size - a.size;
|
||||
if (sizeDiff !== 0) return sizeDiff;
|
||||
return b.key.localeCompare(a.key);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Extension ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function s3BrowserExtension(pi: ExtensionAPI) {
|
||||
pi.registerCommand("backups", {
|
||||
description: "List backup files in the S3 bucket",
|
||||
handler: async (_args, ctx) => {
|
||||
// ── Validate credentials ────────────────────────────────────────────
|
||||
|
||||
const accessKeyId = process.env.LITESTREAM_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.LITESTREAM_SECRET_ACCESS_KEY;
|
||||
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
ctx.ui.notify(
|
||||
"Missing LITESTREAM_ACCESS_KEY_ID or LITESTREAM_SECRET_ACCESS_KEY env vars",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Fetch objects with loader UI ────────────────────────────────────
|
||||
|
||||
const objects = await ctx.ui.custom<S3Object[] | null>(
|
||||
(tui, theme, _kb, done) => {
|
||||
const loader = new BorderedLoader(
|
||||
tui,
|
||||
theme,
|
||||
"Fetching backup list from S3...",
|
||||
);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
const doFetch = async (): Promise<S3Object[]> => {
|
||||
const client = new S3Client({
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
return listAllObjects(client, loader.signal);
|
||||
};
|
||||
|
||||
doFetch()
|
||||
.then(done)
|
||||
.catch((err) => {
|
||||
console.error("[s3-browser] Fetch failed:", err);
|
||||
done(null);
|
||||
});
|
||||
|
||||
return loader;
|
||||
},
|
||||
);
|
||||
|
||||
if (objects === null) {
|
||||
ctx.ui.notify("Cancelled or fetch failed — check logs for details", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (objects.length === 0) {
|
||||
ctx.ui.notify("No backup files found in bucket", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build the file list UI ──────────────────────────────────────────
|
||||
|
||||
const totalSize = objects.reduce((sum, o) => sum + o.size, 0);
|
||||
|
||||
const items: SelectItem[] = objects.map((obj) => ({
|
||||
value: obj.key,
|
||||
label: displayKey(obj.key),
|
||||
description: `${humanSize(obj.size)} │ ${formatDate(obj.lastModified)}`,
|
||||
}));
|
||||
|
||||
// Summary header line
|
||||
const summary = `${objects.length} files · ${humanSize(totalSize)} total`;
|
||||
|
||||
const selectedKey = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(
|
||||
new DynamicBorder((s: string) => theme.fg("accent", s)),
|
||||
);
|
||||
|
||||
// Header
|
||||
container.addChild(
|
||||
new Text(theme.fg("accent", theme.bold("S3 Backups — ffmusiclibrary/prod/")), 1, 0),
|
||||
);
|
||||
container.addChild(
|
||||
new Text(theme.fg("muted", summary), 1, 0),
|
||||
);
|
||||
|
||||
// Spacer before list
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", "─".repeat(40)), 1, 0),
|
||||
);
|
||||
|
||||
// SelectList
|
||||
const selectList = new SelectList(items, Math.min(items.length, 20), {
|
||||
selectedPrefix: (t: string) => theme.fg("accent", t),
|
||||
selectedText: (t: string) => theme.fg("accent", t),
|
||||
description: (t: string) => theme.fg("muted", t),
|
||||
scrollInfo: (t: string) => theme.fg("dim", t),
|
||||
noMatch: (t: string) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => done(item.value);
|
||||
selectList.onCancel = () => done(null);
|
||||
container.addChild(selectList);
|
||||
|
||||
// Footer hint
|
||||
container.addChild(
|
||||
new Text(
|
||||
theme.fg("dim", "↑↓ navigate · enter select · esc close"),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(
|
||||
new DynamicBorder((s: string) => theme.fg("accent", s)),
|
||||
);
|
||||
|
||||
return {
|
||||
render(width: number) {
|
||||
return container.render(width);
|
||||
},
|
||||
invalidate() {
|
||||
container.invalidate();
|
||||
},
|
||||
handleInput(data: string) {
|
||||
selectList.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (selectedKey) {
|
||||
const obj = objects.find((o) => o.key === selectedKey);
|
||||
if (obj) {
|
||||
ctx.ui.notify(
|
||||
`${displayKey(obj.key)} — ${humanSize(obj.size)} — ${formatDate(obj.lastModified)}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+1673
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "s3-browser",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.787.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user