Skip to content

Commit 9f91c4a

Browse files
Add web dashboard with repo discovery
- Built-in HTTP server on port 3847 - Status tracking (sync count, timestamps, relay status) - Manual sync button per repo - View config modal - Recent events feed - Discover section matching ngit (9 relays, same dedup logic) - One-click add discovered repos to config
1 parent a693c3a commit 9f91c4a

2 files changed

Lines changed: 654 additions & 10 deletions

File tree

dashboard.html

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Nostr Git Sync Dashboard</title>
7+
<style>
8+
* { box-sizing: border-box; margin: 0; padding: 0; }
9+
body {
10+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11+
background: #f6f8fa;
12+
color: #1f2328;
13+
padding: 20px;
14+
min-height: 100vh;
15+
}
16+
h1 { color: #1f2328; margin-bottom: 20px; }
17+
h2 { color: #656d76; font-size: 14px; text-transform: uppercase; margin: 20px 0 10px; }
18+
.card {
19+
background: #ffffff;
20+
border: 1px solid #d0d7de;
21+
border-radius: 6px;
22+
padding: 16px;
23+
margin-bottom: 12px;
24+
overflow: hidden;
25+
}
26+
.card-header {
27+
display: flex;
28+
justify-content: space-between;
29+
align-items: center;
30+
margin-bottom: 8px;
31+
}
32+
.repo-name { font-weight: 600; color: #0969da; }
33+
.status {
34+
padding: 2px 8px;
35+
border-radius: 12px;
36+
font-size: 12px;
37+
font-weight: 500;
38+
}
39+
.status.watching { background: #ddf4ff; color: #0969da; }
40+
.status.synced { background: #dafbe1; color: #1a7f37; }
41+
.status.syncing { background: #fff8c5; color: #9a6700; }
42+
.status.error { background: #ffebe9; color: #cf222e; }
43+
.status.connected { background: #dafbe1; color: #1a7f37; }
44+
.status.disconnected { background: #ffebe9; color: #cf222e; }
45+
.meta { font-size: 13px; color: #656d76; }
46+
.meta span { margin-right: 16px; }
47+
.time { font-family: monospace; }
48+
.path { font-family: monospace; font-size: 12px; color: #656d76; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
49+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
50+
.relay-url { font-family: monospace; font-size: 13px; word-break: break-all; }
51+
.uptime { font-size: 12px; color: #656d76; margin-top: 8px; }
52+
.refresh { color: #656d76; font-size: 13px; }
53+
#error { color: #cf222e; padding: 20px; text-align: center; }
54+
.btn {
55+
background: #f6f8fa;
56+
border: 1px solid #d0d7de;
57+
border-radius: 6px;
58+
padding: 5px 12px;
59+
font-size: 13px;
60+
cursor: pointer;
61+
color: #1f2328;
62+
}
63+
.btn:hover { background: #eaeef2; }
64+
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
65+
.sync-btn { margin-right: 8px; }
66+
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
67+
.modal {
68+
display: none;
69+
position: fixed;
70+
top: 0; left: 0; right: 0; bottom: 0;
71+
background: rgba(0,0,0,0.5);
72+
justify-content: center;
73+
align-items: center;
74+
z-index: 100;
75+
}
76+
.modal.open { display: flex; }
77+
.modal-content {
78+
background: #fff;
79+
border-radius: 6px;
80+
padding: 20px;
81+
max-width: 700px;
82+
max-height: 80vh;
83+
overflow: auto;
84+
width: 90%;
85+
}
86+
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
87+
.modal-header h3 { margin: 0; }
88+
.close-btn { background: none; border: none; font-size: 20px; cursor: pointer; color: #656d76; }
89+
pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; font-size: 13px; }
90+
.events-list { padding: 0; }
91+
.event-row { display: flex; align-items: center; padding: 10px 16px; border-bottom: 1px solid #d0d7de; font-size: 13px; }
92+
.event-row:last-child { border-bottom: none; }
93+
.event-time { color: #656d76; width: 80px; flex-shrink: 0; }
94+
.event-kind { background: #ddf4ff; color: #0969da; padding: 2px 6px; border-radius: 4px; font-size: 11px; margin-right: 10px; }
95+
.event-repo { font-weight: 600; color: #0969da; margin-right: 10px; }
96+
.event-pubkey { font-family: monospace; color: #656d76; font-size: 12px; }
97+
.no-events { padding: 16px; color: #656d76; }
98+
.btn-add { background: #dafbe1; border-color: #1a7f37; color: #1a7f37; }
99+
.btn-add:hover { background: #aceebb; }
100+
.repo-desc { font-size: 12px; color: #656d76; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
101+
.discovered-empty { color: #656d76; padding: 16px; }
102+
</style>
103+
</head>
104+
<body>
105+
<div class="header">
106+
<h1>Nostr Git Sync</h1>
107+
<button class="btn" onclick="showConfig()">View Config</button>
108+
</div>
109+
<p class="refresh">Auto-refreshes every 5s | <span id="lastUpdate">Loading...</span></p>
110+
111+
<div id="error" style="display: none;"></div>
112+
113+
<div id="configModal" class="modal" onclick="closeModal(event)">
114+
<div class="modal-content" onclick="event.stopPropagation()">
115+
<div class="modal-header">
116+
<h3>repos.json</h3>
117+
<button class="close-btn" onclick="closeConfig()">&times;</button>
118+
</div>
119+
<pre id="configContent">Loading...</pre>
120+
</div>
121+
</div>
122+
123+
<h2>Repositories</h2>
124+
<div id="repos" class="grid"></div>
125+
126+
<h2>Relays</h2>
127+
<div id="relays" class="grid"></div>
128+
129+
<h2>Discover</h2>
130+
<div id="discovered" class="grid"></div>
131+
132+
<h2>Recent Events</h2>
133+
<div id="events" class="card events-list"></div>
134+
135+
<p class="uptime" id="uptime"></p>
136+
137+
<script>
138+
function timeAgo(dateStr) {
139+
if (!dateStr) return 'never';
140+
const diff = Date.now() - new Date(dateStr).getTime();
141+
const secs = Math.floor(diff / 1000);
142+
if (secs < 60) return `${secs}s ago`;
143+
const mins = Math.floor(secs / 60);
144+
if (mins < 60) return `${mins}m ago`;
145+
const hours = Math.floor(mins / 60);
146+
if (hours < 24) return `${hours}h ago`;
147+
return `${Math.floor(hours / 24)}d ago`;
148+
}
149+
150+
async function fetchStatus() {
151+
try {
152+
const res = await fetch('status.json?' + Date.now());
153+
if (!res.ok) throw new Error('Failed to fetch status');
154+
const data = await res.json();
155+
render(data);
156+
document.getElementById('error').style.display = 'none';
157+
} catch (err) {
158+
document.getElementById('error').textContent = 'Error: ' + err.message;
159+
document.getElementById('error').style.display = 'block';
160+
}
161+
document.getElementById('lastUpdate').textContent = 'Updated: ' + new Date().toLocaleTimeString();
162+
}
163+
164+
function render(data) {
165+
// Repos
166+
const reposHtml = Object.entries(data.repos || {}).map(([id, repo]) => `
167+
<div class="card">
168+
<div class="card-header">
169+
<span class="repo-name">${id}</span>
170+
<div>
171+
<button class="btn sync-btn" onclick="syncRepo('${id}')" ${repo.status === 'syncing' ? 'disabled' : ''}>
172+
${repo.status === 'syncing' ? 'Syncing...' : 'Sync'}
173+
</button>
174+
<span class="status ${repo.status || 'watching'}">${repo.status || 'watching'}</span>
175+
</div>
176+
</div>
177+
<div class="meta">
178+
<span>Syncs: <strong>${repo.syncCount || 0}</strong></span>
179+
<span>Last: <span class="time">${timeAgo(repo.lastSync)}</span></span>
180+
</div>
181+
${repo.lastCommit ? `<div class="meta">Commit: <code>${repo.lastCommit.slice(0,7)}</code></div>` : ''}
182+
<div class="path">${repo.path}</div>
183+
</div>
184+
`).join('');
185+
document.getElementById('repos').innerHTML = reposHtml || '<p class="meta">No repositories configured</p>';
186+
187+
// Relays
188+
const relaysHtml = Object.entries(data.relays || {}).map(([url, relay]) => `
189+
<div class="card">
190+
<div class="card-header">
191+
<span class="relay-url">${url}</span>
192+
<span class="status ${relay.connected ? 'connected' : 'disconnected'}">${relay.connected ? 'connected' : 'disconnected'}</span>
193+
</div>
194+
<div class="meta">
195+
Last msg: <span class="time">${timeAgo(relay.lastMessage)}</span>
196+
</div>
197+
</div>
198+
`).join('');
199+
document.getElementById('relays').innerHTML = relaysHtml || '<p class="meta">No relays configured</p>';
200+
201+
// Discovered repos (sorted by created_at like ngit)
202+
const discovered = Object.entries(data.discovered || {}).map(([key, v]) => ({...v, _key: key}));
203+
discovered.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
204+
const discoveredHtml = discovered.length ? discovered.map(d => `
205+
<div class="card">
206+
<div class="card-header">
207+
<span class="repo-name">${d.name || d.id}</span>
208+
<button class="btn btn-add" onclick="addRepo('${d._key}')">Add</button>
209+
</div>
210+
${d.description ? `<div class="repo-desc">${d.description}</div>` : ''}
211+
<div class="meta" style="margin-top: 8px;">
212+
<span class="event-pubkey">${d.pubkey.slice(0, 16)}...</span>
213+
${d.createdAt ? `<span class="time" style="margin-left: 12px;">${new Date(d.createdAt * 1000).toLocaleString()}</span>` : ''}
214+
</div>
215+
${d.cloneUrl ? `<div class="path">${d.cloneUrl}</div>` : ''}
216+
</div>
217+
`).join('') : '<p class="discovered-empty">No new repos discovered yet. Listening for 30617 events...</p>';
218+
document.getElementById('discovered').innerHTML = discoveredHtml;
219+
220+
// Recent events
221+
const events = data.recentEvents || [];
222+
const eventsHtml = events.length ? events.map(e => `
223+
<div class="event-row">
224+
<span class="event-time">${timeAgo(e.time)}</span>
225+
<span class="event-kind">${e.kind}</span>
226+
<span class="event-repo">${e.repo}</span>
227+
<span class="event-pubkey">${e.pubkey}...</span>
228+
</div>
229+
`).join('') : '<div class="no-events">No events yet</div>';
230+
document.getElementById('events').innerHTML = eventsHtml;
231+
232+
// Uptime
233+
if (data.startedAt) {
234+
document.getElementById('uptime').textContent = `Daemon started: ${new Date(data.startedAt).toLocaleString()}`;
235+
}
236+
}
237+
238+
fetchStatus();
239+
setInterval(fetchStatus, 5000);
240+
241+
async function showConfig() {
242+
document.getElementById('configModal').classList.add('open');
243+
try {
244+
const res = await fetch('config.json?' + Date.now());
245+
const data = await res.json();
246+
document.getElementById('configContent').textContent = JSON.stringify(data, null, 2);
247+
} catch (err) {
248+
document.getElementById('configContent').textContent = 'Error loading config: ' + err.message;
249+
}
250+
}
251+
252+
function closeConfig() {
253+
document.getElementById('configModal').classList.remove('open');
254+
}
255+
256+
function closeModal(event) {
257+
if (event.target.classList.contains('modal')) {
258+
closeConfig();
259+
}
260+
}
261+
262+
async function syncRepo(repoId) {
263+
try {
264+
const res = await fetch('sync/' + encodeURIComponent(repoId), { method: 'POST' });
265+
const data = await res.json();
266+
if (!data.ok) {
267+
alert('Sync failed: ' + (data.error || 'Unknown error'));
268+
}
269+
fetchStatus();
270+
} catch (err) {
271+
alert('Sync failed: ' + err.message);
272+
fetchStatus();
273+
}
274+
}
275+
276+
async function addRepo(repoId) {
277+
if (!confirm(`Add "${repoId}" to your sync config?`)) return;
278+
try {
279+
const res = await fetch('add/' + encodeURIComponent(repoId), { method: 'POST' });
280+
const data = await res.json();
281+
if (data.ok) {
282+
alert(`Added! Will sync to: ${data.path}`);
283+
} else {
284+
alert('Failed to add: ' + (data.error || 'Unknown error'));
285+
}
286+
fetchStatus();
287+
} catch (err) {
288+
alert('Failed to add: ' + err.message);
289+
fetchStatus();
290+
}
291+
}
292+
</script>
293+
</body>
294+
</html>

0 commit comments

Comments
 (0)