mirror of
https://github.com/Twigpine/node-explorer.git
synced 2026-11-10 13:11:36 -10:00
fix: stop presenting the gossip-only events feed as containing local pushes
The node's global ref-updates feed only carries events gossiped in from peers (received_ref_updates has from_peer NOT NULL); pushes received directly by this node are recorded as per-repo signed certificates and never appear there. The events page claimed otherwise: a permanent 'local: 0' stat, a local/gossip filter that could never match, an empty state promising 'push a repo to this node to see activity', and a search that came up empty for freshly pushed repos — reading as a failed write to anyone verifying a push. Describe the feed as peer gossip, replace the dead local stat and source filter with a distinct-peer-nodes count, and add a local pushes panel: searching now also matches repos by name/owner/description and shows their signed push certificates from the per-repo events feed, with links to the repo page.
This commit is contained in:
1 parent
2d873b3914
commit
8e941ee1d7
4 files changed
+152
-46
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Circle } from 'lucide-react';
|
||||
import {
|
||||
fetchRepos, fetchEvents, didKeySegment, shortDid, shortRefName, shortSha, timeAgo,
|
||||
} from '../../lib/api';
|
||||
import type { ApiRepo, ApiRepoEvent } from '../../lib/api';
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
||||
import { MicroLabel } from '../ui/MicroLabel';
|
||||
|
||||
interface RepoPushes {
|
||||
repo: ApiRepo;
|
||||
pushes: ApiRepoEvent[];
|
||||
}
|
||||
|
||||
const MAX_REPOS = 3;
|
||||
const MAX_PUSHES_PER_REPO = 3;
|
||||
// The node's /repos endpoint has no server-side text search (the `q` param is
|
||||
// behind SERVER_SEARCH_ENABLED and unsupported by the node today), so match
|
||||
// against one page of the most-recently-updated repos — a freshly pushed repo,
|
||||
// the case this panel exists for, is at the top of that ordering.
|
||||
const SEARCH_WINDOW = 200;
|
||||
|
||||
function matchesQuery(repo: ApiRepo, q: string): boolean {
|
||||
return (
|
||||
repo.name.toLowerCase().includes(q) ||
|
||||
(repo.description ?? '').toLowerCase().includes(q) ||
|
||||
repo.owner_did.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local pushes matching the current search, looked up from repo records.
|
||||
*
|
||||
* The global ref-updates feed only carries gossip from peer nodes, so a push
|
||||
* received directly by this node never appears in it. Without this panel a
|
||||
* search for a freshly pushed repo comes up empty and reads as a failed
|
||||
* write. One bounded repo lookup per (debounced) search term.
|
||||
*/
|
||||
export function LocalPushPanel({ query }: { query: string }) {
|
||||
const debounced = useDebouncedValue(query.trim(), 300);
|
||||
// Keyed by the query it answers, so a stale result never renders for a new
|
||||
// search and the effect needs no synchronous reset.
|
||||
const [loaded, setLoaded] = useState<{ forQuery: string; data: RepoPushes[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debounced) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
const { repos } = await fetchRepos({
|
||||
limit: SEARCH_WINDOW, offset: 0, signal: controller.signal,
|
||||
});
|
||||
const q = debounced.toLowerCase();
|
||||
const matches = repos.filter(repo => matchesQuery(repo, q)).slice(0, MAX_REPOS);
|
||||
const withPushes = await Promise.all(
|
||||
matches.map(async repo => {
|
||||
const events = await fetchEvents(
|
||||
didKeySegment(repo.owner_did), repo.name, controller.signal,
|
||||
).catch(() => []);
|
||||
return {
|
||||
repo,
|
||||
pushes: events.filter(e => e.source === 'local').slice(0, MAX_PUSHES_PER_REPO),
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (!controller.signal.aborted) setLoaded({ forQuery: debounced, data: withPushes });
|
||||
})().catch(() => {
|
||||
// Best-effort panel: a failed lookup just leaves the gossip list alone.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [debounced]);
|
||||
|
||||
const results = loaded && loaded.forQuery === debounced ? loaded.data : null;
|
||||
if (!debounced || results === null || results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border border-border mb-4">
|
||||
<div className="flex items-baseline gap-3 px-4 sm:px-6 h-10 border-b border-border bg-surface">
|
||||
<MicroLabel>local pushes</MicroLabel>
|
||||
<span className="text-[11px] text-muted">
|
||||
signed certificates from matching repos — these never appear in the gossip feed below
|
||||
</span>
|
||||
</div>
|
||||
<ul className="m-0 p-0 list-none">
|
||||
{results.map(({ repo, pushes }) => {
|
||||
const ownerKey = didKeySegment(repo.owner_did);
|
||||
return (
|
||||
<li key={repo.id} className="px-4 sm:px-6 py-3 border-b border-separator last:border-b-0">
|
||||
<Link
|
||||
to={`/repos/${encodeURIComponent(ownerKey)}/${encodeURIComponent(repo.name)}`}
|
||||
className="text-[13px] font-bold text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{shortDid(repo.owner_did)}/{repo.name}
|
||||
</Link>
|
||||
{pushes.length === 0 ? (
|
||||
<p className="m-0 mt-1 text-[12px] text-muted">no pushes recorded yet</p>
|
||||
) : (
|
||||
<ul className="m-0 mt-1 p-0 list-none flex flex-col gap-1">
|
||||
{pushes.map(push => (
|
||||
<li key={push.id} className="flex flex-wrap items-baseline gap-x-3 text-[12px] text-muted">
|
||||
<span aria-hidden="true" className="text-success self-center">
|
||||
<Circle size={6} fill="currentColor" />
|
||||
</span>
|
||||
<span>{shortRefName(push.ref_name)}</span>
|
||||
<span className="tabular-nums">
|
||||
{push.old_sha.startsWith('0000000')
|
||||
? `new · ${shortSha(push.new_sha)}`
|
||||
: `${shortSha(push.old_sha)} → ${shortSha(push.new_sha)}`}
|
||||
</span>
|
||||
<span className="truncate max-w-[220px]" title={push.id}>cert {push.id.slice(0, 8)}</span>
|
||||
<span title={push.pusher_did}>{shortDid(push.pusher_did)}</span>
|
||||
<span className="tabular-nums">{timeAgo(push.timestamp)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,9 @@ export function RefUpdateList({
|
||||
loading = false,
|
||||
skeletonCount = 10,
|
||||
header = true,
|
||||
emptyMessage = 'no ref updates yet — push a repo to this node to see activity',
|
||||
// The global feed only carries gossip from peers; a push to this node lands
|
||||
// on the repo page, not here — don't promise otherwise.
|
||||
emptyMessage = 'no gossip from peer nodes yet',
|
||||
}: RefUpdateListProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
|
||||
+11
-12
@@ -1,16 +1,13 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { fetchRefUpdates, MAX_EVENT_LIMIT, isGossip } from '../lib/api';
|
||||
import { fetchRefUpdates, MAX_EVENT_LIMIT } from '../lib/api';
|
||||
import type { ApiRefUpdate } from '../lib/api';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
import { useAutoRefresh } from './useAutoRefresh';
|
||||
|
||||
export type EventSourceFilter = 'all' | 'local' | 'gossip';
|
||||
|
||||
interface Options {
|
||||
page: number;
|
||||
perPage: number;
|
||||
search?: string;
|
||||
source?: EventSourceFilter;
|
||||
/** Auto-refresh the feed (visibility-aware, 30s). */
|
||||
live?: boolean;
|
||||
}
|
||||
@@ -18,7 +15,8 @@ interface Options {
|
||||
interface Result {
|
||||
events: ApiRefUpdate[] | null;
|
||||
allCount: number;
|
||||
gossipCount: number;
|
||||
/** Distinct peer nodes that contributed events to the feed. */
|
||||
peerCount: number;
|
||||
latest: ApiRefUpdate | null;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
@@ -28,8 +26,12 @@ interface Result {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// The node's global ref-updates feed only contains events gossiped in from
|
||||
// peer nodes (received_ref_updates has from_peer NOT NULL); pushes received
|
||||
// directly by this node are recorded as per-repo signed certificates instead.
|
||||
// No local/gossip source filter here — it would always come up empty.
|
||||
export function useRefUpdates({
|
||||
page, perPage, search = '', source = 'all', live = true,
|
||||
page, perPage, search = '', live = true,
|
||||
}: Options): Result {
|
||||
const tick = useAutoRefresh(30_000, live);
|
||||
const [all, setAll] = useState<ApiRefUpdate[] | null>(null);
|
||||
@@ -58,14 +60,11 @@ export function useRefUpdates({
|
||||
const derived = useMemo(() => {
|
||||
if (!all) {
|
||||
return {
|
||||
events: null, allCount: 0, gossipCount: 0, latest: null,
|
||||
events: null, allCount: 0, peerCount: 0, latest: null,
|
||||
totalCount: 0, totalPages: 1, windowStart: 0, windowEnd: 0,
|
||||
};
|
||||
}
|
||||
let filtered = all;
|
||||
if (source !== 'all') {
|
||||
filtered = filtered.filter(e => (source === 'gossip') === isGossip(e));
|
||||
}
|
||||
if (debouncedSearch) {
|
||||
filtered = filtered.filter(
|
||||
e =>
|
||||
@@ -83,14 +82,14 @@ export function useRefUpdates({
|
||||
return {
|
||||
events: filtered.slice(start, start + perPage),
|
||||
allCount: all.length,
|
||||
gossipCount: all.filter(isGossip).length,
|
||||
peerCount: new Set(all.map(e => e.from_peer).filter(Boolean)).size,
|
||||
latest: all[0] ?? null,
|
||||
totalCount,
|
||||
totalPages,
|
||||
windowStart: totalCount === 0 ? 0 : start + 1,
|
||||
windowEnd: Math.min(start + perPage, totalCount),
|
||||
};
|
||||
}, [all, debouncedSearch, source, page, perPage]);
|
||||
}, [all, debouncedSearch, page, perPage]);
|
||||
|
||||
return { ...derived, loading, error };
|
||||
}
|
||||
+14
-33
@@ -5,22 +5,18 @@ import { Input } from '@heroui/react';
|
||||
import { useListNav } from '../hooks/useShortcuts';
|
||||
import { DEFAULT_PER_PAGE, PER_PAGE_OPTIONS } from '../lib/constants';
|
||||
import { useRefUpdates } from '../hooks/useRefUpdates';
|
||||
import type { EventSourceFilter } from '../hooks/useRefUpdates';
|
||||
import { timeAgo, MAX_EVENT_LIMIT } from '../lib/api';
|
||||
import { RefUpdateList } from '../components/events/RefUpdateList';
|
||||
import { LocalPushPanel } from '../components/events/LocalPushPanel';
|
||||
import { RepoPagination } from '../components/repos/RepoPagination';
|
||||
import { RepoHero } from '../components/repos/RepoHero';
|
||||
import { MicroLabel } from '../components/ui/MicroLabel';
|
||||
import { Pill } from '../components/ui/Pill';
|
||||
|
||||
const SOURCES: EventSourceFilter[] = ['all', 'local', 'gossip'];
|
||||
|
||||
export default function EventsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const search = searchParams.get('q') ?? '';
|
||||
const rawSource = searchParams.get('src') as EventSourceFilter | null;
|
||||
const source: EventSourceFilter = rawSource && SOURCES.includes(rawSource) ? rawSource : 'all';
|
||||
const live = searchParams.get('live') !== 'off';
|
||||
const rawPage = Number(searchParams.get('page'));
|
||||
const page = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1;
|
||||
@@ -42,9 +38,9 @@ export default function EventsPage() {
|
||||
);
|
||||
|
||||
const {
|
||||
events, allCount, gossipCount, latest,
|
||||
events, allCount, peerCount, latest,
|
||||
totalCount, totalPages, windowStart, windowEnd, loading, error,
|
||||
} = useRefUpdates({ page, perPage, search, source, live });
|
||||
} = useRefUpdates({ page, perPage, search, live });
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
useListNav(listRef);
|
||||
@@ -71,24 +67,23 @@ export default function EventsPage() {
|
||||
countNoun="events"
|
||||
description={
|
||||
<p className="m-0">
|
||||
Every ref update this node knows about — pushes it received directly{' '}
|
||||
(<Circle size={6} fill="currentColor" className="inline text-muted" aria-hidden="true" /> local) and updates gossiped
|
||||
in from peer nodes over libp2p{' '}
|
||||
(<Circle size={6} fill="currentColor" className="inline text-accent" aria-hidden="true" /> gossip). The feed keeps the
|
||||
Ref updates gossiped in from peer nodes over libp2p{' '}
|
||||
(<Circle size={6} fill="currentColor" className="inline text-accent" aria-hidden="true" /> gossip).
|
||||
Pushes received directly by this node are recorded as signed certificates on each
|
||||
repository's page — search below to find them. The feed keeps the
|
||||
latest {MAX_EVENT_LIMIT} events{live ? ' and refreshes itself every 30s' : ''}.
|
||||
</p>
|
||||
}
|
||||
cells={[
|
||||
{ label: 'in feed', value: allCount > 0 ? allCount.toLocaleString() : '—' },
|
||||
{ label: 'local', value: allCount > 0 ? (allCount - gossipCount).toLocaleString() : '—' },
|
||||
{ label: 'gossip', value: allCount > 0 ? gossipCount.toLocaleString() : '—' },
|
||||
{ label: 'peer nodes', value: allCount > 0 ? peerCount.toLocaleString() : '—' },
|
||||
{ label: 'latest', value: latest ? timeAgo(latest.timestamp) : '—' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="pt-8 pb-20">
|
||||
|
||||
{/* Toolbar: search + source filter + live toggle */}
|
||||
{/* Toolbar: search + live toggle */}
|
||||
<div className="flex flex-wrap items-end gap-x-6 gap-y-4 mb-4">
|
||||
<div className="flex-1 min-w-[240px] max-w-[560px]">
|
||||
<MicroLabel className="block mb-1.5">
|
||||
@@ -109,22 +104,6 @@ export default function EventsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MicroLabel className="block mb-1.5">source</MicroLabel>
|
||||
<div className="flex gap-1.5">
|
||||
{SOURCES.map(s => (
|
||||
<Pill
|
||||
key={s}
|
||||
active={source === s}
|
||||
onClick={() => setParams({ src: s === 'all' ? null : s, page: null })}
|
||||
aria-pressed={source === s}
|
||||
>
|
||||
{s}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MicroLabel className="block mb-1.5">feed</MicroLabel>
|
||||
<Pill
|
||||
@@ -138,6 +117,8 @@ export default function EventsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LocalPushPanel query={search} />
|
||||
|
||||
{error ? (
|
||||
<div className="border border-border py-16 text-center">
|
||||
<p className="m-0 text-[13px] text-danger mb-4">failed to load events: {error}</p>
|
||||
@@ -150,9 +131,9 @@ export default function EventsPage() {
|
||||
loading={loading}
|
||||
skeletonCount={Math.min(perPage, 12)}
|
||||
emptyMessage={
|
||||
search || source !== 'all'
|
||||
? 'no events match'
|
||||
: 'no ref updates yet — push a repo to this node to see activity'
|
||||
search
|
||||
? 'no gossip events match — pushes to this node show up under local pushes and on the repo page'
|
||||
: 'no gossip from peer nodes yet'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user