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:
Kevin Codex committed 2026-08-17 16:12:00 +08:00
1 parent 2d873b3914
commit 8e941ee1d7
4 files changed
+152 -46

No files matched your search

+124
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>