Skip to content

Commit 83fea30

Browse files
author
The No Hands Company
committed
feat: SiteSettings tabs, Admin user/site management, diff UI, clone/transfer, Grafana dashboards
SiteSettings — Custom Domains tab (DomainsPanel) - Add domain form with CNAME instruction pointing to site's primary domain - TXT verification record displayed inline with one-click copy button - Verify button calls POST /domains/:id/verify — shows DNS check result live - Status badges: pending / verified / failed with last error message - Delete domain with DELETE /domains/:id SiteSettings — Team Members tab (TeamPanel) - Invite by email + role selector (viewer / editor / admin) - Current members list: avatar initial, name, email, role badge, remove button - Pending invitations: email, expiry, revoke button - All mutations invalidate relevant query keys Admin — Users tab (AdminUsersTab) - Paginated table from GET /admin/users (25 per page) - Client-side filter by name or email - Columns: avatar, name, email, site count, join date - Prev/Next pagination controls Admin — All Sites tab (AdminSitesTab) - Paginated table from GET /admin/sites (25 per page) - Client-side filter by name, domain, or owner email - Columns: status dot, name, domain, owner, storage, link to site detail Deployment diff — visual inline panel (DeploymentDiff) - Replaces raw JSON link with a toggleable button (GitCompare icon) - Clicking 'diff' expands an inline panel below the deployment row - Summary bar: +N added / ~N changed / -N removed / net size delta - Per-section file lists (added/changed/removed) with path and size - Lists truncated at 20 with '...and N more' footer - Uses existing GET /api/sites/:id/deployments/:depId/diff endpoint Clone / Transfer / Export — MoreActions dropdown on MySites cards - ⋯ button opens DropdownMenu: Clone site / Transfer ownership / Export manifest - Clone dialog: new name + new domain inputs, submits POST /sites/:id/clone Files reused — objectPaths shared, no storage duplicated - Transfer dialog: new owner email, amber warning, POST /sites/:id/transfer 24h acceptance window notice - Export: fetches GET /sites/:id/export and saves as {domain}-export.json - All backed by existing endpoints, no new backend routes needed Grafana monitoring stack (monitoring/) - monitoring/README.md: setup guide, metrics catalogue, docker-compose snippet - monitoring/grafana/datasources.yaml: Prometheus datasource provisioning - monitoring/grafana/dashboards.yaml: dashboard folder provisioning - monitoring/grafana/dashboards/node-overview.json: HTTP rate, latency p50/p95/p99, Node.js memory, event loop lag, DB pool connections, storage ops, deploy rate - monitoring/grafana/dashboards/federation-health.json: peer status, sync rate, signature verification, retry queue, blocked request attempts - monitoring/grafana/dashboards/site-traffic.json: per-site hits/bandwidth, cache hit rate, top-10 sites by hits and bandwidth (with variable) ROADMAP.md - Content deduplication: 🔮 → ✅ (was already built, roadmap was stale) - Prometheus + Grafana: 🔮 → ✅ (split into two rows, both done) - Virtual scrolling: annotated as deferred (admin lists paginated instead)
1 parent f13fc12 commit 83fea30

File tree

11 files changed

+1176
-16
lines changed

11 files changed

+1176
-16
lines changed

ROADMAP.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,13 @@ A living document tracking what is built, what is in progress, and what must be
165165
| Feature | Status | Notes |
166166
|---|---|---|
167167
| Paid plans / node sponsorship | 🔮 | Revenue model not designed |
168-
| Prometheus metrics + Grafana dashboards | 🔮 | |
168+
| Prometheus metrics || `prom-client`, 12 metrics + Node.js defaults, `/metrics` endpoint |
169+
| Grafana dashboards || `monitoring/grafana/` — node overview, federation health, site traffic |
169170
| OpenTelemetry distributed tracing | 🔮 | |
170-
| Virtual scrolling for large lists | 🔮 | |
171+
| Virtual scrolling for large lists | 🔮 | Admin lists paginated; virtual scroll deferred |
171172
| CDN integration guide | 🔮 | |
172173
| Multi-region PostgreSQL (read replicas) | 🔮 | |
173-
| Content deduplication (file hash) | 🔮 | |
174+
| Content deduplication (file hash) | | `content_hash` column, dedup on upload, cross-site reuse |
174175

175176
---
176177

artifacts/federated-hosting/src/pages/Admin.tsx

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {
1111
Server, Users, Globe, Activity, HardDrive, Cpu, MemoryStick,
1212
TrendingUp, Settings, LogIn, RefreshCw, Zap, Radio, Loader2,
1313
ClipboardList, HeartPulse, CheckCircle2, AlertTriangle, XCircle,
14+
ExternalLink,
1415
} from "lucide-react";
16+
import { Link } from "wouter";
1517
import { motion } from "framer-motion";
1618
import { formatDistanceToNow, format } from "date-fns";
1719
import { useToast } from "@/components/ui/use-toast";
@@ -338,6 +340,161 @@ export default function AdminPage() {
338340
);
339341
}
340342

343+
// ── Admin Users tab ───────────────────────────────────────────────────────────
344+
345+
interface AdminUser { id: string; email: string; firstName: string | null; lastName: string | null; createdAt: string; siteCount: number; }
346+
347+
function AdminUsersTab() {
348+
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
349+
const [page, setPage] = useState(1);
350+
const [search, setSearch] = useState("");
351+
352+
const { data, isLoading } = useQuery<{ data: AdminUser[]; meta: { total: number; page: number; limit: number } }>({
353+
queryKey: ["admin-users", page],
354+
queryFn: async () => {
355+
const r = await fetch(`${BASE}/api/admin/users?page=${page}&limit=25`, { credentials: "include" });
356+
return r.ok ? r.json() : { data: [], meta: { total: 0, page: 1, limit: 25 } };
357+
},
358+
staleTime: 30_000,
359+
});
360+
361+
const users = (data?.data ?? []).filter(u =>
362+
!search || u.email?.toLowerCase().includes(search.toLowerCase()) ||
363+
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
364+
);
365+
const meta = data?.meta;
366+
367+
return (
368+
<div className="space-y-3">
369+
<div className="flex items-center gap-3">
370+
<input
371+
type="search" placeholder="Filter by name or email…" value={search}
372+
onChange={e => setSearch(e.target.value)}
373+
className="flex-1 bg-muted/20 border border-white/8 rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:border-primary/40"
374+
/>
375+
<span className="text-xs text-muted-foreground shrink-0">{meta?.total ?? "…"} total</span>
376+
</div>
377+
378+
{isLoading ? <div className="flex items-center gap-2 text-muted-foreground py-4"><Loader2 className="w-4 h-4 animate-spin" />Loading…</div> : (
379+
<>
380+
<div className="divide-y divide-white/5 border border-white/5 rounded-xl overflow-hidden">
381+
{users.map(u => (
382+
<div key={u.id} className="flex items-center gap-3 px-4 py-3 hover:bg-white/2">
383+
<div className="w-8 h-8 rounded-full bg-primary/20 border border-primary/20 flex items-center justify-center text-primary text-xs font-bold shrink-0">
384+
{(u.firstName?.[0] ?? u.email?.[0] ?? "?").toUpperCase()}
385+
</div>
386+
<div className="flex-1 min-w-0">
387+
<p className="text-white text-sm truncate">
388+
{u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : u.email}
389+
</p>
390+
<p className="text-muted-foreground text-xs truncate">{u.email}</p>
391+
</div>
392+
<div className="text-right shrink-0 hidden sm:block">
393+
<p className="text-white text-sm">{u.siteCount}</p>
394+
<p className="text-muted-foreground text-xs">sites</p>
395+
</div>
396+
<p className="text-muted-foreground text-xs shrink-0 hidden md:block">
397+
{formatDistanceToNow(new Date(u.createdAt), { addSuffix: true })}
398+
</p>
399+
</div>
400+
))}
401+
{users.length === 0 && <p className="px-4 py-8 text-center text-muted-foreground text-sm">No users found.</p>}
402+
</div>
403+
404+
{meta && meta.total > meta.limit && (
405+
<div className="flex items-center justify-between text-xs text-muted-foreground">
406+
<span>Page {page} of {Math.ceil(meta.total / meta.limit)}</span>
407+
<div className="flex gap-2">
408+
<Button size="sm" variant="outline" className="h-7 border-white/10" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>Prev</Button>
409+
<Button size="sm" variant="outline" className="h-7 border-white/10" onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(meta.total / meta.limit)}>Next</Button>
410+
</div>
411+
</div>
412+
)}
413+
</>
414+
)}
415+
</div>
416+
);
417+
}
418+
419+
// ── Admin Sites tab ───────────────────────────────────────────────────────────
420+
421+
interface AdminSite { id: number; name: string; domain: string; status: string; visibility: string; ownerEmail: string | null; storageUsedMb: number; hitCount: number; createdAt: string; }
422+
423+
function AdminSitesTab() {
424+
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
425+
const [page, setPage] = useState(1);
426+
const [search, setSearch] = useState("");
427+
428+
const { data, isLoading } = useQuery<{ data: AdminSite[]; meta: { total: number; page: number; limit: number } }>({
429+
queryKey: ["admin-sites", page],
430+
queryFn: async () => {
431+
const r = await fetch(`${BASE}/api/admin/sites?page=${page}&limit=25`, { credentials: "include" });
432+
return r.ok ? r.json() : { data: [], meta: { total: 0, page: 1, limit: 25 } };
433+
},
434+
staleTime: 30_000,
435+
});
436+
437+
const sites = (data?.data ?? []).filter(s =>
438+
!search || s.name?.toLowerCase().includes(search.toLowerCase()) ||
439+
s.domain?.toLowerCase().includes(search.toLowerCase()) ||
440+
s.ownerEmail?.toLowerCase().includes(search.toLowerCase())
441+
);
442+
const meta = data?.meta;
443+
444+
const STATUS_DOT: Record<string, string> = {
445+
active: "bg-status-active", inactive: "bg-muted-foreground", maintenance: "bg-amber-400",
446+
};
447+
448+
return (
449+
<div className="space-y-3">
450+
<div className="flex items-center gap-3">
451+
<input
452+
type="search" placeholder="Filter by name, domain, or owner…" value={search}
453+
onChange={e => setSearch(e.target.value)}
454+
className="flex-1 bg-muted/20 border border-white/8 rounded-lg px-3 py-2 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:border-primary/40"
455+
/>
456+
<span className="text-xs text-muted-foreground shrink-0">{meta?.total ?? "…"} total</span>
457+
</div>
458+
459+
{isLoading ? <div className="flex items-center gap-2 text-muted-foreground py-4"><Loader2 className="w-4 h-4 animate-spin" />Loading…</div> : (
460+
<>
461+
<div className="divide-y divide-white/5 border border-white/5 rounded-xl overflow-hidden">
462+
{sites.map(s => (
463+
<div key={s.id} className="flex items-center gap-3 px-4 py-3 hover:bg-white/2 group">
464+
<span className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[s.status] ?? "bg-muted-foreground"}`} />
465+
<div className="flex-1 min-w-0">
466+
<p className="text-white text-sm font-semibold truncate">{s.name}</p>
467+
<p className="text-muted-foreground text-xs font-mono truncate">{s.domain}</p>
468+
</div>
469+
<div className="text-right shrink-0 hidden sm:block">
470+
<p className="text-white text-xs truncate max-w-[160px]">{s.ownerEmail ?? "—"}</p>
471+
</div>
472+
<div className="text-right shrink-0 hidden md:block">
473+
<p className="text-white text-xs">{s.storageUsedMb.toFixed(1)} MB</p>
474+
</div>
475+
<Link href={`/sites/${s.id}`}>
476+
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
477+
</Link>
478+
</div>
479+
))}
480+
{sites.length === 0 && <p className="px-4 py-8 text-center text-muted-foreground text-sm">No sites found.</p>}
481+
</div>
482+
483+
{meta && meta.total > meta.limit && (
484+
<div className="flex items-center justify-between text-xs text-muted-foreground">
485+
<span>Page {page} of {Math.ceil(meta.total / meta.limit)}</span>
486+
<div className="flex gap-2">
487+
<Button size="sm" variant="outline" className="h-7 border-white/10" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>Prev</Button>
488+
<Button size="sm" variant="outline" className="h-7 border-white/10" onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(meta.total / meta.limit)}>Next</Button>
489+
</div>
490+
</div>
491+
)}
492+
</>
493+
)}
494+
</div>
495+
);
496+
}
497+
341498
// ── Processes tab ─────────────────────────────────────────────────────────────
342499

343500
interface ProcessInfo {
@@ -554,9 +711,9 @@ function SiteHealthTab() {
554711
</div>
555712
)}
556713

557-
{/* ── Secondary tabs: Audit Log + Site Health + Processes ── */}
714+
{/* ── Secondary tabs: Audit Log + Site Health + Processes + Users + Sites ── */}
558715
<Tabs defaultValue="audit">
559-
<TabsList className="bg-muted/30 border border-white/5">
716+
<TabsList className="bg-muted/30 border border-white/5 flex-wrap h-auto">
560717
<TabsTrigger value="audit" className="gap-1.5">
561718
<ClipboardList className="w-3.5 h-3.5" />Audit Log
562719
</TabsTrigger>
@@ -566,6 +723,12 @@ function SiteHealthTab() {
566723
<TabsTrigger value="processes" className="gap-1.5">
567724
<Cpu className="w-3.5 h-3.5" />Processes
568725
</TabsTrigger>
726+
<TabsTrigger value="users" className="gap-1.5">
727+
<Users className="w-3.5 h-3.5" />Users
728+
</TabsTrigger>
729+
<TabsTrigger value="sites" className="gap-1.5">
730+
<Globe className="w-3.5 h-3.5" />All Sites
731+
</TabsTrigger>
569732
</TabsList>
570733
<TabsContent value="audit" className="mt-4">
571734
<AuditLogTab />
@@ -576,6 +739,12 @@ function SiteHealthTab() {
576739
<TabsContent value="processes" className="mt-4">
577740
<ProcessesTab />
578741
</TabsContent>
742+
<TabsContent value="users" className="mt-4">
743+
<AdminUsersTab />
744+
</TabsContent>
745+
<TabsContent value="sites" className="mt-4">
746+
<AdminSitesTab />
747+
</TabsContent>
579748
</Tabs>
580749
</div>
581750
);

artifacts/federated-hosting/src/pages/DeploySite.tsx

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
Upload, FileIcon, Rocket, CheckCircle, Clock, ArrowLeft,
77
Globe, ExternalLink, AlertCircle, Loader2, FolderOpen, Trash2,
88
RotateCcw, Eye, ChevronDown, ChevronRight, BarChart2,
9-
FileText, Image, Code, Package,
10-
} from "lucide-react";import { Button } from "@/components/ui/button";
9+
FileText, Image, Code, Package, GitCompare, Plus, Minus, RefreshCw,
10+
Copy, GitBranch, Share2,
11+
} from "lucide-react";
1112
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
1213
import { Badge } from "@/components/ui/badge";
1314
import { Progress } from "@/components/ui/progress";
@@ -133,10 +134,115 @@ function PreviewPanel({ files, queue }: { files: SiteFile[]; queue: UploadItem[]
133134
);
134135
}
135136

137+
// ── Deployment Diff ──────────────────────────────────────────────────────────
138+
139+
interface DiffFile { filePath: string; sizeBytes: number; contentType?: string; }
140+
interface DiffResult {
141+
summary: { added: number; changed: number; removed: number; unchanged: number; netSizeBytes: number };
142+
added: DiffFile[]; changed: DiffFile[]; removed: DiffFile[];
143+
}
144+
145+
function DeploymentDiff({ siteId, depId, onClose }: { siteId: number; depId: number; onClose: () => void }) {
146+
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
147+
148+
const { data: diff, isLoading, error } = useQuery<DiffResult>({
149+
queryKey: ["dep-diff", siteId, depId],
150+
queryFn: async () => {
151+
const r = await fetch(`${BASE}/api/sites/${siteId}/deployments/${depId}/diff`, { credentials: "include" });
152+
if (!r.ok) throw new Error("Failed to load diff");
153+
return r.json();
154+
},
155+
staleTime: 300_000,
156+
});
157+
158+
function formatBytes(n: number) {
159+
if (Math.abs(n) < 1024) return `${n} B`;
160+
if (Math.abs(n) < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
161+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
162+
}
163+
164+
return (
165+
<motion.div
166+
initial={{ opacity: 0, y: 8 }}
167+
animate={{ opacity: 1, y: 0 }}
168+
exit={{ opacity: 0, y: 8 }}
169+
className="bg-card border border-white/8 rounded-xl overflow-hidden"
170+
>
171+
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5">
172+
<div className="flex items-center gap-2 text-sm">
173+
<GitCompare className="w-4 h-4 text-primary" />
174+
<span className="text-white font-semibold">Deployment #{depId} diff</span>
175+
<span className="text-muted-foreground">vs previous version</span>
176+
</div>
177+
<button onClick={onClose} className="text-muted-foreground hover:text-white text-lg leading-none">×</button>
178+
</div>
179+
180+
{isLoading && (
181+
<div className="flex items-center gap-2 text-muted-foreground text-sm p-4">
182+
<Loader2 className="w-4 h-4 animate-spin" />Loading diff…
183+
</div>
184+
)}
185+
186+
{error && (
187+
<p className="text-red-400 text-sm p-4">Failed to load diff — this may be the first deployment.</p>
188+
)}
189+
190+
{diff && (
191+
<div className="p-4 space-y-4">
192+
{/* Summary bar */}
193+
<div className="flex flex-wrap gap-3 text-xs">
194+
{diff.summary.added > 0 && (
195+
<span className="flex items-center gap-1 text-status-active">
196+
<Plus className="w-3 h-3" />{diff.summary.added} added
197+
</span>
198+
)}
199+
{diff.summary.changed > 0 && (
200+
<span className="flex items-center gap-1 text-amber-400">
201+
<RefreshCw className="w-3 h-3" />{diff.summary.changed} changed
202+
</span>
203+
)}
204+
{diff.summary.removed > 0 && (
205+
<span className="flex items-center gap-1 text-red-400">
206+
<Minus className="w-3 h-3" />{diff.summary.removed} removed
207+
</span>
208+
)}
209+
<span className="text-muted-foreground ml-auto">
210+
net {diff.summary.netSizeBytes >= 0 ? "+" : ""}{formatBytes(diff.summary.netSizeBytes)}
211+
</span>
212+
</div>
213+
214+
{/* File lists */}
215+
{[
216+
{ files: diff.added, label: "Added", icon: Plus, color: "text-status-active", bg: "bg-status-active/5 border-status-active/15" },
217+
{ files: diff.changed, label: "Changed", icon: RefreshCw, color: "text-amber-400", bg: "bg-amber-400/5 border-amber-400/15" },
218+
{ files: diff.removed, label: "Removed", icon: Minus, color: "text-red-400", bg: "bg-red-400/5 border-red-400/15" },
219+
].map(({ files, label, icon: Icon, color, bg }) => files.length > 0 && (
220+
<div key={label}>
221+
<p className={`text-xs font-semibold ${color} mb-1.5`}>{label} ({files.length})</p>
222+
<div className={`border rounded-lg overflow-hidden ${bg}`}>
223+
{files.slice(0, 20).map((f, i) => (
224+
<div key={i} className="flex items-center justify-between px-3 py-1.5 border-b border-white/5 last:border-0 text-xs">
225+
<span className="font-mono text-white/80 truncate flex-1">{f.filePath}</span>
226+
<span className="text-muted-foreground shrink-0 ml-2">{formatBytes(f.sizeBytes)}</span>
227+
</div>
228+
))}
229+
{files.length > 20 && (
230+
<p className="px-3 py-1.5 text-muted-foreground text-xs">…and {files.length - 20} more</p>
231+
)}
232+
</div>
233+
</div>
234+
))}
235+
</div>
236+
)}
237+
</motion.div>
238+
);
239+
}
240+
136241
function DeploymentHistory({ siteId, deployments, onRollback, isRollingBack, rollingBackId }: {
137242
siteId:number; deployments:Deployment[];
138243
onRollback:(depId:number)=>void; isRollingBack:boolean; rollingBackId:number|null;
139244
}) {
245+
const [diffingDepId, setDiffingDepId] = useState<number|null>(null);
140246
const STATUS_STYLE: Record<string,string> = {
141247
active:"border-status-active/30 text-status-active",
142248
pending:"border-amber-400/30 text-amber-400",
@@ -183,12 +289,13 @@ function DeploymentHistory({ siteId, deployments, onRollback, isRollingBack, rol
183289
<span className="flex items-center justify-between">
184290
<span>{d.deployedBy?.startsWith("federation:") ? <span className="text-secondary">↙ replicated</span> : formatDistanceToNow(new Date(d.deployedAt),{addSuffix:true})}</span>
185291
{d.version > 1 && (
186-
<a href={`${import.meta.env.BASE_URL?.replace(/\/$/,"")}/api/sites/${siteId}/deployments/${d.id}/diff`}
187-
target="_blank" rel="noopener noreferrer"
188-
className="text-muted-foreground hover:text-primary transition-colors"
189-
title="View diff vs previous version">
190-
diff
191-
</a>
292+
<button
293+
onClick={() => setDiffingDepId(diffingDepId === d.id ? null : d.id)}
294+
className={`flex items-center gap-1 transition-colors text-xs ${diffingDepId === d.id ? "text-primary" : "text-muted-foreground hover:text-primary"}`}
295+
title="Show diff vs previous version"
296+
>
297+
<GitCompare className="w-3 h-3" />diff
298+
</button>
192299
)}
193300
</span>
194301
</div>
@@ -198,6 +305,19 @@ function DeploymentHistory({ siteId, deployments, onRollback, isRollingBack, rol
198305
</div>
199306
)}
200307
</CardContent>
308+
309+
{/* Inline diff panel */}
310+
<AnimatePresence>
311+
{diffingDepId !== null && (
312+
<div className="px-6 pb-4">
313+
<DeploymentDiff
314+
siteId={siteId}
315+
depId={diffingDepId}
316+
onClose={() => setDiffingDepId(null)}
317+
/>
318+
</div>
319+
)}
320+
</AnimatePresence>
201321
</Card>
202322
);
203323
}

0 commit comments

Comments
 (0)