feat: add Phase 4 x402 metering and Phase 5 hardening
Phase 4: Usage metrics service (container stats, post count, storage), billing calculator with pro-rated monthly pricing, usage + payment routes, x402 middleware wired to provisioning endpoint. Phase 5: In-memory rate limiter (general 10/min + provision 2/hour), admin routes (force teardown, suspend/resume), Dockerfile hardened with non-root user and healthcheck. Suspended instance status added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
908e2257e4
commit
a536a9bc0f
|
|
@ -2,17 +2,28 @@ FROM oven/bun:1-alpine
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps as root
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
RUN mkdir -p /data
|
||||
# Create data dir and non-root user
|
||||
RUN mkdir -p /data && \
|
||||
addgroup -g 1001 rsocials && \
|
||||
adduser -u 1001 -G rsocials -s /bin/sh -D rsocials && \
|
||||
chown -R rsocials:rsocials /app /data
|
||||
|
||||
USER rsocials
|
||||
|
||||
ENV PORT=3001
|
||||
ENV DATABASE_PATH=/data/instances.db
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3001/health || exit 1
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import { initDb } from "./db/schema.js";
|
|||
import { InstanceStore } from "./db/queries.js";
|
||||
import { siweAuth } from "./middleware/siwe-auth.js";
|
||||
import { tokenGate } from "./middleware/token-gate.js";
|
||||
import { rateLimiter, provisionRateLimiter } from "./middleware/rate-limit.js";
|
||||
import { setupX402FromEnv } from "./middleware/x402.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { spacesRoutes } from "./routes/spaces.js";
|
||||
import { provisionRoutes } from "./routes/provision.js";
|
||||
import { usageRoutes } from "./routes/usage.js";
|
||||
import { adminRoutes } from "./routes/admin.js";
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? "3001", 10);
|
||||
const DB_PATH = process.env.DATABASE_PATH ?? "./data/instances.db";
|
||||
|
|
@ -31,8 +35,30 @@ app.route("/v1/auth", authRoutes());
|
|||
// Protected routes (SIWE wallet or API key)
|
||||
app.use("/v1/spaces/*", siweAuth);
|
||||
app.use("/v1/spaces/*", tokenGate);
|
||||
app.use("/v1/spaces/*", rateLimiter);
|
||||
|
||||
// x402 payment gate on provisioning (only if X402_PAY_TO is set)
|
||||
const x402Middleware = setupX402FromEnv();
|
||||
if (x402Middleware) {
|
||||
// Apply x402 only to POST (provisioning) and pay endpoint
|
||||
app.use("/v1/spaces", async (c, next) => {
|
||||
if (c.req.method === "POST") return x402Middleware(c, next);
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
// Provision rate limiter (stricter — 2/hour)
|
||||
app.post("/v1/spaces", provisionRateLimiter);
|
||||
|
||||
// Space routes
|
||||
app.route("/v1/spaces", provisionRoutes(store));
|
||||
app.route("/v1/spaces", spacesRoutes(store));
|
||||
app.route("/v1/spaces", usageRoutes(store));
|
||||
|
||||
// Admin routes (API key only)
|
||||
app.use("/v1/admin/*", siweAuth); // reuse auth (checks API key first)
|
||||
app.use("/v1/admin/*", rateLimiter);
|
||||
app.route("/v1/admin", adminRoutes(store));
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* In-memory rate limiter per wallet/IP.
|
||||
*
|
||||
* Configurable:
|
||||
* RATE_LIMIT_WINDOW_MS=60000 (1 minute default)
|
||||
* RATE_LIMIT_MAX_REQUESTS=10 (10 requests per window)
|
||||
* RATE_LIMIT_PROVISION_MAX=2 (2 provisions per hour)
|
||||
*/
|
||||
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
interface RateEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "60000", 10);
|
||||
const MAX_REQUESTS = parseInt(
|
||||
process.env.RATE_LIMIT_MAX_REQUESTS ?? "10",
|
||||
10
|
||||
);
|
||||
const PROVISION_MAX = parseInt(
|
||||
process.env.RATE_LIMIT_PROVISION_MAX ?? "2",
|
||||
10
|
||||
);
|
||||
const PROVISION_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const generalLimits = new Map<string, RateEntry>();
|
||||
const provisionLimits = new Map<string, RateEntry>();
|
||||
|
||||
function cleanExpired(store: Map<string, RateEntry>) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now > entry.resetAt) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getIdentifier(c: Context): string {
|
||||
const owner = c.get("owner" as never) as string | undefined;
|
||||
if (owner && owner !== "admin") return owner;
|
||||
return (
|
||||
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
c.req.header("x-real-ip") ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function checkLimit(
|
||||
store: Map<string, RateEntry>,
|
||||
key: string,
|
||||
max: number,
|
||||
windowMs: number
|
||||
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
const resetAt = now + windowMs;
|
||||
store.set(key, { count: 1, resetAt });
|
||||
return { allowed: true, remaining: max - 1, resetAt };
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
if (entry.count > max) {
|
||||
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: max - entry.count,
|
||||
resetAt: entry.resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* General rate limiter for all API routes.
|
||||
*/
|
||||
export async function rateLimiter(c: Context, next: Next) {
|
||||
const id = getIdentifier(c);
|
||||
|
||||
// Admin API key bypasses rate limiting
|
||||
const authMethod = c.get("authMethod" as never) as string | undefined;
|
||||
if (authMethod === "api-key") return next();
|
||||
|
||||
// Clean expired entries periodically
|
||||
if (Math.random() < 0.1) cleanExpired(generalLimits);
|
||||
|
||||
const result = checkLimit(generalLimits, id, MAX_REQUESTS, WINDOW_MS);
|
||||
|
||||
c.header("X-RateLimit-Limit", String(MAX_REQUESTS));
|
||||
c.header("X-RateLimit-Remaining", String(result.remaining));
|
||||
c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
|
||||
|
||||
if (!result.allowed) {
|
||||
return c.json(
|
||||
{
|
||||
error: "Too many requests",
|
||||
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stricter rate limiter specifically for provisioning.
|
||||
* 2 provisions per hour per wallet.
|
||||
*/
|
||||
export async function provisionRateLimiter(c: Context, next: Next) {
|
||||
const id = getIdentifier(c);
|
||||
|
||||
const authMethod = c.get("authMethod" as never) as string | undefined;
|
||||
if (authMethod === "api-key") return next();
|
||||
|
||||
if (Math.random() < 0.1) cleanExpired(provisionLimits);
|
||||
|
||||
const result = checkLimit(
|
||||
provisionLimits,
|
||||
id,
|
||||
PROVISION_MAX,
|
||||
PROVISION_WINDOW_MS
|
||||
);
|
||||
|
||||
if (!result.allowed) {
|
||||
return c.json(
|
||||
{
|
||||
error: "Provisioning rate limit exceeded",
|
||||
detail: `Max ${PROVISION_MAX} provisions per hour`,
|
||||
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Admin-only routes for managing the provisioning platform.
|
||||
*
|
||||
* All routes require X-API-Key authentication (admin only).
|
||||
*
|
||||
* GET /v1/admin/instances — List all instances (any owner)
|
||||
* DELETE /v1/admin/instances/:slug — Force teardown (bypass owner check)
|
||||
* POST /v1/admin/instances/:slug/suspend — Suspend an instance
|
||||
* POST /v1/admin/instances/:slug/resume — Resume a suspended instance
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { InstanceStore } from "../db/queries.js";
|
||||
import {
|
||||
teardownSpace,
|
||||
checkContainerHealth,
|
||||
} from "../services/docker-deployer.js";
|
||||
import {
|
||||
removeTunnelHostnames,
|
||||
restartCloudflared,
|
||||
} from "../services/tunnel-config.js";
|
||||
|
||||
export function adminRoutes(store: InstanceStore) {
|
||||
const app = new Hono();
|
||||
|
||||
// Admin guard — only API key auth allowed
|
||||
app.use("*", async (c, next) => {
|
||||
const authMethod = c.get("authMethod" as never) as string;
|
||||
if (authMethod !== "api-key") {
|
||||
return c.json({ error: "Admin access required" }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
// List all instances (all owners)
|
||||
app.get("/instances", (c) => {
|
||||
const instances = store.list();
|
||||
const summary = {
|
||||
total: instances.length,
|
||||
active: instances.filter((i) => i.status === "active").length,
|
||||
provisioning: instances.filter((i) => i.status === "provisioning").length,
|
||||
failed: instances.filter((i) => i.status === "failed").length,
|
||||
};
|
||||
return c.json({ summary, instances });
|
||||
});
|
||||
|
||||
// Force teardown (bypass owner check)
|
||||
app.delete("/instances/:slug", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const instance = store.getBySlug(slug);
|
||||
if (!instance) {
|
||||
return c.json({ error: "Instance not found" }, 404);
|
||||
}
|
||||
|
||||
if (instance.status === "destroyed") {
|
||||
return c.json({ error: "Already destroyed" }, 400);
|
||||
}
|
||||
|
||||
store.updateStatus(instance.id, "teardown");
|
||||
store.addLog(instance.id, "admin_force_teardown");
|
||||
|
||||
// Run teardown in background
|
||||
(async () => {
|
||||
try {
|
||||
removeTunnelHostnames(instance.primaryDomain, instance.fallbackDomain);
|
||||
await restartCloudflared();
|
||||
await teardownSpace(instance.slug);
|
||||
store.updateStatus(instance.id, "destroyed");
|
||||
store.addLog(instance.id, "teardown_complete");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
store.addLog(instance.id, "teardown_error", msg);
|
||||
store.updateStatus(instance.id, "failed");
|
||||
}
|
||||
})();
|
||||
|
||||
return c.json({ message: "Force teardown started", instance });
|
||||
});
|
||||
|
||||
// Suspend an instance (stop containers but keep data)
|
||||
app.post("/instances/:slug/suspend", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const instance = store.getBySlug(slug);
|
||||
if (!instance) {
|
||||
return c.json({ error: "Instance not found" }, 404);
|
||||
}
|
||||
|
||||
if (instance.status !== "active") {
|
||||
return c.json({ error: `Cannot suspend: status is ${instance.status}` }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
["docker", "compose", "-f", instance.composePath!, "stop"],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
await proc.exited;
|
||||
|
||||
store.updateStatus(instance.id, "suspended" as never);
|
||||
store.addLog(instance.id, "admin_suspend");
|
||||
|
||||
return c.json({ message: "Instance suspended", slug });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Suspend failed: ${msg}` }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Resume a suspended instance
|
||||
app.post("/instances/:slug/resume", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const instance = store.getBySlug(slug);
|
||||
if (!instance) {
|
||||
return c.json({ error: "Instance not found" }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
["docker", "compose", "-f", instance.composePath!, "start"],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
await proc.exited;
|
||||
|
||||
const healthy = await checkContainerHealth(slug);
|
||||
if (!healthy) {
|
||||
store.addLog(instance.id, "resume_unhealthy");
|
||||
return c.json({ error: "Containers started but unhealthy" }, 500);
|
||||
}
|
||||
|
||||
store.updateStatus(instance.id, "active");
|
||||
store.addLog(instance.id, "admin_resume");
|
||||
|
||||
return c.json({ message: "Instance resumed", slug });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Resume failed: ${msg}` }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Usage & billing routes.
|
||||
*
|
||||
* GET /v1/spaces/:slug/usage — Container stats, post count, storage, billing estimate
|
||||
* POST /v1/spaces/:slug/pay — Settle balance via x402 payment
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { InstanceStore } from "../db/queries.js";
|
||||
import { collectMetrics } from "../services/metrics.js";
|
||||
import { calculateBill } from "../services/billing.js";
|
||||
import { setupX402FromEnv } from "../middleware/x402.js";
|
||||
|
||||
export function usageRoutes(store: InstanceStore) {
|
||||
const app = new Hono();
|
||||
|
||||
// Get usage + billing estimate for an instance
|
||||
app.get("/:slug/usage", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const instance = store.getBySlug(slug);
|
||||
if (!instance) {
|
||||
return c.json({ error: "Instance not found" }, 404);
|
||||
}
|
||||
|
||||
const owner = c.get("owner" as never) as string;
|
||||
if (owner !== "admin" && instance.owner !== owner) {
|
||||
return c.json({ error: "Not authorized" }, 403);
|
||||
}
|
||||
|
||||
const metrics = await collectMetrics(slug, instance.status);
|
||||
const billing = calculateBill(
|
||||
slug,
|
||||
metrics.postCount ?? 0,
|
||||
metrics.storageMB ?? 0,
|
||||
instance.createdAt
|
||||
);
|
||||
|
||||
return c.json({ usage: metrics, billing });
|
||||
});
|
||||
|
||||
// Settle balance — x402 payment endpoint
|
||||
app.post("/:slug/pay", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const instance = store.getBySlug(slug);
|
||||
if (!instance) {
|
||||
return c.json({ error: "Instance not found" }, 404);
|
||||
}
|
||||
|
||||
const owner = c.get("owner" as never) as string;
|
||||
if (owner !== "admin" && instance.owner !== owner) {
|
||||
return c.json({ error: "Not authorized" }, 403);
|
||||
}
|
||||
|
||||
// If x402 is enabled, the middleware will have already verified payment
|
||||
const payment = c.get("x402Payment" as never) as string | undefined;
|
||||
|
||||
if (payment) {
|
||||
store.addLog(
|
||||
instance.id,
|
||||
"payment_received",
|
||||
`x402 payment verified`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
status: "paid",
|
||||
instance: { slug: instance.slug, status: instance.status },
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire x402 middleware to the payment endpoint (if configured).
|
||||
*/
|
||||
export function getX402PayMiddleware() {
|
||||
return setupX402FromEnv({
|
||||
description: "Monthly payment for Postiz space hosting",
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Billing calculator for x402 metered usage.
|
||||
*
|
||||
* Pricing:
|
||||
* Monthly base: $5.00 USDC (covers ~2.5GB RAM)
|
||||
* Per post: $0.01 USDC
|
||||
* Per GB storage: $0.50 USDC (above 1GB free tier)
|
||||
*/
|
||||
|
||||
export interface BillingPeriod {
|
||||
slug: string;
|
||||
periodStart: string; // ISO date
|
||||
periodEnd: string;
|
||||
baseCostUSD: number;
|
||||
postCount: number;
|
||||
postCostUSD: number;
|
||||
storageMB: number;
|
||||
storageCostUSD: number;
|
||||
totalUSD: number;
|
||||
}
|
||||
|
||||
const BASE_MONTHLY_USD = parseFloat(
|
||||
process.env.X402_BASE_MONTHLY_PRICE ?? "5.00"
|
||||
);
|
||||
const PER_POST_USD = parseFloat(process.env.X402_PER_POST_PRICE ?? "0.01");
|
||||
const PER_GB_USD = parseFloat(process.env.X402_PER_GB_PRICE ?? "0.50");
|
||||
const FREE_STORAGE_MB = 1024; // 1 GB free tier
|
||||
|
||||
export function calculateBill(
|
||||
slug: string,
|
||||
postCount: number,
|
||||
storageMB: number,
|
||||
createdAt: string
|
||||
): BillingPeriod {
|
||||
const now = new Date();
|
||||
const created = new Date(createdAt);
|
||||
|
||||
// Pro-rate base cost if instance is less than a month old
|
||||
const ageMs = now.getTime() - created.getTime();
|
||||
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
||||
const monthFraction = Math.min(ageDays / 30, 1);
|
||||
const baseCostUSD = Math.round(BASE_MONTHLY_USD * monthFraction * 100) / 100;
|
||||
|
||||
const postCostUSD = Math.round(postCount * PER_POST_USD * 100) / 100;
|
||||
|
||||
const billableStorageMB = Math.max(0, storageMB - FREE_STORAGE_MB);
|
||||
const billableStorageGB = billableStorageMB / 1024;
|
||||
const storageCostUSD = Math.round(billableStorageGB * PER_GB_USD * 100) / 100;
|
||||
|
||||
const totalUSD =
|
||||
Math.round((baseCostUSD + postCostUSD + storageCostUSD) * 100) / 100;
|
||||
|
||||
return {
|
||||
slug,
|
||||
periodStart: created.toISOString(),
|
||||
periodEnd: now.toISOString(),
|
||||
baseCostUSD,
|
||||
postCount,
|
||||
postCostUSD,
|
||||
storageMB,
|
||||
storageCostUSD,
|
||||
totalUSD,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Instance usage metrics — queries Docker for container stats and
|
||||
* the instance's PostgreSQL for post counts.
|
||||
*/
|
||||
|
||||
export interface UsageMetrics {
|
||||
slug: string;
|
||||
status: string;
|
||||
containerStats: ContainerStats | null;
|
||||
postCount: number | null;
|
||||
storageMB: number | null;
|
||||
uptimeHours: number | null;
|
||||
}
|
||||
|
||||
interface ContainerStats {
|
||||
state: string;
|
||||
memoryUsageMB: number;
|
||||
cpuPercent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container resource usage from Docker.
|
||||
*/
|
||||
export async function getContainerStats(
|
||||
slug: string
|
||||
): Promise<ContainerStats | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"docker",
|
||||
"stats",
|
||||
`postiz-${slug}`,
|
||||
"--no-stream",
|
||||
"--format",
|
||||
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.Name}}",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) return null;
|
||||
|
||||
const line = output.trim();
|
||||
if (!line) return null;
|
||||
|
||||
const parts = line.split("\t");
|
||||
const memStr = parts[0]?.split("/")[0]?.trim() ?? "0MiB";
|
||||
const cpuStr = parts[1]?.replace("%", "").trim() ?? "0";
|
||||
|
||||
// Parse memory (could be MiB or GiB)
|
||||
let memoryUsageMB = 0;
|
||||
if (memStr.includes("GiB")) {
|
||||
memoryUsageMB = parseFloat(memStr) * 1024;
|
||||
} else {
|
||||
memoryUsageMB = parseFloat(memStr);
|
||||
}
|
||||
|
||||
return {
|
||||
state: "running",
|
||||
memoryUsageMB: Math.round(memoryUsageMB),
|
||||
cpuPercent: parseFloat(cpuStr),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post count from instance's PostgreSQL container.
|
||||
*/
|
||||
export async function getPostCount(slug: string): Promise<number | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
`postiz-${slug}-db`,
|
||||
"psql",
|
||||
"-U",
|
||||
"postiz",
|
||||
"-d",
|
||||
"postiz-db",
|
||||
"-t",
|
||||
"-c",
|
||||
"SELECT COUNT(*) FROM posts;",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) return null;
|
||||
return parseInt(output.trim(), 10) || 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage usage from Docker volumes.
|
||||
*/
|
||||
export async function getStorageMB(slug: string): Promise<number | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"docker",
|
||||
"system",
|
||||
"df",
|
||||
"-v",
|
||||
"--format",
|
||||
"{{.Name}}\t{{.Size}}",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
// Sum volumes matching the slug
|
||||
let totalMB = 0;
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.includes(slug)) {
|
||||
const sizeStr = line.split("\t")[1] ?? "0B";
|
||||
if (sizeStr.includes("GB")) {
|
||||
totalMB += parseFloat(sizeStr) * 1024;
|
||||
} else if (sizeStr.includes("MB")) {
|
||||
totalMB += parseFloat(sizeStr);
|
||||
} else if (sizeStr.includes("kB")) {
|
||||
totalMB += parseFloat(sizeStr) / 1024;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.round(totalMB);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container uptime in hours.
|
||||
*/
|
||||
export async function getUptimeHours(slug: string): Promise<number | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
`postiz-${slug}`,
|
||||
"--format",
|
||||
"{{.State.StartedAt}}",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) return null;
|
||||
|
||||
const startedAt = new Date(output.trim());
|
||||
const now = new Date();
|
||||
const hours = (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60);
|
||||
return Math.round(hours * 10) / 10;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all usage metrics for an instance.
|
||||
*/
|
||||
export async function collectMetrics(
|
||||
slug: string,
|
||||
status: string
|
||||
): Promise<UsageMetrics> {
|
||||
if (status !== "active") {
|
||||
return {
|
||||
slug,
|
||||
status,
|
||||
containerStats: null,
|
||||
postCount: null,
|
||||
storageMB: null,
|
||||
uptimeHours: null,
|
||||
};
|
||||
}
|
||||
|
||||
const [containerStats, postCount, storageMB, uptimeHours] =
|
||||
await Promise.all([
|
||||
getContainerStats(slug),
|
||||
getPostCount(slug),
|
||||
getStorageMB(slug),
|
||||
getUptimeHours(slug),
|
||||
]);
|
||||
|
||||
return { slug, status, containerStats, postCount, storageMB, uptimeHours };
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ export interface Instance {
|
|||
export type InstanceStatus =
|
||||
| "provisioning"
|
||||
| "active"
|
||||
| "suspended"
|
||||
| "failed"
|
||||
| "teardown"
|
||||
| "destroyed";
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ const statusConfig: Record<
|
|||
color: "bg-destructive/10 text-destructive",
|
||||
icon: <AlertCircle className="h-3 w-3" />,
|
||||
},
|
||||
suspended: {
|
||||
label: "Suspended",
|
||||
color: "bg-blue-500/10 text-blue-600",
|
||||
icon: <Clock className="h-3 w-3" />,
|
||||
},
|
||||
destroying: {
|
||||
label: "Removing",
|
||||
color: "bg-orange-500/10 text-orange-600",
|
||||
|
|
|
|||
Loading…
Reference in New Issue