Skip to content

Commit c6094b6

Browse files
committed
Merge feat/HB-020-dashboard-shell: Dashboard shell with layout, styling, router
2 parents 60fe985 + bd3526f commit c6094b6

3 files changed

Lines changed: 464 additions & 5 deletions

File tree

ui/assets/app.js

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,161 @@
1-
// hookbin dashboard — placeholder
1+
// hookbin dashboard
22
'use strict';
3+
4+
// --- API Client ---
5+
const api = {
6+
async get(path) {
7+
const res = await fetch(path);
8+
if (!res.ok) {
9+
const err = await res.json().catch(() => ({ error: res.statusText }));
10+
throw { status: res.status, ...err };
11+
}
12+
return res.json();
13+
},
14+
15+
async post(path, body) {
16+
const res = await fetch(path, {
17+
method: 'POST',
18+
headers: body ? { 'Content-Type': 'application/json' } : {},
19+
body: body ? JSON.stringify(body) : undefined,
20+
});
21+
if (!res.ok) {
22+
const err = await res.json().catch(() => ({ error: res.statusText }));
23+
throw { status: res.status, ...err };
24+
}
25+
return res.json();
26+
},
27+
28+
async del(path) {
29+
const res = await fetch(path, { method: 'DELETE' });
30+
if (!res.ok) {
31+
const err = await res.json().catch(() => ({ error: res.statusText }));
32+
throw { status: res.status, ...err };
33+
}
34+
return res.json();
35+
},
36+
};
37+
38+
// --- Utilities ---
39+
function $(sel) { return document.querySelector(sel); }
40+
function show(id) {
41+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
42+
const el = document.getElementById(id);
43+
if (el) el.classList.add('active');
44+
}
45+
46+
function timeAgo(epoch) {
47+
const seconds = Math.floor(Date.now() / 1000) - epoch;
48+
if (seconds < 60) return seconds + 's ago';
49+
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
50+
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
51+
return Math.floor(seconds / 86400) + 'd ago';
52+
}
53+
54+
function formatBytes(n) {
55+
if (n === 0) return '0 B';
56+
if (n < 1024) return n + ' B';
57+
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
58+
return (n / 1048576).toFixed(1) + ' MB';
59+
}
60+
61+
function escapeHtml(str) {
62+
const div = document.createElement('div');
63+
div.textContent = str;
64+
return div.innerHTML;
65+
}
66+
67+
function toast(msg, type) {
68+
const el = document.createElement('div');
69+
el.className = 'toast ' + (type || '');
70+
el.textContent = msg;
71+
document.body.appendChild(el);
72+
setTimeout(() => el.remove(), 2500);
73+
}
74+
75+
async function copyText(text) {
76+
try {
77+
await navigator.clipboard.writeText(text);
78+
toast('Copied!', 'success');
79+
} catch {
80+
toast('Copy failed', 'error');
81+
}
82+
}
83+
84+
function methodBadge(method) {
85+
return '<span class="method-badge method-' + escapeHtml(method) + '">' + escapeHtml(method) + '</span>';
86+
}
87+
88+
// --- Router ---
89+
function getRoute() {
90+
const hash = location.hash.slice(1) || '/hooks';
91+
return hash;
92+
}
93+
94+
function navigate(path) {
95+
location.hash = '#' + path;
96+
}
97+
98+
function parseRoute(hash) {
99+
const hookDetail = hash.match(/^\/hooks\/([^/]+)\/requests\/([^/]+)$/);
100+
if (hookDetail) return { view: 'detail', hookId: hookDetail[1], requestId: hookDetail[2] };
101+
102+
const hookRequests = hash.match(/^\/hooks\/([^/]+)$/);
103+
if (hookRequests) return { view: 'requests', hookId: hookRequests[1] };
104+
105+
return { view: 'hooks' };
106+
}
107+
108+
async function route() {
109+
const parsed = parseRoute(getRoute());
110+
try {
111+
if (parsed.view === 'hooks') {
112+
await renderHookList();
113+
} else if (parsed.view === 'requests') {
114+
await renderRequestFeed(parsed.hookId);
115+
} else if (parsed.view === 'detail') {
116+
await renderRequestDetail(parsed.hookId, parsed.requestId);
117+
}
118+
} catch (err) {
119+
console.error('Route error:', err);
120+
show('view-hooks');
121+
const el = $('#view-hooks');
122+
el.innerHTML = '<p class="empty-state">Error: ' + escapeHtml(err.error || err.message || 'Unknown error') + '</p>';
123+
}
124+
}
125+
126+
// --- Health Check ---
127+
async function checkHealth() {
128+
const indicator = $('#health-status');
129+
try {
130+
const data = await api.get('/health');
131+
indicator.className = 'health-indicator ok';
132+
indicator.title = 'v' + data.version + ' — up ' + data.uptime_seconds + 's';
133+
} catch {
134+
indicator.className = 'health-indicator error';
135+
indicator.title = 'Unreachable';
136+
}
137+
}
138+
139+
// --- Views (stubs, implemented in HB-021 through HB-023) ---
140+
async function renderHookList() {
141+
show('view-hooks');
142+
$('#view-hooks').innerHTML = '<p class="empty-state">Loading hooks...</p>';
143+
}
144+
145+
async function renderRequestFeed(hookId) {
146+
show('view-requests');
147+
$('#view-requests').innerHTML = '<p class="empty-state">Loading requests...</p>';
148+
}
149+
150+
async function renderRequestDetail(hookId, requestId) {
151+
show('view-detail');
152+
$('#view-detail').innerHTML = '<p class="empty-state">Loading request...</p>';
153+
}
154+
155+
// --- Init ---
156+
window.addEventListener('hashchange', route);
157+
window.addEventListener('DOMContentLoaded', () => {
158+
checkHealth();
159+
setInterval(checkHealth, 30000);
160+
route();
161+
});

0 commit comments

Comments
 (0)