/** * — Discourse instance provisioner dashboard. * * Lists user's forum instances, shows provisioning status, and allows * creating new instances. */ import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; class FolkForumDashboard extends HTMLElement { private shadow: ShadowRoot; private instances: any[] = []; private selectedInstance: any = null; private selectedLogs: any[] = []; private view: "list" | "detail" | "create" = "list"; private loading = false; private pollTimer: number | null = null; private space = ""; private _offlineUnsub: (() => void) | null = null; private _history = new ViewHistory<"list" | "detail" | "create">("list"); private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '.instance-card', title: "Forum Instances", message: "View your deployed Discourse forums — click one for status and logs.", advanceOnClick: false }, { target: '[data-action="show-create"]', title: "New Forum", message: "Deploy a new self-hosted Discourse forum in minutes.", advanceOnClick: true }, { target: '.price-card', title: "Choose a Plan", message: "Select a hosting tier based on your community size and needs.", advanceOnClick: false }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this._tour = new TourEngine( this.shadow, FolkForumDashboard.TOUR_STEPS, "rforum_tour_done", () => this.shadow.host as HTMLElement, ); } connectedCallback() { this.space = this.getAttribute("space") || ""; if (this.space === "demo") { this.loadDemoData(); } else { this.subscribeOffline(); this.render(); this.loadInstances(); } if (!localStorage.getItem("rforum_tour_done")) { setTimeout(() => this._tour.start(), 1200); } } private loadDemoData() { this.instances = [ { id: "1", name: "Commons Hub", domain: "commons.rforum.online", status: "active", region: "nbg1", size: "cx22", adminEmail: "admin@commons.example", vpsIp: "116.203.x.x", sslProvisioned: true }, { id: "2", name: "Design Guild", domain: "design.rforum.online", status: "provisioning", region: "fsn1", size: "cx22", adminEmail: "admin@design.example", vpsIp: "168.119.x.x", sslProvisioned: false }, { id: "3", name: "Archive Project", domain: "archive.rforum.online", status: "destroyed", region: "hel1", size: "cx22", adminEmail: "admin@archive.example", vpsIp: null, sslProvisioned: false }, ]; this.loading = false; this.render(); } disconnectedCallback() { if (this.pollTimer) clearInterval(this.pollTimer); this._offlineUnsub?.(); this._offlineUnsub = null; } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { // Forum uses a global doc (not space-scoped) const docId = FORUM_DOC_ID as DocumentId; const doc = await runtime.subscribe(docId, forumSchema); if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) { this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({ id: inst.id, name: inst.name, domain: inst.domain, status: inst.status, region: inst.region, size: inst.size, adminEmail: inst.adminEmail, vpsIp: inst.vpsIp, sslProvisioned: inst.sslProvisioned, })); this.render(); } this._offlineUnsub = runtime.onChange(docId, (updated: ForumDoc) => { if (updated?.instances) { this.instances = Object.values(updated.instances).map(inst => ({ id: inst.id, name: inst.name, domain: inst.domain, status: inst.status, region: inst.region, size: inst.size, adminEmail: inst.adminEmail, vpsIp: inst.vpsIp, sslProvisioned: inst.sslProvisioned, })); this.render(); } }); } catch { /* runtime unavailable */ } } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rforum/); return match ? match[0] : ""; } private getAuthHeaders(): Record { const token = localStorage.getItem("encryptid_session"); if (token) { try { const parsed = JSON.parse(token); return { "X-User-DID": parsed.did || "" }; } catch {} } return {}; } private async loadInstances() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances`, { headers: this.getAuthHeaders() }); if (res.ok) { const data = await res.json(); this.instances = data.instances || []; } } catch (e) { console.error("[ForumDashboard] Error:", e); } this.loading = false; this.render(); } private async loadInstanceDetail(id: string) { if (this.space === "demo") { this.selectedInstance = this.instances.find(i => i.id === id); const demoLogs: Record = { "1": [ { step: "create_vps", status: "success", message: "Server created in nbg1" }, { step: "wait_ready", status: "success", message: "Server booted and SSH ready" }, { step: "configure_dns", status: "success", message: "DNS record set for commons.rforum.online" }, { step: "install_discourse", status: "success", message: "Discourse installed and configured" }, { step: "verify_live", status: "success", message: "Forum responding at https://commons.rforum.online" }, ], "2": [ { step: "create_vps", status: "success", message: "Server created in fsn1" }, { step: "wait_ready", status: "success", message: "Server booted and SSH ready" }, { step: "configure_dns", status: "running", message: "Configuring DNS for design.rforum.online..." }, { step: "install_discourse", status: "pending", message: "" }, { step: "verify_live", status: "pending", message: "" }, ], "3": [ { step: "create_vps", status: "success", message: "Server created in hel1" }, { step: "wait_ready", status: "success", message: "Server booted and SSH ready" }, { step: "configure_dns", status: "success", message: "DNS record set for archive.rforum.online" }, { step: "install_discourse", status: "success", message: "Discourse installed and configured" }, { step: "verify_live", status: "success", message: "Forum verified live before destruction" }, ], }; this.selectedLogs = demoLogs[id] || []; this.view = "detail"; this.render(); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() }); if (res.ok) { const data = await res.json(); this.selectedInstance = data.instance; this.selectedLogs = data.logs || []; this.view = "detail"; this.render(); // Poll if provisioning const active = ["pending", "provisioning", "installing", "configuring"]; if (active.includes(this.selectedInstance.status)) { if (this.pollTimer) clearInterval(this.pollTimer); this.pollTimer = setInterval(() => this.loadInstanceDetail(id), 5000) as any; } else { if (this.pollTimer) clearInterval(this.pollTimer); } } } catch {} } private async handleCreate(e: Event) { e.preventDefault(); if (this.space === "demo") { alert("Create is disabled in demo mode."); return; } const form = this.shadow.querySelector("#create-form") as HTMLFormElement; if (!form) return; const name = (form.querySelector('[name="name"]') as HTMLInputElement)?.value; const subdomain = (form.querySelector('[name="subdomain"]') as HTMLInputElement)?.value; const adminEmail = (form.querySelector('[name="admin_email"]') as HTMLInputElement)?.value; const region = (form.querySelector('[name="region"]') as HTMLSelectElement)?.value; const size = (form.querySelector('[name="size"]') as HTMLSelectElement)?.value; if (!name || !subdomain || !adminEmail) return; try { const base = this.getApiBase(); const res = await fetch(`${base}/api/instances`, { method: "POST", headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, body: JSON.stringify({ name, subdomain, admin_email: adminEmail, region, size }), }); if (res.ok) { const data = await res.json(); this.view = "detail"; this.loadInstanceDetail(data.instance.id); } else { const err = await res.json(); alert(err.error || "Failed to create instance"); } } catch { alert("Network error"); } } private async handleDestroy(id: string) { if (this.space === "demo") { alert("Destroy is disabled in demo mode."); return; } if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return; try { const base = this.getApiBase(); await fetch(`${base}/api/instances/${id}`, { method: "DELETE", headers: this.getAuthHeaders(), }); this.view = "list"; this.loadInstances(); } catch {} } private statusBadge(status: string): string { const colors: Record = { pending: "#fbbf24", provisioning: "#4f46e5", installing: "#4f46e5", configuring: "#4f46e5", active: "#22c55e", error: "#ef4444", destroying: "#fbbf24", destroyed: "#64748b", }; const color = colors[status] || "#64748b"; const pulse = ["provisioning", "installing", "configuring"].includes(status) ? "animation: pulse 1.5s ease-in-out infinite;" : ""; return `${status}`; } private logStepIcon(status: string): string { if (status === "success") return "✅"; if (status === "error") return "❌"; if (status === "running") return "⏳"; return "⏭️"; } private render() { this.shadow.innerHTML = ` ${this.view === "list" ? this.renderList() : ""} ${this.view === "detail" ? this.renderDetail() : ""} ${this.view === "create" ? this.renderCreate() : ""} `; this.attachEvents(); this._tour.renderOverlay(); } startTour() { this._tour.start(); } private renderList(): string { return `
Forum Instances
${this.loading ? '
Loading...
' : ""} ${!this.loading && this.instances.length === 0 ? '
No forum instances yet. Deploy your first Discourse forum!
' : ""}
${this.instances.map((inst) => `
${this.esc(inst.name)} ${this.statusBadge(inst.status)}
${inst.domain} · ${inst.region} · ${inst.size} ${inst.vpsIp ? ` · ${inst.vpsIp}` : ""}
`).join("")}
`; } private renderDetail(): string { const inst = this.selectedInstance; if (!inst) return ""; return `
${this._history.canGoBack ? '' : ''} ${this.esc(inst.name)} ${inst.status !== "destroyed" ? `` : ""}
${this.esc(inst.name)}
${this.statusBadge(inst.status)}
${inst.status === "active" ? `↗ Open Forum` : ""}
${inst.errorMessage ? `
${this.esc(inst.errorMessage)}
` : ""}
${inst.domain}
${inst.vpsIp || "—"}
${inst.region}
${inst.size}
${inst.adminEmail || "—"}
${inst.sslProvisioned ? "✅ Active" : "⏳ Pending"}

Provision Log

${this.selectedLogs.length === 0 ? '
No logs yet
' : ""} ${this.selectedLogs.map((log) => `
${this.logStepIcon(log.status)}
${this.formatStep(log.step)}
${this.esc(log.message || "")}
`).join("")}
`; } private renderCreate(): string { return `
${this._history.canGoBack ? '' : ''} Deploy New Forum

Deploy New Forum

Starter
€3.79/mo
2 vCPU · 4 GB · ~500 users
Standard
€6.80/mo
4 vCPU · 8 GB · ~2000 users
Performance
€13.80/mo
8 vCPU · 16 GB · ~10k users
.rforum.online
`; } private attachEvents() { this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.querySelectorAll("[data-action]").forEach((el) => { const action = (el as HTMLElement).dataset.action!; const id = (el as HTMLElement).dataset.id; el.addEventListener("click", () => { if (action === "show-create") { this._history.push(this.view); this._history.push("create"); this.view = "create"; this.render(); } else if (action === "back") { this.goBack(); } else if (action === "detail" && id) { this._history.push(this.view); this._history.push("detail", { id }); this.loadInstanceDetail(id); } else if (action === "destroy" && id) { this.handleDestroy(id); } }); }); this.shadow.querySelectorAll(".price-card").forEach((card) => { card.addEventListener("click", () => { this.shadow.querySelectorAll(".price-card").forEach((c) => c.classList.remove("selected")); card.classList.add("selected"); const sizeInput = this.shadow.querySelector('[name="size"]') as HTMLInputElement; if (sizeInput) sizeInput.value = (card as HTMLElement).dataset.size || "cx22"; }); }); const form = this.shadow.querySelector("#create-form"); if (form) form.addEventListener("submit", (e) => this.handleCreate(e)); } private goBack() { if (this.pollTimer) clearInterval(this.pollTimer); const prev = this._history.back(); if (!prev) return; this.view = prev.view; if (prev.view === "list") this.loadInstances(); else this.render(); } private formatStep(step: string): string { const labels: Record = { create_vps: "Create Server", wait_ready: "Wait for Boot", configure_dns: "Configure DNS", install_discourse: "Install Discourse", verify_live: "Verify Live", }; return labels[step] || step; } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-forum-dashboard", FolkForumDashboard);