chore: creative AI-imbued dashboard UI + fallback context endpoint
Some checks failed
CI / lint (push) Has been cancelled
Release / tag-release (push) Has been cancelled
CI / build-frontend (push) Has been cancelled
Release / draft-rc (push) Has been cancelled

This commit is contained in:
timmy 2026-07-10 17:46:19 +00:00
parent ea91b2d590
commit 8ff742c0fe
12 changed files with 555 additions and 184 deletions

25
docs/ops-fundamentals.md Normal file
View File

@ -0,0 +1,25 @@
# Release Engineering & Continuous Delivery
- Treat every commit as a deployable artifact.
- Use CI to run tests and build artifacts; use CD to push to staging/production.
- Pin versions, sign artifacts, and audit changes.
- Release trains > hero deploys: small batches, fast feedback.
# 11 Factor Apps (12 Factor methodology adapted)
1. Codebase: one repo per service, tracked in version control.
2. Dependencies: explicit manifests, lockfiles, isolated environments.
3. Config: env vars/secrets outside code.
4. Backing services: attach databases/queues as resources.
5. Build/run separation: immutable artifacts, separate runtime.
6. Processes: stateless, share nothing, push state out.
7. Port binding: self-contained HTTP service.
8. Concurrency: scale by process model.
9. Disposability: fast startup, graceful shutdown.
10. Dev/prod parity: avoid one-off configs.
11. Logs/logging: treat logs as event streams.
# Microservices Architecture
- bounded context per service
- async communication where possible
- API contracts + versioning
- independent deployability
- observability: metrics, traces, structured logs

View File

@ -2,75 +2,86 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Stackchain Dashboard</title> <title>Stackchain Dashboard</title>
<style> <style>
:root { color-scheme: light dark; } :root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } #bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
header { padding: 12px 16px; border-bottom: 1px solid #ddd; display:flex; gap: 16px; align-items: center; justify-content: space-between; position: sticky; top: 0; background: inherit; z-index: 20; } header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.panel { border: 1px solid #ddd; border-radius: 10px; padding: 10px; background: #fff; } button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
aside, section { background: #fff; } button:hover { filter: brightness(1.15); }
.sidebar { background: #fafafa; } .panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #374151; } aside, section { position: relative; z-index: 1; }
a { color: #2563eb; text-decoration: none; } main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
.muted { color: #6b7280; } @media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#e5e7eb; color:#111; font-size: 12px; } .sidebar { display:flex; flex-direction:column; gap: 12px; }
.stack { display:flex; flex-direction:column; gap:10px; }
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; } .kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #6b7280; } .kv .label { color: #94a3b8; }
.kv .value { color: #111; } .kv .value { color: #e5e7eb; }
main { display: grid; grid-template-columns: 260px 1fr 300px; gap: 12px; padding: 12px; } .suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
@media (max-width: 980px) { main { grid-template-columns: 1fr; } } .suggestion.high { border-color: #fca5a5; background: #2a1216; }
.collapsible summary { cursor: pointer; font-weight: 600; } .suggestion.medium { border-color: #fcd34d; background: #2a2012; }
.suggestion { padding: 8px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; margin-bottom: 8px; } .suggestion.low { border-color: #d1fae5; background: #122a1e; }
.suggestion.high { border-color: #fca5a5; background: #fef2f2; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
.suggestion.medium { border-color: #fcd34d; background: #fffbeb; } .pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
.suggestion.low { border-color: #d1fae5; background: #ecfdf5; } canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; } pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
.stack { display: flex; flex-direction: column; gap: 10px; } input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.widget { border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; background: #fff; }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #374151; }
canvas { display: block; width: 100%; height: 260px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; cursor: crosshair; }
pre { margin: 0; padding: 8px; background: #0f172a; color: #e2e8f0; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; }
input[type="text"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #ccc; }
textarea { resize: vertical; min-height: 120px; } textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(720px, 94vw); background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 20px 50px rgba(0,0,0,.25); padding: 10px; z-index: 30; display: none; } #cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; } #cmd-palette.open { display: block; }
.cmd-item { padding: 8px; cursor: pointer; border-radius: 8px; } .cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
.cmd-item:hover { background: #f3f4f6; } .cmd-item:hover { background: #10233a; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(15,23,42,.35); display: none; align-items: center; justify-content: center; z-index: 40; } #whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; } #whiteboard-modal.open, #markdown-modal.open { display: flex; }
.modal { background: #fff; border-radius: 12px; padding: 14px; width: min(980px, 94vw); max-height: 92vh; overflow: auto; } .modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; } .modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background: #e5e7eb; color: #111; } .badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
.small { font-size: 12px; color: #6b7280; } .small { font-size: 12px; color: #94a3b8; }
.status { display:inline-flex; gap:6px; align-items:center; } .status { display:inline-flex; gap:6px; align-items:center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; } .dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
</style> </style>
</head> </head>
<body> <body>
<canvas id="bg"></canvas>
<header> <header>
<div><strong>Stackchain</strong> <span class="muted">Dashboard</span></div> <div><span class="obi"></span> <strong style="margin-left:6px;">Stackchain</strong> <span class="muted">Dashboard</span></div>
<div class="toolbar"> <div class="toolbar">
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div> <div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button class="btn" id="refresh">Refresh</button> <button id="refresh">Refresh</button>
<button class="btn" id="open-palette">Command</button> <button id="open-palette">Command</button>
<span class="small muted" id="clock"></span> <span class="small muted" id="clock"></span>
</div> </div>
</header> </header>
<main> <main>
<aside class="sidebar panel stack" id="nav"> <aside class="sidebar">
<details open> <div class="panel stack">
<summary>Context</summary> <div>
<div class="muted" id="context">Loading…</div> <h2>Context</h2>
</details> <div class="muted" id="context">Loading…</div>
<details> </div>
<summary>View</summary> <div>
<div class="muted" id="view-hint"></div> <h2>View</h2>
</details> <div class="muted" id="view-hint"></div>
</div>
</div>
<div class="panel stack">
<h2>AI Stream</h2>
<div id="ai" class="muted">Loading…</div>
</div>
</aside> </aside>
<section class="stack" id="work"> <section class="stack" id="work">
@ -82,17 +93,25 @@ textarea { resize: vertical; min-height: 120px; }
<h2>Pull Requests</h2> <h2>Pull Requests</h2>
<div class="muted">Loading…</div> <div class="muted">Loading…</div>
</div> </div>
<div class="panel">
<h2>Live Widgets</h2>
<div id="widgets" class="stack"></div>
</div>
</section> </section>
<aside class="sidebar panel stack" id="right"> <aside class="sidebar">
<details open> <div class="panel stack">
<summary>AI</summary> <h2>Layout</h2>
<div id="ai" class="muted">Loading…</div> <div class="muted" id="layout-hint"></div>
</details> </div>
<details> <div class="panel stack">
<summary>Widgets</summary> <h2>Markdown</h2>
<div id="widgets" class="stack"></div> <textarea id="md-input" placeholder="# Write markdown here...
</details> - bullet
- **bold**
- `code`"></textarea>
<div id="md-preview" style="margin-top:8px;"></div>
</div>
</aside> </aside>
</main> </main>
@ -107,42 +126,28 @@ textarea { resize: vertical; min-height: 120px; }
<strong>Whiteboard</strong> <strong>Whiteboard</strong>
<div> <div>
<span class="badge">Click & drag to draw</span> <span class="badge">Click & drag to draw</span>
<button class="btn" id="close-whiteboard">Close</button> <button id="close-whiteboard">Close</button>
</div> </div>
</div> </div>
<canvas id="wb"></canvas> <canvas id="wb"></canvas>
<div class="row" style="margin-top:8px;"> <div class="row" style="margin-top:8px;">
<button class="btn" id="wb-clear">Clear</button> <button id="wb-clear">Clear</button>
<button class="btn" id="wb-save">Save PNG</button> <button id="wb-save">Save PNG</button>
</div> </div>
</div> </div>
</div> </div>
<div id="markdown-modal"> <div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<div class="modal">
<div class="modal-actions">
<strong>Markdown Widget</strong>
<button class="btn" id="close-markdown">Close</button>
</div>
<textarea id="md-input" placeholder="# Write markdown here...&#10;- bullet&#10;- **bold**&#10;- `code`"></textarea>
<div id="md-preview" style="margin-top:10px;"></div>
</div>
</div>
<script> <script>
(function(){ (function(){
const API_BASE = '';
const qs = (s, el=document) => el.querySelector(s); const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString(); const fmt = (d) => new Date(d).toLocaleString();
let liveMode = true;
function setStatus(msg) { function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
qs('#status').textContent = msg || 'Live'; function setClock() { qs('#clock').textContent = fmt(new Date()); }
} setClock(); setInterval(setClock, 1000);
function setClock() {
qs('#clock').textContent = fmt(new Date());
}
setClock();
setInterval(setClock, 1000);
async function load() { async function load() {
setStatus('Loading…'); setStatus('Loading…');
@ -150,159 +155,179 @@ textarea { resize: vertical; min-height: 120px; }
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } }); const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json(); const data = await res.json();
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user.full_name || data.user.login) + '</div>' + liveMode = true;
'<div class="label">Repos</div><div class="value">' + data.repos.length + '</div>' + qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
'<div class="label">Issues</div><div class="value">' + data.issues.length + '</div>' + '<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
'<div class="label">PRs</div><div class="value">' + data.pull_requests.length + '</div></div>'; '<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
const view = data.view || 'dashboard'; '<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
qs('#view-hint').textContent = 'Active view: ' + view; qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
const issuesBox = qs('#issues'); const issuesBox = qs('#issues');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12); const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = '<h2>Issues</h2>' + (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' + issuesBox.innerHTML = '<h2>Issues</h2>' + (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>'); '<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
issuesBox.querySelectorAll('a').forEach(a => a.target = '_blank');
const prsBox = qs('#prs'); const prsBox = qs('#prs');
const openPrs = (data.pull_requests || []).slice(0, 12); const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = '<h2>Pull Requests</h2>' + (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' + prsBox.innerHTML = '<h2>Pull Requests</h2>' + (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>'); '<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
prsBox.querySelectorAll('a').forEach(a => a.target = '_blank');
const ai = qs('#ai');
const deltas = data.deltas || [];
ai.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
paintDeltas(data.deltas || []);
setStatus('Live · updated just now'); setStatus('Live · updated just now');
setClock(); setClock();
} catch (e) { } catch (e) {
console.error(e); console.error('context failed', e);
setStatus('Unavailable'); liveMode = false;
qs('#context').innerHTML = '<div class="muted">Could not reach backend.</div>'; setStatus('Mock');
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">Timmy Jr</div><div class="label">Repos</div><div class="value">3</div><div class="label">Issues</div><div class="value"></div><div class="label">PRs</div><div class="value"></div></div>';
qs('#view-hint').textContent = 'Active view: dashboard';
const mockIssues = [
{ number: 1, title: 'Kickoff issue', state: 'open', labels: ['epic'], url: '#1' },
{ number: 8, title: 'Live UI implementation', state: 'open', labels: ['frontend'], url: '#8' },
{ number: 37, title: 'Docker build and push', state: 'open', labels: ['devops'], url: '#37' },
];
qs('#issues').innerHTML = '<h2>Issues</h2>' + mockIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a><div class="muted">' + (i.labels||[]).map(l=>'<span class="pill">'+escapeHtml(String(l))+'</span>').join(' ') + '</div></div>').join('');
const mockPrs = [
{ number: 4, title: 'Agent UI delta binding', state: 'open', user: 'turdbot', url: '#4' },
];
qs('#prs').innerHTML = '<h2>Pull Requests</h2>' + mockPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a><div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user||'')) + '</div></div>').join('');
paintDeltas([
{panel:'triage', action:'open', target:'#1', priority:'high'},
{panel:'review', action:'review', target:'#4', priority:'medium'},
{panel:'layout', action:'highlight', target:'issues', priority:'low'},
]);
} }
} }
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c])); } function paintDeltas(deltas) {
function escAttr(s) { return escapeHtml(s); } const el = qs('#ai');
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
}
async function tickWidgets() {
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
} catch (e) {
qs('#layout-hint').innerHTML = '<div class="muted">Mock layout mode</div>';
}
}
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return escapeHtml(s); }
function openModal(id) { qs('#' + id).classList.add('open'); } function openModal(id) { qs('#' + id).classList.add('open'); }
function closeModal(id) { qs('#' + id).classList.remove('open'); } function closeModal(id) { qs('#' + id).classList.remove('open'); }
/* Creative ambient background */
(function bg(){
const c=qs('#bg'),ctx=c.getContext('2d');
let w,h,time=0;
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
resize(); addEventListener('resize', resize);
const draw=()=>{
time+=0.012;
ctx.clearRect(0,0,w,h);
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
const step=60;
for(let x=0;x<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
requestAnimationFrame(draw);
};
draw();
})();
/* Whiteboard */ /* Whiteboard */
function initWhiteboard() { function initWhiteboard() {
const canvas = qs('#wb'); const canvas = qs('#wb'), ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d');
let drawing = false; let drawing = false;
function resize() { function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
const dpr = window.devicePixelRatio || 1; resize(); addEventListener('resize', resize);
const r = canvas.getBoundingClientRect(); const p = (e) => ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
canvas.width = r.width * dpr; canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
canvas.height = r.height * dpr; canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); canvas.addEventListener('pointerup', () => drawing=false);
ctx.lineCap = 'round'; canvas.addEventListener('pointerleave', () => drawing=false);
ctx.lineJoin = 'round'; qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
ctx.strokeStyle = '#111'; qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
ctx.lineWidth = 2;
}
resize();
window.addEventListener('resize', resize);
const p = (e) => {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
canvas.addEventListener('pointerdown', (e) => { drawing = true; const pos = p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
canvas.addEventListener('pointermove', (e) => { if (!drawing) return; const pos = p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
canvas.addEventListener('pointerup', () => { drawing = false; });
canvas.addEventListener('pointerleave', () => { drawing = false; });
qs('#wb-clear').addEventListener('click', () => { const r = canvas.getBoundingClientRect(); ctx.clearRect(0, 0, r.width, r.height); });
qs('#wb-save').addEventListener('click', () => { const a = document.createElement('a'); a.href = canvas.toDataURL(); a.download = 'whiteboard.png'; a.click(); });
} }
initWhiteboard();
/* Markdown widget */ /* Markdown */
qs('#close-markdown').addEventListener('click', () => closeModal('markdown-modal')); qs('#md-input').addEventListener('input', renderMD);
qs('#md-input').addEventListener('input', () => renderMarkdown()); function renderMD() {
function renderMarkdown() {
const raw = qs('#md-input').value || ''; const raw = qs('#md-input').value || '';
const html = raw const html = raw.replace(/^#{1,6}\s.*$/gm, (m)=>{ const lvl=m.match(/^(#{1,6})/)[1].length; return '<h'+lvl+'>'+m.replace(/^#{1,6}\s/,'')+'</h'+lvl+'>'; })
.replace(/^#{1,6}\s.*$/gm, (m) => { const lvl = m.match(/^(#{1,6})/)[1].length; return '<h' + lvl + '>' + m.replace(/^#{1,6}\s/, '') + '</h' + lvl + '>'; }) .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/\*(.+?)\*/g, '<em>$1</em>') .replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/`([^`]+)`/g, '<code>$1</code>') .replace(/^\-\s?(.+)$/gm,'<li>$1</li>')
.replace(/^-\s?(.+)$/gm, '<li>$1</li>') .replace(/(<li>.*<\/li>)/s,'<ul>$1</ul>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>') .replace(/\n/g,'<br>');
.replace(/\n/g, '<br>');
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + html + '</div>'; qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + html + '</div>';
} }
/* Command palette / widgets */ /* Commands */
const commands = [ const commands = [
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); resizeWhiteboard(); } }, { name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Open markdown widget', run: () => { openModal('markdown-modal'); renderMarkdown(); } }, { name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
{ name: 'Focus issues', run: () => qs('#issues').scrollIntoView({ behavior: 'smooth', block: 'start' }) },
{ name: 'Focus PRs', run: () => qs('#prs').scrollIntoView({ behavior: 'smooth', block: 'start' }) },
{ name: 'Focus AI', run: () => qs('#right').scrollIntoView({ behavior: 'smooth', block: 'start' }) },
{ name: 'Refresh now', run: load }, { name: 'Refresh now', run: load },
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
]; ];
function resizeWhiteboard(){ const canvas = qs('#wb'); if(!canvas) return; const r = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = r.width * dpr; canvas.height = r.height * dpr; canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); canvas.getContext('2d').lineCap = 'round'; canvas.getContext('2d').lineJoin = 'round'; }
function renderCommands(filter) { function renderCommands(filter) {
const el = qs('#cmd-results'); const el = qs('#cmd-results');
const term = String(filter || '').toLowerCase(); const term = String(filter||'').toLowerCase();
const items = commands.filter(c => c.name.toLowerCase().includes(term)); const items = commands.filter(c => c.name.toLowerCase().includes(term));
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join(''); el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
el.querySelectorAll('.cmd-item').forEach((item) => { el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => { commands[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value = ''; }); item.addEventListener('click', () => { commands[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
}); });
} }
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); }); qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value)); qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if (qs('#cmd-palette').classList.contains('open')) { qs('#cmd-input').focus(); renderCommands(''); } } }); document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#refresh').addEventListener('click', load); qs('#refresh').addEventListener('click', load);
load(); load();
setInterval(load, 8000); setInterval(load, 8000);
setInterval(tickWidgets, 2000);
tickWidgets();
/* Live widgets */ /* Widgets */
const widgetOrder = ['countdown','repoAtAGlance'];
const widgetRegistry = { const widgetRegistry = {
markdown: () => ({ title: 'Markdown', html: '<div class="small">Launches a tiny markdown scratchpad.</div><button class="btn" id="widget-markdown-launch">Open</button>' }),
countdown: () => ({ title: 'Live clock', html: '<div class="small" id="widget-clock"></div>' }), countdown: () => ({ title: 'Live clock', html: '<div class="small" id="widget-clock"></div>' }),
repoAtAGlance: () => ({ title: 'Repo mix', html: '<div class="small" id="repo-mix">Loading…</div>' }), repoAtAGlance: async () => {
const el = qs('#repo-mix');
if (!el) return { title:'Repo mix', html:'<div class="small">Unavailable</div>' };
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
const repos = data.repos || [];
const issues = (data.issues||[]).filter(i=>i.state==='open');
const prs = data.pull_requests || [];
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
} catch (e) { el.innerHTML = '<div class="muted">Widget unavailable.</div>'; }
return { title:'Repo mix', html:'<div class="small" id="repo-mix">Loading…</div>' };
},
}; };
const widgetOrder = ['countdown', 'repoAtAGlance'];
const widgetsEl = qs('#widgets'); const widgetsEl = qs('#widgets');
function paintWidgets() { function paintWidgets() {
widgetsEl.innerHTML = widgetOrder.map(k => { widgetsEl.innerHTML = '';
const w = widgetRegistry[k](); widgetOrder.forEach(async k => {
return '<div class="widget"><h3>' + escapeHtml(w.title) + '</h3>' + w.html + '</div>'; const w = await widgetRegistry[k]();
}).join(''); const node = document.createElement('div'); node.className='widget'; node.innerHTML = '<h3>' + escapeHtml(w.title) + '</h3>' + w.html;
qs('#widget-markdown-launch')?.addEventListener('click', () => { openModal('markdown-modal'); renderMarkdown(); }); widgetsEl.appendChild(node);
} });
function tickWidgets() {
const clock = qs('#widget-clock');
if (clock) clock.textContent = fmt(new Date());
} }
paintWidgets(); paintWidgets();
setInterval(tickWidgets, 1000);
window.repoAtAGlance = async function() { function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
const el = qs('#repo-mix'); setInterval(widgetTick, 1000);
if (!el) return;
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
const repos = data.repos || [];
const issues = data.issues || [];
const prs = data.pull_requests || [];
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div>' +
'<div class="label">Open issues</div><div class="value">' + issues.filter(i => i.state === 'open').length + '</div>' +
'<div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
} catch (e) {
el.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
};
setInterval(window.repoAtAGlance, 8000);
window.repoAtAGlance();
})(); })();
</script> </script>
</body> </body>

309
frontend/static/index.html Normal file
View File

@ -0,0 +1,309 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stackchain Dashboard</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { padding: 12px 16px; border-bottom: 1px solid #ddd; display:flex; gap: 16px; align-items: center; justify-content: space-between; position: sticky; top: 0; background: inherit; z-index: 20; }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.panel { border: 1px solid #ddd; border-radius: 10px; padding: 10px; background: #fff; }
aside, section { background: #fff; }
.sidebar { background: #fafafa; }
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #374151; }
a { color: #2563eb; text-decoration: none; }
.muted { color: #6b7280; }
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#e5e7eb; color:#111; font-size: 12px; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #6b7280; }
.kv .value { color: #111; }
main { display: grid; grid-template-columns: 260px 1fr 300px; gap: 12px; padding: 12px; }
@media (max-width: 980px) { main { grid-template-columns: 1fr; } }
.collapsible summary { cursor: pointer; font-weight: 600; }
.suggestion { padding: 8px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; margin-bottom: 8px; }
.suggestion.high { border-color: #fca5a5; background: #fef2f2; }
.suggestion.medium { border-color: #fcd34d; background: #fffbeb; }
.suggestion.low { border-color: #d1fae5; background: #ecfdf5; }
.row { display: flex; gap: 10px; flex-wrap: wrap; }
.stack { display: flex; flex-direction: column; gap: 10px; }
.widget { border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; background: #fff; }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #374151; }
canvas { display: block; width: 100%; height: 260px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; cursor: crosshair; }
pre { margin: 0; padding: 8px; background: #0f172a; color: #e2e8f0; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; }
input[type="text"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #ccc; }
textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(720px, 94vw); background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 20px 50px rgba(0,0,0,.25); padding: 10px; z-index: 30; display: none; }
#cmd-palette.open { display: block; }
.cmd-item { padding: 8px; cursor: pointer; border-radius: 8px; }
.cmd-item:hover { background: #f3f4f6; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(15,23,42,.35); display: none; align-items: center; justify-content: center; z-index: 40; }
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
.modal { background: #fff; border-radius: 12px; padding: 14px; width: min(980px, 94vw); max-height: 92vh; overflow: auto; }
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background: #e5e7eb; color: #111; }
.small { font-size: 12px; color: #6b7280; }
.status { display:inline-flex; gap:6px; align-items:center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; }
</style>
</head>
<body>
<header>
<div><strong>Stackchain</strong> <span class="muted">Dashboard</span></div>
<div class="toolbar">
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button class="btn" id="refresh">Refresh</button>
<button class="btn" id="open-palette">Command</button>
<span class="small muted" id="clock"></span>
</div>
</header>
<main>
<aside class="sidebar panel stack" id="nav">
<details open>
<summary>Context</summary>
<div class="muted" id="context">Loading…</div>
</details>
<details>
<summary>View</summary>
<div class="muted" id="view-hint"></div>
</details>
</aside>
<section class="stack" id="work">
<div class="panel" id="issues">
<h2>Issues</h2>
<div class="muted">Loading…</div>
</div>
<div class="panel" id="prs">
<h2>Pull Requests</h2>
<div class="muted">Loading…</div>
</div>
</section>
<aside class="sidebar panel stack" id="right">
<details open>
<summary>AI</summary>
<div id="ai" class="muted">Loading…</div>
</details>
<details>
<summary>Widgets</summary>
<div id="widgets" class="stack"></div>
</details>
</aside>
</main>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
</div>
<div id="whiteboard-modal">
<div class="modal">
<div class="modal-actions">
<strong>Whiteboard</strong>
<div>
<span class="badge">Click & drag to draw</span>
<button class="btn" id="close-whiteboard">Close</button>
</div>
</div>
<canvas id="wb"></canvas>
<div class="row" style="margin-top:8px;">
<button class="btn" id="wb-clear">Clear</button>
<button class="btn" id="wb-save">Save PNG</button>
</div>
</div>
</div>
<div id="markdown-modal">
<div class="modal">
<div class="modal-actions">
<strong>Markdown Widget</strong>
<button class="btn" id="close-markdown">Close</button>
</div>
<textarea id="md-input" placeholder="# Write markdown here...&#10;- bullet&#10;- **bold**&#10;- `code`"></textarea>
<div id="md-preview" style="margin-top:10px;"></div>
</div>
</div>
<script>
(function(){
const API_BASE = '';
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
function setStatus(msg) {
qs('#status').textContent = msg || 'Live';
}
function setClock() {
qs('#clock').textContent = fmt(new Date());
}
setClock();
setInterval(setClock, 1000);
async function load() {
setStatus('Loading…');
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user.full_name || data.user.login) + '</div>' +
'<div class="label">Repos</div><div class="value">' + data.repos.length + '</div>' +
'<div class="label">Issues</div><div class="value">' + data.issues.length + '</div>' +
'<div class="label">PRs</div><div class="value">' + data.pull_requests.length + '</div></div>';
const view = data.view || 'dashboard';
qs('#view-hint').textContent = 'Active view: ' + view;
const issuesBox = qs('#issues');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = '<h2>Issues</h2>' + (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
issuesBox.querySelectorAll('a').forEach(a => a.target = '_blank');
const prsBox = qs('#prs');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = '<h2>Pull Requests</h2>' + (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
prsBox.querySelectorAll('a').forEach(a => a.target = '_blank');
const ai = qs('#ai');
const deltas = data.deltas || [];
ai.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
setStatus('Live · updated just now');
setClock();
} catch (e) {
console.error(e);
setStatus('Unavailable');
qs('#context').innerHTML = '<div class="muted">Could not reach backend.</div>';
}
}
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c])); }
function escAttr(s) { return escapeHtml(s); }
function openModal(id) { qs('#' + id).classList.add('open'); }
function closeModal(id) { qs('#' + id).classList.remove('open'); }
/* Whiteboard */
function initWhiteboard() {
const canvas = qs('#wb');
const ctx = canvas.getContext('2d');
let drawing = false;
function resize() {
const dpr = window.devicePixelRatio || 1;
const r = canvas.getBoundingClientRect();
canvas.width = r.width * dpr;
canvas.height = r.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#111';
ctx.lineWidth = 2;
}
resize();
window.addEventListener('resize', resize);
const p = (e) => {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
canvas.addEventListener('pointerdown', (e) => { drawing = true; const pos = p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
canvas.addEventListener('pointermove', (e) => { if (!drawing) return; const pos = p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
canvas.addEventListener('pointerup', () => { drawing = false; });
canvas.addEventListener('pointerleave', () => { drawing = false; });
qs('#wb-clear').addEventListener('click', () => { const r = canvas.getBoundingClientRect(); ctx.clearRect(0, 0, r.width, r.height); });
qs('#wb-save').addEventListener('click', () => { const a = document.createElement('a'); a.href = canvas.toDataURL(); a.download = 'whiteboard.png'; a.click(); });
}
initWhiteboard();
/* Markdown widget */
qs('#close-markdown').addEventListener('click', () => closeModal('markdown-modal'));
qs('#md-input').addEventListener('input', () => renderMarkdown());
function renderMarkdown() {
const raw = qs('#md-input').value || '';
const html = raw
.replace(/^#{1,6}\s.*$/gm, (m) => { const lvl = m.match(/^(#{1,6})/)[1].length; return '<h' + lvl + '>' + m.replace(/^#{1,6}\s/, '') + '</h' + lvl + '>'; })
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br>');
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + html + '</div>';
}
/* Command palette / widgets */
const commands = [
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); resizeWhiteboard(); } },
{ name: 'Open markdown widget', run: () => { openModal('markdown-modal'); renderMarkdown(); } },
{ name: 'Focus issues', run: () => qs('#issues').scrollIntoView({ behavior: 'smooth', block: 'start' }) },
{ name: 'Focus PRs', run: () => qs('#prs').scrollIntoView({ behavior: 'smooth', block: 'start' }) },
{ name: 'Focus AI', run: () => qs('#right').scrollIntoView({ behavior: 'smooth', block: 'start' }) },
{ name: 'Refresh now', run: load },
];
function resizeWhiteboard(){ const canvas = qs('#wb'); if(!canvas) return; const r = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = r.width * dpr; canvas.height = r.height * dpr; canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); canvas.getContext('2d').lineCap = 'round'; canvas.getContext('2d').lineJoin = 'round'; }
function renderCommands(filter) {
const el = qs('#cmd-results');
const term = String(filter || '').toLowerCase();
const items = commands.filter(c => c.name.toLowerCase().includes(term));
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => { commands[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value = ''; });
});
}
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if (qs('#cmd-palette').classList.contains('open')) { qs('#cmd-input').focus(); renderCommands(''); } } });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#refresh').addEventListener('click', load);
load();
setInterval(load, 8000);
/* Live widgets */
const widgetRegistry = {
markdown: () => ({ title: 'Markdown', html: '<div class="small">Launches a tiny markdown scratchpad.</div><button class="btn" id="widget-markdown-launch">Open</button>' }),
countdown: () => ({ title: 'Live clock', html: '<div class="small" id="widget-clock"></div>' }),
repoAtAGlance: () => ({ title: 'Repo mix', html: '<div class="small" id="repo-mix">Loading…</div>' }),
};
const widgetOrder = ['countdown', 'repoAtAGlance'];
const widgetsEl = qs('#widgets');
function paintWidgets() {
widgetsEl.innerHTML = widgetOrder.map(k => {
const w = widgetRegistry[k]();
return '<div class="widget"><h3>' + escapeHtml(w.title) + '</h3>' + w.html + '</div>';
}).join('');
qs('#widget-markdown-launch')?.addEventListener('click', () => { openModal('markdown-modal'); renderMarkdown(); });
}
function tickWidgets() {
const clock = qs('#widget-clock');
if (clock) clock.textContent = fmt(new Date());
}
paintWidgets();
setInterval(tickWidgets, 1000);
window.repoAtAGlance = async function() {
const el = qs('#repo-mix');
if (!el) return;
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
const repos = data.repos || [];
const issues = data.issues || [];
const prs = data.pull_requests || [];
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div>' +
'<div class="label">Open issues</div><div class="value">' + issues.filter(i => i.state === 'open').length + '</div>' +
'<div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
} catch (e) {
el.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
};
setInterval(window.repoAtAGlance, 8000);
window.repoAtAGlance();
})();
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -18,18 +18,30 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.mount("/static", StaticFiles(directory="frontend/static"), name="static")
app.include_router(frontend_router) app.include_router(frontend_router)
@app.get("/api/v1/context") @app.get("/api/v1/context")
async def context() -> JSONResponse: async def context() -> JSONResponse:
user_data = await current_user() try:
user_data = await current_user()
repo_data = await repos()
issues_data = await issues()
prs_data = await pull_requests()
except Exception as e:
return JSONResponse({
"user": {"id": None, "login": "timmy", "full_name": "Timmy Jr", "email": ""},
"repos": [],
"issues": [],
"pull_requests": [],
"view": "dashboard",
"deltas": [
{"panel": "auth", "action": "connect", "target": "stackchain-dashboard backend", "priority": "high"},
{"panel": "gitea", "action": "set GITEA_URL + GITEA_TOKEN", "target": "/root/stackchain-dashboard/.env or systemd env", "priority": "high"},
],
"error": str(e),
})
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name", ""), email=user_data.get("email", "")) user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name", ""), email=user_data.get("email", ""))
repo_data = await repos()
issues_data = await issues()
prs_data = await pull_requests()
repo_models = [ repo_models = [
Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description", ""), url=r["html_url"], updated_at=r.get("updated_at", "")) Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description", ""), url=r["html_url"], updated_at=r.get("updated_at", ""))
for r in repo_data[:50] for r in repo_data[:50]

Binary file not shown.