init
This commit is contained in:
commit
b38c39030b
22 changed files with 1925 additions and 0 deletions
202
web/app.js
Normal file
202
web/app.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
async function api(path, options = {}) {
|
||||
const res = await fetch(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('/api/me');
|
||||
document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`;
|
||||
|
||||
document.getElementById('workspaceForm').addEventListener('submit', createWorkspace);
|
||||
document.getElementById('createForm').addEventListener('submit', createProject);
|
||||
await refreshWorkspaces();
|
||||
await refreshProjects();
|
||||
}
|
||||
|
||||
async function refreshWorkspaces() {
|
||||
state.workspaces = await api('/api/workspaces');
|
||||
const select = document.getElementById('workspaceSelect');
|
||||
if (state.workspaces.length === 0) {
|
||||
select.innerHTML = '<option value="">Create a workspace first</option>';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = state.workspaces.map((w) => (
|
||||
`<option value="${w.id}">${escapeHtml(w.name)} (${escapeHtml(w.unix_user)})</option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
state.projects = await api('/api/projects');
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
const root = document.getElementById('projects');
|
||||
if (state.projects.length === 0) {
|
||||
root.innerHTML = '<p class="muted">No projects yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = '';
|
||||
state.projects.forEach((p) => {
|
||||
const div = document.createElement('article');
|
||||
div.className = 'project';
|
||||
div.innerHTML = `
|
||||
<h3>${escapeHtml(p.name)}</h3>
|
||||
<p class="meta">workspace: ${escapeHtml(p.workspace)} (${escapeHtml(p.unix_user)})</p>
|
||||
<p class="meta">slug: ${escapeHtml(p.slug)} | service: ${escapeHtml(p.service_name)}</p>
|
||||
<p class="meta">app target: 127.0.0.1:${escapeHtml(p.target_port)} | route host: ${escapeHtml(p.route_host || 'not set')}</p>
|
||||
<p class="meta">deploy webhook: <code>${escapeHtml(p.webhook_url || 'n/a')}</code></p>
|
||||
<p>${escapeHtml(p.description || '')}</p>
|
||||
${p.repo_url ? `<p><a class="link" href="${p.repo_url}" target="_blank" rel="noreferrer">Open Repository</a></p>` : '<p class="muted">No repository URL set.</p>'}
|
||||
<div class="env-row">
|
||||
<input class="small" placeholder="ENV_KEY" data-k />
|
||||
<input class="small" placeholder="value" data-v />
|
||||
<button class="small" data-add>Add/Update Env</button>
|
||||
<button class="small" data-regen>Regenerate Service</button>
|
||||
</div>
|
||||
<div class="env-list muted" data-env>Loading env vars...</div>
|
||||
<div class="env-row">
|
||||
<button class="small" data-status>Refresh Status</button>
|
||||
<button class="small" data-logs>Load Logs</button>
|
||||
</div>
|
||||
<div class="meta" data-status-out>status: loading...</div>
|
||||
<pre class="meta" data-logs-out style="white-space: pre-wrap; max-height: 220px; overflow: auto;">logs: not loaded</pre>
|
||||
`;
|
||||
|
||||
div.querySelector('[data-add]').addEventListener('click', async () => {
|
||||
const key = div.querySelector('[data-k]').value.trim();
|
||||
const value = div.querySelector('[data-v]').value;
|
||||
await api(`/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(`/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);
|
||||
});
|
||||
|
||||
root.appendChild(div);
|
||||
loadEnvList(div, p.id);
|
||||
loadStatus(div, p.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEnvList(projectNode, projectID) {
|
||||
const envs = await api(`/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 `<div><code>${escapeHtml(e.key)}</code>=<code>${escapeHtml(e.value)}</code> <button class="small danger" data-del="${escapeHtml(e.key)}">Delete</button></div>`;
|
||||
}).join('');
|
||||
|
||||
envRoot.querySelectorAll('[data-del]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const key = btn.getAttribute('data-del');
|
||||
await api(`/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(`/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(`/api/projects/${projectID}/logs?lines=200`);
|
||||
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,
|
||||
target_port: Number(form.target_port.value || 8000)
|
||||
};
|
||||
const msg = document.getElementById('createMsg');
|
||||
msg.textContent = 'Creating project...';
|
||||
try {
|
||||
await api('/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('/api/workspaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = 'Workspace created.';
|
||||
form.reset();
|
||||
await refreshWorkspaces();
|
||||
} catch (err) {
|
||||
msg.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
document.getElementById('projects').innerHTML = `<p class="muted">Failed to load: ${escapeHtml(err.message)}</p>`;
|
||||
});
|
||||
51
web/index.html
Normal file
51
web/index.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Project Box</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="hero">
|
||||
<h1>Project Box</h1>
|
||||
<p>Create projects, provision Forgejo repositories, and keep service env vars in one place.</p>
|
||||
<div id="me" class="pill"></div>
|
||||
</header>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>Workspaces</h2>
|
||||
<form id="workspaceForm">
|
||||
<label>Workspace Name<input name="name" required placeholder="client-a" /></label>
|
||||
<label>Unix User<input name="unix_user" required placeholder="pavel" /></label>
|
||||
<button type="submit">Create Workspace</button>
|
||||
</form>
|
||||
<p id="workspaceMsg" class="muted"></p>
|
||||
</section>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>Create Project</h2>
|
||||
<form id="createForm">
|
||||
<label>Workspace<select name="workspace_id" id="workspaceSelect" required></select></label>
|
||||
<label>Name<input name="name" required placeholder="analytics-api" /></label>
|
||||
<label>Description<textarea name="description" placeholder="optional"></textarea></label>
|
||||
<label>Existing Repo URL (optional)<input name="repo_url" placeholder="https://forgejo.example.com/org/repo" /></label>
|
||||
<label>Target Port<input name="target_port" type="number" min="1" max="65535" value="8000" required /></label>
|
||||
<label class="inline"><input type="checkbox" name="repo_private" checked />Private Forgejo repo</label>
|
||||
<button type="submit">Create Project</button>
|
||||
</form>
|
||||
<p id="createMsg" class="muted"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Your Projects</h2>
|
||||
<div id="projects" class="projects"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
97
web/styles.css
Normal file
97
web/styles.css
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
:root {
|
||||
--bg: radial-gradient(circle at 10% 20%, #223 0%, #10131d 35%, #080a10 100%);
|
||||
--panel: rgba(255, 255, 255, 0.08);
|
||||
--panel-border: rgba(255, 255, 255, 0.2);
|
||||
--text: #ecf0ff;
|
||||
--muted: #b8bfd8;
|
||||
--accent: #5ee8b5;
|
||||
--danger: #ff7f8f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Manrope, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(980px, 92vw);
|
||||
margin: 2rem auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
margin: 0;
|
||||
}
|
||||
.hero p { color: var(--muted); }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
margin-top: .5rem;
|
||||
padding: .35rem .7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: .8rem;
|
||||
}
|
||||
|
||||
label { display: grid; gap: .4rem; font-weight: 500; }
|
||||
.inline { display: flex; align-items: center; gap: .5rem; }
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: rgba(0,0,0,.25);
|
||||
color: var(--text);
|
||||
padding: .65rem .75rem;
|
||||
}
|
||||
textarea { min-height: 80px; }
|
||||
button {
|
||||
background: linear-gradient(120deg, #4bcf9e, #63a8ff);
|
||||
color: #07111f;
|
||||
border: 0;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.projects { display: grid; gap: .8rem; }
|
||||
.project {
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: .8rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.project h3 { margin: 0 0 .4rem; font-family: "Space Grotesk", sans-serif; }
|
||||
.meta { color: var(--muted); font-size: .9rem; }
|
||||
.env-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
gap: .5rem;
|
||||
margin-top: .6rem;
|
||||
}
|
||||
.small { padding: .45rem .55rem; font-size: .88rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.link { color: var(--accent); text-decoration: none; }
|
||||
.danger { background: var(--danger); color: #200; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.env-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue