const API_BASE = '/web-api';
async function api(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const state = { projects: [], workspaces: [] };
async function init() {
const me = await api('/me');
document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`;
document.getElementById('workspaceForm').addEventListener('submit', createWorkspace);
document.getElementById('createForm').addEventListener('submit', createProject);
document.getElementById('refreshCaddyBtn').addEventListener('click', loadCaddyConfig);
await refreshWorkspaces();
await refreshProjects();
await loadCaddyConfig();
}
async function refreshWorkspaces() {
state.workspaces = await api('/workspaces');
const select = document.getElementById('workspaceSelect');
if (state.workspaces.length === 0) {
select.innerHTML = '';
return;
}
select.innerHTML = state.workspaces.map((w) => (
``
)).join('');
}
async function refreshProjects() {
state.projects = await api('/projects');
renderProjects();
}
function renderProjects() {
const root = document.getElementById('projects');
if (state.projects.length === 0) {
root.innerHTML = '
No projects yet.
';
return;
}
root.innerHTML = '';
state.projects.forEach((p) => {
const div = document.createElement('article');
div.className = 'project';
div.innerHTML = `
${escapeHtml(p.name)}
workspace: ${escapeHtml(p.workspace)} (${escapeHtml(p.unix_user)})
slug: ${escapeHtml(p.slug)} | service: ${escapeHtml(p.service_name)}
route host: ${escapeHtml(p.route_host || 'not set')}
app url: ${p.app_url ? `${escapeHtml(p.app_url)}` : 'not set'}
last deploy: ${p.last_deployed_at ? escapeHtml(new Date(p.last_deployed_at).toLocaleString()) : 'never'}
latest commit: ${escapeHtml(p.last_deployed_commit || 'n/a')}
deploy webhook: ${escapeHtml(p.webhook_url || 'n/a')}
provisioning: ${escapeHtml(p.provision_state || 'unknown')}${p.provision_error ? ` | error: ${escapeHtml(p.provision_error)}` : ''}
${escapeHtml(p.description || '')}
${p.repo_url ? `Open Repository
` : 'No repository URL set.
'}
Loading env vars...
status: loading...
logs: not loaded
`;
div.querySelector('[data-add]').addEventListener('click', async () => {
const key = div.querySelector('[data-k]').value.trim();
const value = div.querySelector('[data-v]').value;
await api(`/projects/${p.id}/env`, {
method: 'POST',
body: JSON.stringify({ key, value })
});
await loadEnvList(div, p.id);
});
div.querySelector('[data-regen]').addEventListener('click', async () => {
await api(`/projects/${p.id}/service`, { method: 'POST' });
alert('Service regenerated and systemctl --user reload attempted.');
});
div.querySelector('[data-status]').addEventListener('click', async () => {
await loadStatus(div, p.id);
});
div.querySelector('[data-logs]').addEventListener('click', async () => {
await loadLogs(div, p.id);
});
div.querySelector('[data-reprovision]').addEventListener('click', async () => {
await api(`/projects/${p.id}/reprovision`, { method: 'POST' });
await refreshProjects();
});
div.querySelector('[data-unprovision]').addEventListener('click', async () => {
const ok = confirm(`Unprovision ${p.name}? This removes service/route and project record, but keeps DB.`);
if (!ok) return;
await api(`/projects/${p.id}/unprovision`, { method: 'POST' });
await refreshProjects();
});
root.appendChild(div);
loadEnvList(div, p.id);
loadStatus(div, p.id);
});
}
async function loadEnvList(projectNode, projectID) {
const envs = await api(`/projects/${projectID}/env`);
const envRoot = projectNode.querySelector('[data-env]');
if (envs.length === 0) {
envRoot.innerHTML = 'No env vars';
return;
}
envRoot.innerHTML = envs.map((e) => {
return `${escapeHtml(e.key)}=${escapeHtml(e.value)}
`;
}).join('');
envRoot.querySelectorAll('[data-del]').forEach((btn) => {
btn.addEventListener('click', async () => {
const key = btn.getAttribute('data-del');
await api(`/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
await loadEnvList(projectNode, projectID);
});
});
}
async function loadStatus(projectNode, projectID) {
const out = projectNode.querySelector('[data-status-out]');
out.textContent = 'status: loading...';
try {
const s = await api(`/projects/${projectID}/status`);
out.textContent = `status: ${s.active_state}/${s.sub_state} pid=${s.main_pid} result=${s.result || 'n/a'} exit=${s.exec_status}`;
} catch (err) {
out.textContent = `status error: ${err.message}`;
}
}
async function loadLogs(projectNode, projectID) {
const out = projectNode.querySelector('[data-logs-out]');
out.textContent = 'loading logs...';
try {
const res = await api(`/projects/${projectID}/logs?lines=100`);
out.textContent = res.logs || '(no logs)';
} catch (err) {
out.textContent = `logs error: ${err.message}`;
}
}
async function createProject(e) {
e.preventDefault();
const form = e.target;
const payload = {
workspace_id: Number(form.workspace_id.value),
name: form.name.value,
description: form.description.value,
repo_url: form.repo_url.value.trim(),
repo_private: form.repo_private.checked
};
const msg = document.getElementById('createMsg');
msg.textContent = 'Creating project...';
try {
await api('/projects', {
method: 'POST',
body: JSON.stringify(payload)
});
msg.textContent = 'Project created.';
form.reset();
form.repo_private.checked = true;
await refreshProjects();
} catch (err) {
msg.textContent = `Error: ${err.message}`;
}
}
async function createWorkspace(e) {
e.preventDefault();
const form = e.target;
const payload = {
name: form.name.value.trim(),
unix_user: form.unix_user.value.trim()
};
const msg = document.getElementById('workspaceMsg');
msg.textContent = 'Creating workspace...';
try {
await api('/workspaces', {
method: 'POST',
body: JSON.stringify(payload)
});
msg.textContent = 'Workspace created.';
form.reset();
await refreshWorkspaces();
} catch (err) {
msg.textContent = `Error: ${err.message}`;
}
}
async function loadCaddyConfig() {
const out = document.getElementById('caddyConfigOut');
out.textContent = 'Loading Caddy config...';
try {
const cfg = await api('/caddy/config');
out.textContent = cfg.content || JSON.stringify(cfg, null, 2);
} catch (err) {
out.textContent = `Error: ${err.message}`;
}
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
init().catch((err) => {
document.getElementById('projects').innerHTML = `Failed to load: ${escapeHtml(err.message)}
`;
});