Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
// unknown ?tab value falls back to Packets instead of rendering a blank pane.
// "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working.
const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab");
const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer";
const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets");
const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets");
// Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection).
const [initialSelection] = useState(() => computeInitialSelection(searchParams));

Expand All @@ -170,9 +169,6 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null;
const [selectedObservationId, setSelectedObservationId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(() => searchParams.get("node"));
useEffect(() => {
if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true });
}, [legacyObserver, searchParams, setSearchParams]);
const [panels, setPanels] = useState<Investigation[]>([]);
const scene = JSON.stringify([activeTab, searchParams.get("route"), searchParams.get("hash"), searchParams.get("node"), searchParams.get("observer"), searchParams.get("analyze")]);
const [panelScene, setPanelScene] = useState(scene);
Expand Down Expand Up @@ -319,7 +315,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
Traces: <TraceList onAnalyze={hash => { if (hash) viewPacket(hash); }} onViewNode={viewNode} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Analytics: <StatsOverview wsManager={wsManager} onObserverDashboard={handleViewObserverStats} />,
Analytics: <StatsOverview wsManager={wsManager} />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
};

Expand Down
161 changes: 113 additions & 48 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ import { useRateLimit } from "../hooks/useRateLimit";
import { useTheme } from "../hooks/useTheme";
import { Dropdown } from "./Dropdown";
import { BottomNav } from "./BottomNav";
import { LanguagePicker } from "./LanguagePicker";
import { LanguageOptions, LanguagePicker } from "./LanguagePicker";
import { useIsMobile } from "../hooks/useMediaQuery";
import { BeaconWordmark } from "./BeaconWordmark";
import { getIatas } from "../api/client";
import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants";
import type { WsManager } from "../api/ws-manager";

// header widgets: WS status, region picker, theme picker

function LiveBadge({ wsManager }: { wsManager: WsManager }) {
function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compact?: boolean }) {
const { t } = useTranslation();
const { status } = useWsStatus(wsManager);
const [staleStr, setStaleStr] = useState("");
Expand All @@ -35,6 +36,11 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) {
}, [status, wsManager]);

if (status === "connected") {
if (compact) return (
<span role="status" aria-label={t("connection.live")} title={t("connection.live")} className="flex h-6 w-6 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-green animate-pulse" />
</span>
);
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-green bg-green/8 border border-green/15 px-2 py-0.5 rounded-sm">
<span className="w-1.5 h-1.5 rounded-full bg-green animate-pulse" />
Expand Down Expand Up @@ -306,9 +312,8 @@ function RegionSelectorPanel() {

function ThemePicker() {
const { t } = useTranslation();
const { themeId, themes, setThemeId } = useTheme();
const { themeId, themes } = useTheme();
const current = themes.find((t) => t.id === themeId);
const list = selectableThemes(themes, ENABLED_THEME_IDS);

return (
<Dropdown
Expand All @@ -326,30 +331,85 @@ function ThemePicker() {
<span className="text-text-dim text-[11px]">▾</span>
</button>
)}
>
{(close) => <ThemeOptions onPick={close} />}
</Dropdown>
);
}

function ThemeOptions({ onPick }: { onPick?: () => void }) {
const { themeId, themes, setThemeId } = useTheme();
return (
<>
{selectableThemes(themes, ENABLED_THEME_IDS).map((theme) => (
<button
key={theme.id}
type="button"
aria-pressed={theme.id === themeId}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
theme.id === themeId ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => {
setThemeId(theme.id);
onPick?.();
}}
>
<span className="w-3 h-3 rounded-full shrink-0 border border-text-normal/20" style={{ background: theme.vars["--palette-primary"] }} />
{theme.name}
</button>
))}
</>
);
}

function GitHubLink() {
return (
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-text-muted hover:text-text-normal transition-colors shrink-0"
>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
</a>
);
}

const MENU_GROUP = "px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim";

// Phones fold theme, language and the repo link into one menu so the header stays a single row.
function SettingsMenu() {
const { t } = useTranslation();
return (
<Dropdown
width="w-56"
renderTrigger={({ open, toggle }) => (
<button
type="button"
aria-label={t("header.settings")}
aria-expanded={open}
onClick={toggle}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-bg-raised text-text-muted hover:text-text-normal"
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
<path d="M2.5 4h11M2.5 8h11M2.5 12h11" />
</svg>
</button>
)}
>
{(close) => (
<>
{list.map((t) => (
<button
key={t.id}
type="button"
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
t.id === themeId
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => {
setThemeId(t.id);
close();
}}
>
<span
className="w-3 h-3 rounded-full shrink-0 border border-text-normal/20"
style={{ background: t.vars["--palette-primary"] }}
/>
{t.name}
</button>
))}
<div className={MENU_GROUP}>{t("language.label")}</div>
<LanguageOptions onPick={close} />
<div className={`${MENU_GROUP} mt-1 border-t border-border-subtle`}>{t("theme.label")}</div>
<ThemeOptions onPick={close} />
<div className="mt-1 flex items-center gap-2 border-t border-border-subtle px-3 py-2 font-mono text-xs text-text-muted">
<GitHubLink />
<span>{APP_NAME} v{__APP_VERSION__}</span>
</div>
</>
)}
</Dropdown>
Expand All @@ -367,31 +427,36 @@ interface AppShellProps {

export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
return (
<div className="flex flex-col h-dvh">
<header className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-3 py-1.5 min-h-[42px] md:flex md:gap-3 md:px-4 bg-bg-surface border-b border-border shrink-0">
<BeaconWordmark iconSize={22} textClassName="text-sm truncate" className="min-w-0" />
<div className="col-span-2 row-start-2 flex flex-wrap items-center justify-between gap-1.5 min-w-0 md:ml-auto md:flex-nowrap md:justify-start md:gap-3">
<RegionSelector />
<ThemePicker />
<LanguagePicker />
</div>
<div className="col-start-2 row-start-1 flex items-center gap-1.5 md:gap-3">
<LiveBadge wsManager={wsManager} />
<RateLimitBadge />
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-text-muted hover:text-text-normal transition-colors shrink-0"
>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
</a>
</div>
</header>
{isMobile ? (
<header className="flex items-center gap-2 px-3 py-1.5 min-h-[42px] bg-bg-surface border-b border-border shrink-0">
<BeaconWordmark iconSize={20} textClassName="text-sm" className="shrink-0" />
<div className="min-w-0">
<RegionSelector />
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<RateLimitBadge />
<LiveBadge wsManager={wsManager} compact />
<SettingsMenu />
</div>
</header>
) : (
<header className="flex items-center gap-3 px-4 py-1.5 min-h-[42px] bg-bg-surface border-b border-border shrink-0">
<BeaconWordmark iconSize={22} textClassName="text-sm truncate" className="min-w-0" />
<div className="ml-auto flex items-center gap-3">
<RegionSelector />
<ThemePicker />
<LanguagePicker />
</div>
<div className="flex items-center gap-3">
<LiveBadge wsManager={wsManager} />
<RateLimitBadge />
<GitHubLink />
</div>
</header>
)}

<nav className="hidden md:flex bg-bg-surface border-b border-border px-4 shrink-0" role="tablist">
{ENABLED_TABS.map((tab) => (
Expand Down
23 changes: 23 additions & 0 deletions src/components/InfoTip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "./Tooltip";

// Small (i) marker that holds explanatory notes, keeping analytics pages to headings and numbers.
export function InfoTip({ text }: { text: string | (string | false | null | undefined)[] }) {
const { t } = useTranslation();
const lines = (Array.isArray(text) ? text : [text]).filter((line): line is string => !!line);
// preventDefault stops a tip inside <summary> from toggling its <details>
const stop = (e: MouseEvent) => e.preventDefault();
return (
<Tooltip wrap className="align-middle" label={lines.map((line) => <span key={line} className="mt-1.5 block first:mt-0">{line}</span>)}>
<button
type="button"
onClick={stop}
className="inline-flex h-3.5 w-3.5 shrink-0 cursor-help items-center justify-center rounded-full border border-text-dim font-mono text-[9px] font-semibold normal-case leading-none tracking-normal text-text-muted hover:border-text-muted hover:text-text-bright"
>
<span aria-hidden>i</span>
<span className="sr-only">{t("common.info")}: {lines.join(" ")}</span>
</button>
</Tooltip>
);
}
45 changes: 27 additions & 18 deletions src/components/LanguagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,26 +42,35 @@ export function LanguagePicker() {
}
}}
>
{languages.map(({ code, name }) => (
<button
key={code}
type="button"
aria-pressed={language === code}
onClick={() => {
void i18n.changeLanguage(code);
close();
triggerRef.current?.focus();
}}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
language === code ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
>
<span aria-hidden="true" className="font-semibold text-primary min-w-8 shrink-0">{code.toUpperCase()}</span>
<span lang={code}>{name}</span>
</button>
))}
<LanguageOptions onPick={() => { close(); triggerRef.current?.focus(); }} />
</div>
)}
</Dropdown>
);
}

export function LanguageOptions({ onPick }: { onPick?: () => void }) {
const { i18n } = useTranslation();
const language = i18n.resolvedLanguage ?? "en";
return (
<>
{languages.map(({ code, name }) => (
<button
key={code}
type="button"
aria-pressed={language === code}
onClick={() => {
void i18n.changeLanguage(code);
onPick?.();
}}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
language === code ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
>
<span aria-hidden="true" className="font-semibold text-primary min-w-8 shrink-0">{code.toUpperCase()}</span>
<span lang={code}>{name}</span>
</button>
))}
</>
);
}
6 changes: 4 additions & 2 deletions src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useHasHover } from "../hooks/useMediaQuery";

// Portals to <body> with fixed positioning so overflow parents (the data tables) can't clip it.
// Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap.
export function Tooltip({ label, children, className = "" }: { label: ReactNode; children: ReactNode; className?: string }) {
export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) {
const hasHover = useHasHover();
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
Expand Down Expand Up @@ -60,6 +60,8 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode;
onMouseEnter={hasHover ? show : undefined}
onMouseLeave={hasHover ? hide : undefined}
onClick={hasHover ? undefined : toggle}
onFocus={hasHover ? show : undefined}
onBlur={hasHover ? hide : undefined}
className={`inline-flex ${className}`}
>
{children}
Expand All @@ -69,7 +71,7 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode;
ref={tipRef}
role="tooltip"
style={{ left: pos.left, top: pos.top }}
className="fixed z-50 pointer-events-none whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg"
className={`fixed z-50 pointer-events-none ${wrap ? "max-w-xs whitespace-normal leading-relaxed" : "whitespace-nowrap"} rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg`}
>
{label}
</span>,
Expand Down
Loading
Loading