feat: add dev pod management page with start/stop/restart controls
All checks were successful
Build & Release / build (push) Successful in 12s
All checks were successful
Build & Release / build (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -199,10 +199,14 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-brand">LunarFront</div>
|
<div class="sidebar-brand">LunarFront</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-item" id="nav-customers" onclick="showPage('customers')">
|
<div class="nav-item" id="nav-customers" onclick="navigate('customers')">
|
||||||
<span class="nav-icon">◫</span>
|
<span class="nav-icon">◫</span>
|
||||||
<span>Customers</span>
|
<span>Customers</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" id="nav-devpod" onclick="navigate('devpod')">
|
||||||
|
<span class="nav-icon">⬡</span>
|
||||||
|
<span>Dev Pod</span>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-user">
|
<div class="sidebar-user">
|
||||||
<button class="user-btn" onclick="toggleUserMenu()">
|
<button class="user-btn" onclick="toggleUserMenu()">
|
||||||
@@ -284,6 +288,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Dev Pod -->
|
||||||
|
<div id="page-devpod" class="page">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem">
|
||||||
|
<div class="page-title" style="margin-bottom:0">Dev Pod</div>
|
||||||
|
<div style="display:flex;gap:0.5rem">
|
||||||
|
<button class="btn btn-slate btn-sm" onclick="loadDevPod()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid" id="devpod-grid">
|
||||||
|
<div class="stat-card stat-card-full">
|
||||||
|
<div class="card-title">Loading…</div>
|
||||||
|
<div style="height:80px;display:flex;align-items:center;justify-content:center;color:#484f58;font-size:0.845rem">Fetching status…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account -->
|
<!-- Account -->
|
||||||
<div id="page-account" class="page">
|
<div id="page-account" class="page">
|
||||||
<div class="page-title">Account</div>
|
<div class="page-title">Account</div>
|
||||||
@@ -470,6 +490,9 @@
|
|||||||
openDetail(slug);
|
openDetail(slug);
|
||||||
} else if (hash === 'customers') {
|
} else if (hash === 'customers') {
|
||||||
showPage('customers');
|
showPage('customers');
|
||||||
|
} else if (hash === 'devpod') {
|
||||||
|
showPage('devpod');
|
||||||
|
loadDevPod();
|
||||||
} else if (hash === 'account') {
|
} else if (hash === 'account') {
|
||||||
showPage('account');
|
showPage('account');
|
||||||
} else {
|
} else {
|
||||||
@@ -951,6 +974,127 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dev Pod ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let devpodRefreshTimer = null;
|
||||||
|
|
||||||
|
async function loadDevPod() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/devpod/status');
|
||||||
|
if (!res.ok) throw new Error('Failed to load');
|
||||||
|
const data = await res.json();
|
||||||
|
renderDevPod(data);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('devpod-grid').innerHTML =
|
||||||
|
`<div class="stat-card stat-card-full" style="color:#f85149">Failed to load: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevPod({ state, replicas, readyReplicas, image, pods }) {
|
||||||
|
const stateBadge = state === 'running' ? '<span class="badge badge-green">Running</span>'
|
||||||
|
: state === 'stopped' ? '<span class="badge badge-gray">Stopped</span>'
|
||||||
|
: state === 'starting' ? '<span class="badge badge-yellow">Starting</span>'
|
||||||
|
: '<span class="badge badge-red">Error</span>';
|
||||||
|
|
||||||
|
const pod = pods[0];
|
||||||
|
const uptime = pod?.startedAt ? fmtAge(pod.startedAt) : '—';
|
||||||
|
const startedAt = pod?.startedAt ? fmtDateTime(pod.startedAt) : '—';
|
||||||
|
|
||||||
|
const podsHtml = pods.length === 0
|
||||||
|
? '<div style="color:#484f58;font-size:0.845rem;padding:0.5rem 0">No pods running</div>'
|
||||||
|
: `<table class="pods-table">
|
||||||
|
<thead><tr><th>Pod</th><th>Ready</th><th>Status</th><th>Restarts</th><th>Age</th></tr></thead>
|
||||||
|
<tbody>${pods.map(p => {
|
||||||
|
const cls = p.status === 'Running' ? 'pod-status-running'
|
||||||
|
: p.status === 'Pending' ? 'pod-status-pending'
|
||||||
|
: p.status === 'Failed' ? 'pod-status-failed' : 'pod-status-unknown';
|
||||||
|
return `<tr>
|
||||||
|
<td class="pod-name">${p.name}</td>
|
||||||
|
<td style="color:#8b949e">${p.readyCount}/${p.totalCount}</td>
|
||||||
|
<td class="${cls}">${p.status}</td>
|
||||||
|
<td style="color:${p.restarts > 5 ? '#f85149' : '#8b949e'}">${p.restarts}</td>
|
||||||
|
<td style="color:#8b949e">${p.startedAt ? fmtAge(p.startedAt) : '—'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('')}</tbody>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
const startDisabled = state === 'running' || state === 'starting';
|
||||||
|
const stopDisabled = state === 'stopped';
|
||||||
|
const restartDisabled = state === 'stopped';
|
||||||
|
|
||||||
|
document.getElementById('devpod-grid').innerHTML = `
|
||||||
|
<div class="stat-card stat-card-full">
|
||||||
|
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
Status
|
||||||
|
<div style="display:flex;gap:0.5rem">
|
||||||
|
<button class="btn btn-primary btn-sm" id="devpod-start-btn" onclick="devpodAction('start')" ${startDisabled ? 'disabled' : ''}>Start</button>
|
||||||
|
<button class="btn btn-slate btn-sm" id="devpod-restart-btn" onclick="devpodAction('restart')" ${restartDisabled ? 'disabled' : ''}>Restart</button>
|
||||||
|
<button class="btn btn-danger btn-sm" id="devpod-stop-btn" onclick="devpodAction('stop')" ${stopDisabled ? 'disabled' : ''}>Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:2rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">State </span>
|
||||||
|
${stateBadge}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">Uptime </span>
|
||||||
|
<span style="color:#e6edf3;font-size:0.845rem;font-weight:500">${uptime}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">Started </span>
|
||||||
|
<span style="color:#8b949e;font-size:0.845rem">${startedAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${podsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="card-title">Details</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Replicas</span>
|
||||||
|
<span class="stat-value">${readyReplicas} / ${replicas}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Image</span>
|
||||||
|
<span class="stat-value" style="font-family:monospace;font-size:0.78rem;word-break:break-all">${image || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Namespace</span>
|
||||||
|
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">dev</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Deployment</span>
|
||||||
|
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">dev</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Auto-refresh while starting
|
||||||
|
clearInterval(devpodRefreshTimer);
|
||||||
|
if (state === 'starting') {
|
||||||
|
devpodRefreshTimer = setInterval(loadDevPod, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function devpodAction(action) {
|
||||||
|
const btnId = `devpod-${action}-btn`;
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const origText = btn?.textContent;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Restarting…'; }
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/devpod/${action}`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(d.message ?? `${action} failed`);
|
||||||
|
}
|
||||||
|
// Wait a moment then refresh status
|
||||||
|
setTimeout(loadDevPod, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Dev pod ${action} failed: ${err.message}`);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = origText; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account ───────────────────────────────────────────────────────────────
|
// ── Account ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { config } from "./lib/config";
|
|||||||
import { migrate } from "./db/manager";
|
import { migrate } from "./db/manager";
|
||||||
import { authRoutes } from "./routes/auth";
|
import { authRoutes } from "./routes/auth";
|
||||||
import { customerRoutes } from "./routes/customers";
|
import { customerRoutes } from "./routes/customers";
|
||||||
|
import { devpodRoutes } from "./routes/devpod";
|
||||||
import { startSizeCollector } from "./services/sizeCollector";
|
import { startSizeCollector } from "./services/sizeCollector";
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
@@ -42,6 +43,11 @@ app.register(customerRoutes, {
|
|||||||
onRequest: [app.authenticate],
|
onRequest: [app.authenticate],
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
app.register(devpodRoutes, {
|
||||||
|
prefix: "/api",
|
||||||
|
onRequest: [app.authenticate],
|
||||||
|
} as any);
|
||||||
|
|
||||||
await migrate();
|
await migrate();
|
||||||
startSizeCollector(app.log);
|
startSizeCollector(app.log);
|
||||||
|
|
||||||
|
|||||||
63
src/routes/devpod.ts
Normal file
63
src/routes/devpod.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { k8sFetch, rolloutRestart } from "../lib/k8s";
|
||||||
|
|
||||||
|
const NAMESPACE = "dev";
|
||||||
|
const DEPLOYMENT = "dev";
|
||||||
|
|
||||||
|
export async function devpodRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/devpod/status", async (_req, reply) => {
|
||||||
|
const [deployRes, podsRes] = await Promise.allSettled([
|
||||||
|
k8sFetch(`/apis/apps/v1/namespaces/${NAMESPACE}/deployments/${DEPLOYMENT}`).then(r => r.json()),
|
||||||
|
k8sFetch(`/api/v1/namespaces/${NAMESPACE}/pods?labelSelector=app=${DEPLOYMENT}`).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deploy = deployRes.status === "fulfilled" ? deployRes.value : null;
|
||||||
|
const podsRaw = podsRes.status === "fulfilled" ? podsRes.value : null;
|
||||||
|
|
||||||
|
const replicas = deploy?.spec?.replicas ?? 0;
|
||||||
|
const readyReplicas = deploy?.status?.readyReplicas ?? 0;
|
||||||
|
const image = deploy?.spec?.template?.spec?.containers?.[0]?.image ?? null;
|
||||||
|
|
||||||
|
const pods = (podsRaw?.items ?? []).map((pod: any) => ({
|
||||||
|
name: pod.metadata.name,
|
||||||
|
ready: (pod.status?.containerStatuses ?? []).every((c: any) => c.ready),
|
||||||
|
readyCount: (pod.status?.containerStatuses ?? []).filter((c: any) => c.ready).length,
|
||||||
|
totalCount: (pod.status?.containerStatuses ?? []).length,
|
||||||
|
status: pod.status?.phase ?? "Unknown",
|
||||||
|
restarts: (pod.status?.containerStatuses ?? []).reduce((s: number, c: any) => s + (c.restartCount ?? 0), 0),
|
||||||
|
startedAt: pod.status?.startTime ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Determine overall state
|
||||||
|
let state: "stopped" | "running" | "starting" | "error";
|
||||||
|
if (replicas === 0) state = "stopped";
|
||||||
|
else if (readyReplicas > 0 && pods.some((p: any) => p.ready)) state = "running";
|
||||||
|
else if (pods.some((p: any) => p.status === "Failed")) state = "error";
|
||||||
|
else state = "starting";
|
||||||
|
|
||||||
|
return reply.send({ state, replicas, readyReplicas, image, pods });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/devpod/start", async (_req, reply) => {
|
||||||
|
await k8sFetch(`/apis/apps/v1/namespaces/${NAMESPACE}/deployments/${DEPLOYMENT}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||||
|
body: JSON.stringify({ spec: { replicas: 1 } }),
|
||||||
|
});
|
||||||
|
return reply.send({ message: "Dev pod starting" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/devpod/stop", async (_req, reply) => {
|
||||||
|
await k8sFetch(`/apis/apps/v1/namespaces/${NAMESPACE}/deployments/${DEPLOYMENT}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||||
|
body: JSON.stringify({ spec: { replicas: 0 } }),
|
||||||
|
});
|
||||||
|
return reply.send({ message: "Dev pod stopped" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/devpod/restart", async (_req, reply) => {
|
||||||
|
await rolloutRestart(NAMESPACE, DEPLOYMENT);
|
||||||
|
return reply.send({ message: "Dev pod restarting" });
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user