Add subscription debug panel (Ctrl+Shift+D)

Hidden dev tool showing NDK uptime, live subscription status,
per-relay connection state, per-tab last-updated timestamps,
and recent feed diagnostics log. Polls every 2s while visible.
Closes with Ctrl+Shift+D, Escape, or X button.
This commit is contained in:
Jure
2026-03-23 19:12:29 +01:00
parent 911cbea72d
commit 1f50afded7
8 changed files with 174 additions and 3 deletions

View File

@@ -21,6 +21,7 @@ import { HashtagFeed } from "./components/feed/HashtagFeed";
import { PodcastsView } from "./components/podcast/PodcastsView";
import { PodcastPlayerBar } from "./components/podcast/PodcastPlayerBar";
import { ToastContainer } from "./components/shared/ToastContainer";
import { DebugPanel } from "./components/shared/DebugPanel";
import { HelpModal } from "./components/shared/HelpModal";
import { useUIStore } from "./stores/ui";
import { useUpdater } from "./hooks/useUpdater";
@@ -53,6 +54,8 @@ function App() {
const currentView = useUIStore((s) => s.currentView);
const showHelp = useUIStore((s) => s.showHelp);
const toggleHelp = useUIStore((s) => s.toggleHelp);
const showDebugPanel = useUIStore((s) => s.showDebugPanel);
const toggleDebugPanel = useUIStore((s) => s.toggleDebugPanel);
const [onboardingDone, setOnboardingDone] = useState(
() => !!localStorage.getItem("wrystr_pubkey")
);
@@ -110,6 +113,7 @@ function App() {
</div>
<PodcastPlayerBar />
<ToastContainer />
{showDebugPanel && <DebugPanel onClose={toggleDebugPanel} />}
{showHelp && <HelpModal onClose={toggleHelp} />}
</div>
);

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from "react";
import { getNDK, getNDKUptimeMs } from "../../lib/nostr";
import { isLiveSubActive, useFeedStore } from "../../stores/feed";
import { getRecentDiagEntries, type DiagEntry } from "../../lib/feedDiagnostics";
interface RelayInfo {
url: string;
connected: boolean;
}
interface DebugState {
uptimeMs: number | null;
liveSubActive: boolean;
relays: RelayInfo[];
lastUpdated: Record<string, number>;
recentDiag: DiagEntry[];
}
function formatUptime(ms: number): string {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
const remSec = sec % 60;
if (min < 60) return `${min}m ${remSec}s`;
const hr = Math.floor(min / 60);
const remMin = min % 60;
return `${hr}h ${remMin}m`;
}
function shortenUrl(url: string): string {
return url.replace(/^wss?:\/\//, "").replace(/\/$/, "");
}
function timeAgo(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000);
if (sec < 5) return "just now";
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
return `${hr}h ago`;
}
function readState(): DebugState {
const ndk = getNDK();
const relays = Array.from(ndk.pool?.relays?.values() ?? []).map((r) => ({
url: r.url,
connected: r.connected,
}));
return {
uptimeMs: getNDKUptimeMs(),
liveSubActive: isLiveSubActive(),
relays,
lastUpdated: useFeedStore.getState().lastUpdated,
recentDiag: getRecentDiagEntries(5),
};
}
export function DebugPanel({ onClose }: { onClose: () => void }) {
const [state, setState] = useState<DebugState>(readState);
useEffect(() => {
const id = setInterval(() => setState(readState()), 2000);
return () => clearInterval(id);
}, []);
const connectedCount = state.relays.filter((r) => r.connected).length;
return (
<div className="fixed bottom-4 left-4 z-50 w-80 bg-bg-raised/95 backdrop-blur-sm border border-border shadow-xl text-[11px] font-mono">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<span className="text-text-muted uppercase tracking-widest text-[10px]">Debug</span>
<button onClick={onClose} className="text-text-dim hover:text-text transition-colors">×</button>
</div>
{/* Uptime + Live Sub */}
<div className="px-3 py-2 flex items-center justify-between border-b border-border/50">
<span className="text-text-dim">
NDK uptime: <span className="text-text">{state.uptimeMs !== null ? formatUptime(state.uptimeMs) : "—"}</span>
</span>
<span className="flex items-center gap-1">
<span className={`w-1.5 h-1.5 rounded-full ${state.liveSubActive ? "bg-success" : "bg-danger"}`} />
<span className="text-text-dim">live sub {state.liveSubActive ? "on" : "off"}</span>
</span>
</div>
{/* Relays */}
<div className="px-3 py-2 border-b border-border/50">
<div className="text-text-dim mb-1">Relays ({connectedCount}/{state.relays.length})</div>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{state.relays.map((r) => (
<div key={r.url} className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${r.connected ? "bg-success" : "bg-danger"}`} />
<span className="text-text-dim truncate">{shortenUrl(r.url)}</span>
</div>
))}
</div>
</div>
{/* Feed Timestamps */}
<div className="px-3 py-2 border-b border-border/50">
<div className="text-text-dim mb-1">Last updated</div>
<div className="grid grid-cols-3 gap-1">
{(["global", "following", "trending"] as const).map((tab) => (
<div key={tab}>
<span className="text-text-dim">{tab}: </span>
<span className="text-text">{state.lastUpdated[tab] ? timeAgo(state.lastUpdated[tab]) : "—"}</span>
</div>
))}
</div>
</div>
{/* Recent Diagnostics */}
<div className="px-3 py-2">
<div className="text-text-dim mb-1">Recent log</div>
{state.recentDiag.length === 0 ? (
<span className="text-text-dim">No entries</span>
) : (
<div className="space-y-0.5 max-h-28 overflow-y-auto">
{state.recentDiag.map((d, i) => (
<div key={i} className="flex items-center gap-2 text-[10px]">
<span className="text-text-dim shrink-0">
{new Date(d.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</span>
<span className="text-text truncate">{d.action}</span>
{d.durationMs !== undefined && (
<span className="text-text-dim shrink-0">{d.durationMs}ms</span>
)}
{d.eventsReturned !== undefined && (
<span className="text-text-dim shrink-0">{d.eventsReturned}ev</span>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -3,11 +3,18 @@ import { useUIStore } from "../stores/ui";
import { useFeedStore } from "../stores/feed";
export function useKeyboardShortcuts() {
const { currentView, setView, goBack, toggleHelp } = useUIStore();
const { currentView, setView, goBack, toggleHelp, showDebugPanel, toggleDebugPanel } = useUIStore();
const { focusedNoteIndex, setFocusedNoteIndex, notes } = useFeedStore();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Ctrl+Shift+D works everywhere, even in text fields
if (e.ctrlKey && e.shiftKey && e.key === "D") {
e.preventDefault();
toggleDebugPanel();
return;
}
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
switch (e.key) {
@@ -21,6 +28,7 @@ export function useKeyboardShortcuts() {
setTimeout(() => (document.querySelector("[data-search-input]") as HTMLInputElement)?.focus(), 50);
break;
case "Escape":
if (showDebugPanel) { toggleDebugPanel(); break; }
goBack();
break;
case "?":
@@ -38,5 +46,5 @@ export function useKeyboardShortcuts() {
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [currentView, focusedNoteIndex, notes.length]);
}, [currentView, focusedNoteIndex, notes.length, showDebugPanel]);
}

View File

@@ -32,6 +32,10 @@ function getLog(): DiagEntry[] {
}
}
export function getRecentDiagEntries(count = 5): DiagEntry[] {
return getLog().slice(-count).reverse();
}
function saveLog(entries: DiagEntry[]) {
localStorage.setItem(DIAG_KEY, JSON.stringify(entries.slice(-MAX_ENTRIES)));
}

View File

@@ -57,16 +57,22 @@ export function saveRelayUrls(urls: string[]) {
}
let ndk: NDK | null = null;
let ndkCreatedAt: number | null = null;
export function getNDK(): NDK {
if (!ndk) {
ndk = new NDK({
explicitRelayUrls: getStoredRelayUrls(),
});
ndkCreatedAt = Date.now();
}
return ndk;
}
export function getNDKUptimeMs(): number | null {
return ndkCreatedAt ? Date.now() - ndkCreatedAt : null;
}
/**
* Destroy the current NDK instance and create a fresh one.
* Preserves the signer (login state) but resets all relay connections.
@@ -87,6 +93,7 @@ export async function resetNDK(): Promise<void> {
ndk = new NDK({
explicitRelayUrls: getStoredRelayUrls(),
});
ndkCreatedAt = Date.now();
// Restore signer so user stays logged in
if (oldSigner) {

View File

@@ -1,4 +1,4 @@
export { getNDK, connectToRelays, ensureConnected, resetNDK, getStoredRelayUrls, addRelay, removeRelay, fetchWithTimeout, withTimeout, FEED_TIMEOUT, THREAD_TIMEOUT, SINGLE_TIMEOUT } from "./core";
export { getNDK, getNDKUptimeMs, connectToRelays, ensureConnected, resetNDK, getStoredRelayUrls, addRelay, removeRelay, fetchWithTimeout, withTimeout, FEED_TIMEOUT, THREAD_TIMEOUT, SINGLE_TIMEOUT } from "./core";
export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed, fetchThreadEvents, fetchAncestors } from "./notes";
export { publishProfile, publishContactList, fetchProfile, fetchFollowSuggestions, fetchMentions, fetchNewFollowers } from "./social";
export { publishArticle, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchByAddr } from "./articles";

View File

@@ -11,6 +11,10 @@ const MAX_FEED_SIZE = 200;
// Live subscription handle — persists across store calls
let liveSub: NDKSubscription | null = null;
export function isLiveSubActive(): boolean {
return liveSub !== null;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
interface FeedState {

View File

@@ -27,6 +27,7 @@ interface UIState {
pendingArticleEvent: NDKEvent | null;
pendingHashtag: string | null;
showHelp: boolean;
showDebugPanel: boolean;
feedLanguageFilter: string | null;
setView: (view: View) => void;
setFeedTab: (tab: FeedTab) => void;
@@ -40,6 +41,7 @@ interface UIState {
setFeedLanguageFilter: (filter: string | null) => void;
toggleSidebar: () => void;
toggleHelp: () => void;
toggleDebugPanel: () => void;
}
const SIDEBAR_KEY = "wrystr_sidebar_collapsed";
@@ -58,6 +60,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
pendingArticleEvent: null,
pendingHashtag: null,
showHelp: false,
showDebugPanel: false,
feedLanguageFilter: null,
setView: (currentView) => set({ currentView }),
setFeedTab: (feedTab) => set({ feedTab }),
@@ -94,4 +97,5 @@ export const useUIStore = create<UIState>((set, _get) => ({
return { sidebarCollapsed: next };
}),
toggleHelp: () => set((s) => ({ showHelp: !s.showHelp })),
toggleDebugPanel: () => set((s) => ({ showDebugPanel: !s.showDebugPanel })),
}));