rspace-online/modules/forum/components/folk-forum-dashboard.ts

420 lines
15 KiB
TypeScript

/**
* <folk-forum-dashboard> — Discourse instance provisioner dashboard.
*
* Lists user's forum instances, shows provisioning status, and allows
* creating new instances.
*/
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;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.loadInstances();
}
disconnectedCallback() {
if (this.pollTimer) clearInterval(this.pollTimer);
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/forum/);
return match ? `/${match[1]}/forum` : "";
}
private getAuthHeaders(): Record<string, string> {
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) {
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();
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 (!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<string, string> = {
pending: "#ffa726",
provisioning: "#42a5f5",
installing: "#42a5f5",
configuring: "#42a5f5",
active: "#66bb6a",
error: "#ef5350",
destroying: "#ffa726",
destroyed: "#888",
};
const color = colors[status] || "#888";
const pulse = ["provisioning", "installing", "configuring"].includes(status)
? "animation: pulse 1.5s ease-in-out infinite;"
: "";
return `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${color}22;color:${color};border:1px solid ${color}44;${pulse}">${status}</span>`;
}
private logStepIcon(status: string): string {
if (status === "success") return "\u2705";
if (status === "error") return "\u274C";
if (status === "running") return "\u23F3";
return "\u23ED\uFE0F";
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
button {
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
background: #2a4a7a; color: #e0e0e0; cursor: pointer; font-size: 13px;
}
button:hover { background: #3a5a9a; }
button.danger { background: #7a2a2a; }
button.danger:hover { background: #9a3a3a; }
button.secondary { background: #333; }
input, select {
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
padding: 8px 12px; border-radius: 4px; font-size: 13px; width: 100%;
}
label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; }
.form-group { margin-bottom: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.instance-list { display: flex; flex-direction: column; gap: 10px; }
.instance-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.instance-card:hover { border-color: #64b5f6; }
.instance-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.instance-name { font-size: 16px; font-weight: 600; }
.instance-meta { font-size: 12px; color: #888; }
.detail-panel { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 20px; }
.detail-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; }
.detail-title { font-size: 20px; font-weight: 600; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
.detail-item label { font-size: 11px; color: #888; text-transform: uppercase; }
.detail-item .value { font-size: 14px; margin-top: 2px; }
.logs-section h3 { font-size: 14px; color: #aaa; margin: 0 0 12px; }
.log-entry { display: flex; gap: 10px; align-items: start; padding: 8px 0; border-bottom: 1px solid #2a2a3e; }
.log-icon { font-size: 16px; flex-shrink: 0; }
.log-step { font-size: 13px; font-weight: 500; }
.log-msg { font-size: 12px; color: #888; margin-top: 2px; }
.empty { text-align: center; color: #666; padding: 40px 20px; }
.loading { text-align: center; color: #888; padding: 40px; }
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.price-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 14px;
text-align: center; cursor: pointer; transition: border-color 0.2s;
}
.price-card:hover, .price-card.selected { border-color: #64b5f6; }
.price-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
.price-cost { font-size: 18px; color: #64b5f6; font-weight: 700; }
.price-specs { font-size: 11px; color: #888; margin-top: 4px; }
</style>
${this.view === "list" ? this.renderList() : ""}
${this.view === "detail" ? this.renderDetail() : ""}
${this.view === "create" ? this.renderCreate() : ""}
`;
this.attachEvents();
}
private renderList(): string {
return `
<div class="toolbar">
<h2 style="margin:0;font-size:18px">\uD83D\uDCAC Forum Instances</h2>
<button data-action="show-create">+ New Forum</button>
</div>
${this.loading ? '<div class="loading">Loading...</div>' : ""}
${!this.loading && this.instances.length === 0 ? '<div class="empty">No forum instances yet. Deploy your first Discourse forum!</div>' : ""}
<div class="instance-list">
${this.instances.map((inst) => `
<div class="instance-card" data-action="detail" data-id="${inst.id}">
<div class="instance-header">
<span class="instance-name">${this.esc(inst.name)}</span>
${this.statusBadge(inst.status)}
</div>
<div class="instance-meta">
${inst.domain} &middot; ${inst.region} &middot; ${inst.size}
${inst.vps_ip ? ` &middot; ${inst.vps_ip}` : ""}
</div>
</div>
`).join("")}
</div>
`;
}
private renderDetail(): string {
const inst = this.selectedInstance;
if (!inst) return "";
return `
<div class="toolbar">
<button class="secondary" data-action="back">\u2190 Back</button>
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
</div>
<div class="detail-panel">
<div class="detail-header">
<div>
<div class="detail-title">${this.esc(inst.name)}</div>
<div style="margin-top:4px">${this.statusBadge(inst.status)}</div>
</div>
${inst.status === "active" ? `<a href="https://${inst.domain}" target="_blank" style="color:#64b5f6;font-size:13px">\u2197 Open Forum</a>` : ""}
</div>
${inst.error_message ? `<div style="background:#7a2a2a33;border:1px solid #7a2a2a;padding:10px;border-radius:6px;margin-bottom:16px;font-size:13px;color:#ef5350">${this.esc(inst.error_message)}</div>` : ""}
<div class="detail-grid">
<div class="detail-item"><label>Domain</label><div class="value">${inst.domain}</div></div>
<div class="detail-item"><label>IP Address</label><div class="value">${inst.vps_ip || "—"}</div></div>
<div class="detail-item"><label>Region</label><div class="value">${inst.region}</div></div>
<div class="detail-item"><label>Server Size</label><div class="value">${inst.size}</div></div>
<div class="detail-item"><label>Admin Email</label><div class="value">${inst.admin_email || "—"}</div></div>
<div class="detail-item"><label>SSL</label><div class="value">${inst.ssl_provisioned ? "\u2705 Active" : "\u23F3 Pending"}</div></div>
</div>
<div class="logs-section">
<h3>Provision Log</h3>
${this.selectedLogs.length === 0 ? '<div style="color:#666;font-size:13px">No logs yet</div>' : ""}
${this.selectedLogs.map((log) => `
<div class="log-entry">
<span class="log-icon">${this.logStepIcon(log.status)}</span>
<div>
<div class="log-step">${this.formatStep(log.step)}</div>
<div class="log-msg">${this.esc(log.message || "")}</div>
</div>
</div>
`).join("")}
</div>
</div>
`;
}
private renderCreate(): string {
return `
<div class="toolbar">
<button class="secondary" data-action="back">\u2190 Back</button>
</div>
<div class="detail-panel">
<h2 style="margin:0 0 16px;font-size:18px">Deploy New Forum</h2>
<div class="pricing">
<div class="price-card selected" data-size="cx22">
<div class="price-name">Starter</div>
<div class="price-cost">\u20AC3.79/mo</div>
<div class="price-specs">2 vCPU &middot; 4 GB &middot; ~500 users</div>
</div>
<div class="price-card" data-size="cx32">
<div class="price-name">Standard</div>
<div class="price-cost">\u20AC6.80/mo</div>
<div class="price-specs">4 vCPU &middot; 8 GB &middot; ~2000 users</div>
</div>
<div class="price-card" data-size="cx42">
<div class="price-name">Performance</div>
<div class="price-cost">\u20AC13.80/mo</div>
<div class="price-specs">8 vCPU &middot; 16 GB &middot; ~10k users</div>
</div>
</div>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label>Forum Name</label>
<input name="name" placeholder="My Community" required>
</div>
<div class="form-group">
<label>Subdomain</label>
<div style="display:flex;align-items:center;gap:4px">
<input name="subdomain" placeholder="my-community" required style="flex:1">
<span style="font-size:12px;color:#888;white-space:nowrap">.rforum.online</span>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Admin Email</label>
<input name="admin_email" type="email" placeholder="admin@example.com" required>
</div>
<div class="form-group">
<label>Region</label>
<select name="region">
<option value="nbg1">Nuremberg (EU)</option>
<option value="fsn1">Falkenstein (EU)</option>
<option value="hel1">Helsinki (EU)</option>
<option value="ash">Ashburn (US East)</option>
<option value="hil">Hillsboro (US West)</option>
</select>
</div>
</div>
<input type="hidden" name="size" value="cx22">
<button type="submit" style="width:100%;padding:10px;font-size:14px;margin-top:8px">
Deploy Forum
</button>
</form>
</div>
`;
}
private attachEvents() {
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.view = "create"; this.render(); }
else if (action === "back") {
if (this.pollTimer) clearInterval(this.pollTimer);
this.view = "list"; this.loadInstances();
}
else if (action === "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 formatStep(step: string): string {
const labels: Record<string, string> = {
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);