420 lines
15 KiB
TypeScript
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} · ${inst.region} · ${inst.size}
|
|
${inst.vps_ip ? ` · ${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 · 4 GB · ~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 · 8 GB · ~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 · 16 GB · ~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);
|