feat: single shared Spaces key, deactivate/reactivate customer, status badge for inactive
Some checks failed
Build & Release / build (push) Has been cancelled
Some checks failed
Build & Release / build (push) Has been cancelled
This commit is contained in:
@@ -345,6 +345,8 @@
|
|||||||
<!-- Kebab dropdown (shared, repositioned via JS) -->
|
<!-- Kebab dropdown (shared, repositioned via JS) -->
|
||||||
<div id="kebab-menu">
|
<div id="kebab-menu">
|
||||||
<div class="kebab-item" onclick="kebabAction('view')">View Details</div>
|
<div class="kebab-item" onclick="kebabAction('view')">View Details</div>
|
||||||
|
<div class="kebab-item" id="kebab-deactivate" onclick="kebabAction('deactivate')">Deactivate</div>
|
||||||
|
<div class="kebab-item" id="kebab-reactivate" onclick="kebabAction('reactivate')" style="display:none">Reactivate</div>
|
||||||
<div class="kebab-item danger" onclick="kebabAction('delete')">Delete</div>
|
<div class="kebab-item danger" onclick="kebabAction('delete')">Delete</div>
|
||||||
<div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div>
|
<div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,6 +468,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function customerStatusBadge(r) {
|
function customerStatusBadge(r) {
|
||||||
|
if (r.status === 'inactive') return '<span class="badge badge-gray">Inactive</span>';
|
||||||
if (r.status === 'provisioning') return '<span class="badge badge-yellow">Provisioning</span>';
|
if (r.status === 'provisioning') return '<span class="badge badge-yellow">Provisioning</span>';
|
||||||
if (r.status === 'failed') {
|
if (r.status === 'failed') {
|
||||||
const failedStep = Object.entries(r.steps || {}).find(([,v]) => v === 'failed');
|
const failedStep = Object.entries(r.steps || {}).find(([,v]) => v === 'failed');
|
||||||
@@ -500,7 +503,7 @@
|
|||||||
<td>${expiry}</td>
|
<td>${expiry}</td>
|
||||||
<td style="color:#8b949e">${fmtDateTime(r.created_at)}</td>
|
<td style="color:#8b949e">${fmtDateTime(r.created_at)}</td>
|
||||||
<td style="color:#8b949e">${fmtDateTime(r.updated_at)}</td>
|
<td style="color:#8b949e">${fmtDateTime(r.updated_at)}</td>
|
||||||
<td><button class="kebab-btn" onclick="openKebab(event,'${r.slug}')">⋮</button></td>
|
<td><button class="kebab-btn" onclick="openKebab(event,'${r.slug}','${r.status}')">⋮</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -552,9 +555,12 @@
|
|||||||
|
|
||||||
// ── Kebab menu ────────────────────────────────────────────────────────────
|
// ── Kebab menu ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openKebab(event, slug) {
|
function openKebab(event, slug, status) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
kebabSlug = slug;
|
kebabSlug = slug;
|
||||||
|
// Show deactivate or reactivate depending on status
|
||||||
|
document.getElementById('kebab-deactivate').style.display = status === 'inactive' ? 'none' : '';
|
||||||
|
document.getElementById('kebab-reactivate').style.display = status === 'inactive' ? '' : 'none';
|
||||||
const menu = document.getElementById('kebab-menu');
|
const menu = document.getElementById('kebab-menu');
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
menu.style.top = (rect.bottom + 4) + 'px';
|
menu.style.top = (rect.bottom + 4) + 'px';
|
||||||
@@ -572,6 +578,8 @@
|
|||||||
closeKebab();
|
closeKebab();
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
if (action === 'view') openDetail(slug);
|
if (action === 'view') openDetail(slug);
|
||||||
|
else if (action === 'deactivate') deactivate(slug);
|
||||||
|
else if (action === 'reactivate') reactivate(slug);
|
||||||
else if (action === 'delete') openDeleteDialog(slug);
|
else if (action === 'delete') openDeleteDialog(slug);
|
||||||
else if (action === 'record') removeRecord(slug);
|
else if (action === 'record') removeRecord(slug);
|
||||||
}
|
}
|
||||||
@@ -758,6 +766,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deactivate(slug) {
|
||||||
|
if (!confirm(`Deactivate "${slug}"?\n\nThis removes the deployment but keeps the database and storage. You can reactivate later.`)) return;
|
||||||
|
const res = await apiFetch(`/api/customers/${slug}/deactivate`, { method: 'POST' });
|
||||||
|
if (res.ok) { loadCustomers(); if (currentDetailSlug === slug) loadDetail(slug, false); }
|
||||||
|
else { const d = await res.json().catch(() => ({})); alert(d.message ?? 'Failed'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reactivate(slug) {
|
||||||
|
if (!confirm(`Reactivate "${slug}"?\n\nThis will redeploy the application.`)) return;
|
||||||
|
const res = await apiFetch(`/api/customers/${slug}/reactivate`, { method: 'POST' });
|
||||||
|
if (res.ok) { loadCustomers(); if (currentDetailSlug === slug) loadDetail(slug, false); }
|
||||||
|
else { const d = await res.json().catch(() => ({})); alert(d.message ?? 'Failed'); }
|
||||||
|
}
|
||||||
|
|
||||||
async function removeRecord(slug) {
|
async function removeRecord(slug) {
|
||||||
if (!confirm(`Remove "${slug}" from the manager database only?\n\nThis will NOT delete the ArgoCD app or DO database.`)) return;
|
if (!confirm(`Remove "${slug}" from the manager database only?\n\nThis will NOT delete the ArgoCD app or DO database.`)) return;
|
||||||
const res = await apiFetch(`/api/customers/${slug}/record`, { method: 'DELETE' });
|
const res = await apiFetch(`/api/customers/${slug}/record`, { method: 'DELETE' });
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const config = {
|
|||||||
managedValkeyUrl: process.env.MANAGED_VALKEY_URL!,
|
managedValkeyUrl: process.env.MANAGED_VALKEY_URL!,
|
||||||
spacesBucket: process.env.SPACES_BUCKET ?? "lunarfront-data",
|
spacesBucket: process.env.SPACES_BUCKET ?? "lunarfront-data",
|
||||||
spacesRegion: process.env.SPACES_REGION ?? "nyc3",
|
spacesRegion: process.env.SPACES_REGION ?? "nyc3",
|
||||||
|
spacesKey: process.env.SPACES_KEY!,
|
||||||
|
spacesSecret: process.env.SPACES_SECRET!,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(config)) {
|
for (const [key, val] of Object.entries(config)) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer
|
|||||||
import { addCustomerChart, removeCustomerChart } from "../services/git";
|
import { addCustomerChart, removeCustomerChart } from "../services/git";
|
||||||
import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db";
|
import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db";
|
||||||
import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s";
|
import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s";
|
||||||
import { createSpacesKey, deleteSpacesKey, deleteSpacesObjects, getSpacesUsage } from "../services/spaces";
|
import { deleteSpacesObjects, getSpacesUsage } from "../services/spaces";
|
||||||
import { db } from "../db/manager";
|
import { db } from "../db/manager";
|
||||||
import { config } from "../lib/config";
|
import { config } from "../lib/config";
|
||||||
import { getCachedStatus, setCachedStatus } from "../lib/cache";
|
import { getCachedStatus, setCachedStatus } from "../lib/cache";
|
||||||
@@ -126,18 +126,13 @@ export async function customerRoutes(app: FastifyInstance) {
|
|||||||
await setStep("namespace", "done");
|
await setStep("namespace", "done");
|
||||||
await setStep("secrets", "done");
|
await setStep("secrets", "done");
|
||||||
|
|
||||||
const { accessKey: spacesKey, secretKey: spacesSecret } = await createSpacesKey(
|
|
||||||
`customer-${slug}`,
|
|
||||||
config.spacesBucket,
|
|
||||||
);
|
|
||||||
await patchSecret(namespace, "lunarfront-secrets", {
|
await patchSecret(namespace, "lunarfront-secrets", {
|
||||||
"spaces-key": spacesKey,
|
"spaces-key": config.spacesKey,
|
||||||
"spaces-secret": spacesSecret,
|
"spaces-secret": config.spacesSecret,
|
||||||
"spaces-bucket": config.spacesBucket,
|
"spaces-bucket": config.spacesBucket,
|
||||||
"spaces-endpoint": `https://${config.spacesRegion}.digitaloceanspaces.com`,
|
"spaces-endpoint": `https://${config.spacesRegion}.digitaloceanspaces.com`,
|
||||||
"spaces-prefix": `${slug}/`,
|
"spaces-prefix": `${slug}/`,
|
||||||
});
|
});
|
||||||
await db`UPDATE customers SET spaces_key = ${spacesKey}, updated_at = NOW() WHERE slug = ${slug}`;
|
|
||||||
await setStep("storage", "done");
|
await setStep("storage", "done");
|
||||||
|
|
||||||
addCustomerChart(slug, body.appVersion);
|
addCustomerChart(slug, body.appVersion);
|
||||||
@@ -162,19 +157,14 @@ export async function customerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.log.info({ slug }, "deprovisioning customer");
|
app.log.info({ slug }, "deprovisioning customer");
|
||||||
|
|
||||||
const [customer] = await db`SELECT spaces_key FROM customers WHERE slug = ${slug}`;
|
|
||||||
|
|
||||||
removeCustomerChart(slug);
|
removeCustomerChart(slug);
|
||||||
await removeCustomerFromPool(slug);
|
await removeCustomerFromPool(slug);
|
||||||
await teardownCustomerDatabase(slug, slug);
|
await teardownCustomerDatabase(slug, slug);
|
||||||
|
|
||||||
if (customer?.spaces_key) {
|
// Delete all objects under this customer's prefix in Spaces
|
||||||
try {
|
try {
|
||||||
const secrets = await getSecret(namespace, "lunarfront-secrets");
|
await deleteSpacesObjects(config.spacesKey, config.spacesSecret, config.spacesBucket, config.spacesRegion, `${slug}/`);
|
||||||
await deleteSpacesObjects(customer.spaces_key, secrets["spaces-secret"], config.spacesBucket, config.spacesRegion, `${slug}/`);
|
|
||||||
} catch {}
|
} catch {}
|
||||||
try { await deleteSpacesKey(customer.spaces_key); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
deleteDatabase(slug),
|
deleteDatabase(slug),
|
||||||
@@ -247,6 +237,35 @@ export async function customerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Remove only the manager DB record without touching infrastructure —
|
// Remove only the manager DB record without touching infrastructure —
|
||||||
// useful for cleaning up failed partial deployments
|
// useful for cleaning up failed partial deployments
|
||||||
|
// Deactivate: remove ArgoCD chart (stops pods) but keep DB, namespace, secrets, and manager record
|
||||||
|
app.post("/customers/:slug/deactivate", async (req, reply) => {
|
||||||
|
const { slug } = req.params as { slug: string };
|
||||||
|
removeCustomerChart(slug);
|
||||||
|
await removeCustomerFromPool(slug);
|
||||||
|
await db`UPDATE customers SET status = 'inactive', updated_at = NOW() WHERE slug = ${slug}`;
|
||||||
|
app.log.info({ slug }, "customer deactivated");
|
||||||
|
return reply.code(200).send({ slug, status: "inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reactivate: push ArgoCD chart back, re-add to pool
|
||||||
|
app.post("/customers/:slug/reactivate", async (req, reply) => {
|
||||||
|
const { slug } = req.params as { slug: string };
|
||||||
|
const [customer] = await db`SELECT * FROM customers WHERE slug = ${slug}`;
|
||||||
|
if (!customer) return reply.code(404).send({ message: "Not found" });
|
||||||
|
|
||||||
|
// Re-add to pgbouncer — need the password from the k8s secret
|
||||||
|
const namespace = `customer-${slug}`;
|
||||||
|
const secrets = await getSecret(namespace, "lunarfront-secrets");
|
||||||
|
const dbUrl = new URL(secrets["database-url"]);
|
||||||
|
await addCustomerToPool(slug, dbUrl.password);
|
||||||
|
|
||||||
|
addCustomerChart(slug, "*");
|
||||||
|
await db`UPDATE customers SET status = 'provisioned', updated_at = NOW() WHERE slug = ${slug}`;
|
||||||
|
app.log.info({ slug }, "customer reactivated");
|
||||||
|
return reply.code(200).send({ slug, status: "provisioned" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove only the manager DB record without touching infrastructure
|
||||||
app.delete("/customers/:slug/record", async (req, reply) => {
|
app.delete("/customers/:slug/record", async (req, reply) => {
|
||||||
const { slug } = req.params as { slug: string };
|
const { slug } = req.params as { slug: string };
|
||||||
await db`DELETE FROM customers WHERE slug = ${slug}`;
|
await db`DELETE FROM customers WHERE slug = ${slug}`;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { db } from "../db/manager";
|
import { db } from "../db/manager";
|
||||||
import { config } from "../lib/config";
|
import { config } from "../lib/config";
|
||||||
import { getSecret } from "../lib/k8s";
|
|
||||||
import { getSpacesUsage } from "./spaces";
|
import { getSpacesUsage } from "./spaces";
|
||||||
|
|
||||||
async function collectSizes() {
|
async function collectSizes() {
|
||||||
const customers = await db`SELECT slug, spaces_key FROM customers WHERE status = 'provisioned'`;
|
const customers = await db`SELECT slug FROM customers WHERE status = 'provisioned'`;
|
||||||
if (customers.length === 0) return;
|
if (customers.length === 0) return;
|
||||||
|
|
||||||
for (const customer of customers) {
|
for (const customer of customers) {
|
||||||
@@ -26,13 +25,10 @@ async function collectSizes() {
|
|||||||
// Spaces size
|
// Spaces size
|
||||||
let spacesSizeBytes: number | null = null;
|
let spacesSizeBytes: number | null = null;
|
||||||
let spacesObjectCount: number | null = null;
|
let spacesObjectCount: number | null = null;
|
||||||
if (customer.spaces_key) {
|
|
||||||
try {
|
try {
|
||||||
const namespace = `customer-${slug}`;
|
|
||||||
const secrets = await getSecret(namespace, "lunarfront-secrets");
|
|
||||||
const result = await getSpacesUsage(
|
const result = await getSpacesUsage(
|
||||||
customer.spaces_key,
|
config.spacesKey,
|
||||||
secrets["spaces-secret"],
|
config.spacesSecret,
|
||||||
config.spacesBucket,
|
config.spacesBucket,
|
||||||
config.spacesRegion,
|
config.spacesRegion,
|
||||||
`${slug}/`,
|
`${slug}/`,
|
||||||
@@ -40,7 +36,6 @@ async function collectSizes() {
|
|||||||
spacesSizeBytes = result.sizeBytes;
|
spacesSizeBytes = result.sizeBytes;
|
||||||
spacesObjectCount = result.objectCount;
|
spacesObjectCount = result.objectCount;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert today's snapshot (one row per day per customer)
|
// Upsert today's snapshot (one row per day per customer)
|
||||||
await db`
|
await db`
|
||||||
|
|||||||
Reference in New Issue
Block a user