feat(ui): project drill-down route with repo-filtered event timeline
Add repo filter param to /v1/events (SQL COALESCE across payload shapes per source). New /project/:source/* route renders a filtered activity timeline for a single repo. Dashboard cards link to the drill-down page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,8 @@ struct EventsQueryParams {
|
|||||||
to: Option<DateTime<Utc>>,
|
to: Option<DateTime<Utc>>,
|
||||||
/// Comma-separated list, e.g. `source=github,gitea`.
|
/// Comma-separated list, e.g. `source=github,gitea`.
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
|
/// Filter to a specific repo, e.g. `repo=grenade/moments`.
|
||||||
|
repo: Option<String>,
|
||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ async fn list_events(
|
|||||||
from: params.from,
|
from: params.from,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
sources,
|
sources,
|
||||||
|
repo: params.repo,
|
||||||
// Public timeline only — private events stay in the DB but are never
|
// Public timeline only — private events stay in the DB but are never
|
||||||
// surfaced. A future authenticated path can flip this.
|
// surfaced. A future authenticated path can flip this.
|
||||||
include_private: false,
|
include_private: false,
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ impl EventReader for PgStore {
|
|||||||
AND ($2::timestamptz IS NULL OR occurred_at < $2)
|
AND ($2::timestamptz IS NULL OR occurred_at < $2)
|
||||||
AND ($3::text[] IS NULL OR source = ANY($3))
|
AND ($3::text[] IS NULL OR source = ANY($3))
|
||||||
AND ($4::bool OR public = true)
|
AND ($4::bool OR public = true)
|
||||||
|
AND ($6::text IS NULL OR COALESCE(
|
||||||
|
payload->'repo'->>'name',
|
||||||
|
payload->'repository'->>'full_name',
|
||||||
|
payload->>'_repo',
|
||||||
|
payload->>'product'
|
||||||
|
) = $6)
|
||||||
ORDER BY occurred_at DESC
|
ORDER BY occurred_at DESC
|
||||||
LIMIT $5
|
LIMIT $5
|
||||||
"#,
|
"#,
|
||||||
@@ -63,6 +69,7 @@ impl EventReader for PgStore {
|
|||||||
.bind(sources.as_deref())
|
.bind(sources.as_deref())
|
||||||
.bind(query.include_private)
|
.bind(query.include_private)
|
||||||
.bind(query.limit as i64)
|
.bind(query.limit as i64)
|
||||||
|
.bind(query.repo.as_deref())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(map_err)?;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ pub struct EventQuery {
|
|||||||
pub from: Option<DateTime<Utc>>,
|
pub from: Option<DateTime<Utc>>,
|
||||||
pub to: Option<DateTime<Utc>>,
|
pub to: Option<DateTime<Utc>>,
|
||||||
pub sources: Option<Vec<Source>>,
|
pub sources: Option<Vec<Source>>,
|
||||||
|
/// Filter to events matching a specific repo (matched against payload).
|
||||||
|
pub repo: Option<String>,
|
||||||
/// When false (default), only `public = true` rows are returned. The API
|
/// When false (default), only `public = true` rows are returned. The API
|
||||||
/// pins this to false today; a future authenticated path can flip it.
|
/// pins this to false today; a future authenticated path can flip it.
|
||||||
pub include_private: bool,
|
pub include_private: bool,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import './App.css';
|
|||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { DashPage } from './pages/DashPage';
|
import { DashPage } from './pages/DashPage';
|
||||||
import { TimelineHome } from './pages/TimelineHome';
|
import { TimelineHome } from './pages/TimelineHome';
|
||||||
|
import { ProjectPage } from './pages/ProjectPage';
|
||||||
import { CvPage } from './pages/CvPage';
|
import { CvPage } from './pages/CvPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -17,6 +18,7 @@ export default function App() {
|
|||||||
<Route index element={<DashPage />} />
|
<Route index element={<DashPage />} />
|
||||||
<Route path="/dash" element={<DashPage />} />
|
<Route path="/dash" element={<DashPage />} />
|
||||||
<Route path="/activity" element={<TimelineHome />} />
|
<Route path="/activity" element={<TimelineHome />} />
|
||||||
|
<Route path="/project/:source/*" element={<ProjectPage />} />
|
||||||
<Route path="/cv" element={<CvPage />} />
|
<Route path="/cv" element={<CvPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface EventQuery {
|
|||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
sources?: Source[];
|
sources?: Source[];
|
||||||
|
repo?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ export async function fetchEvents(q: EventQuery): Promise<TimelineItem[]> {
|
|||||||
if (q.sources && q.sources.length > 0) {
|
if (q.sources && q.sources.length > 0) {
|
||||||
params.set('source', q.sources.join(','));
|
params.set('source', q.sources.join(','));
|
||||||
}
|
}
|
||||||
|
if (q.repo) params.set('repo', q.repo);
|
||||||
if (q.limit) params.set('limit', String(q.limit));
|
if (q.limit) params.set('limit', String(q.limit));
|
||||||
|
|
||||||
const resp = await fetch(`${API_BASE}/events?${params}`);
|
const resp = await fetch(`${API_BASE}/events?${params}`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import Col from 'react-bootstrap/Col';
|
import Col from 'react-bootstrap/Col';
|
||||||
import Row from 'react-bootstrap/Row';
|
import Row from 'react-bootstrap/Row';
|
||||||
|
|
||||||
@@ -31,16 +32,9 @@ export function DashPage() {
|
|||||||
<Row xs={1} md={2} lg={3} className="g-3">
|
<Row xs={1} md={2} lg={3} className="g-3">
|
||||||
{ranked.map((p) => (
|
{ranked.map((p) => (
|
||||||
<Col key={`${p.source}:${p.repo}`}>
|
<Col key={`${p.source}:${p.repo}`}>
|
||||||
|
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
|
||||||
<div className="project-card p-3">
|
<div className="project-card p-3">
|
||||||
<h5 className="mb-1">
|
<h5 className="mb-1">{p.repo}</h5>
|
||||||
<a
|
|
||||||
href={repoUrl(p)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{p.repo}
|
|
||||||
</a>
|
|
||||||
</h5>
|
|
||||||
<small className="text-muted d-block mb-2">{p.source}</small>
|
<small className="text-muted d-block mb-2">{p.source}</small>
|
||||||
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
|
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
|
||||||
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
|
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
|
||||||
@@ -51,6 +45,7 @@ export function DashPage() {
|
|||||||
{formatRange(p.first_activity, p.last_activity)}
|
{formatRange(p.first_activity, p.last_activity)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -58,21 +53,6 @@ export function DashPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoUrl(p: ProjectSummary): string {
|
|
||||||
switch (p.source) {
|
|
||||||
case 'github':
|
|
||||||
return `https://github.com/${p.repo}`;
|
|
||||||
case 'gitea':
|
|
||||||
return `https://${p.host}/${p.repo}`;
|
|
||||||
case 'hg':
|
|
||||||
return `https://${p.host}/${p.repo}`;
|
|
||||||
case 'bugzilla':
|
|
||||||
return `https://${p.host}`;
|
|
||||||
default:
|
|
||||||
return '#';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRange(first: string | null, last: string | null): string {
|
function formatRange(first: string | null, last: string | null): string {
|
||||||
const fmt = (iso: string) =>
|
const fmt = (iso: string) =>
|
||||||
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();
|
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();
|
||||||
|
|||||||
53
ui/src/pages/ProjectPage.tsx
Normal file
53
ui/src/pages/ProjectPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import Col from 'react-bootstrap/Col';
|
||||||
|
import Row from 'react-bootstrap/Row';
|
||||||
|
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
||||||
|
|
||||||
|
import { fetchEvents, type Source } from '../api/client';
|
||||||
|
import { TimelineEntry } from '../components/TimelineEntry';
|
||||||
|
|
||||||
|
export function ProjectPage() {
|
||||||
|
const { source, '*': repoPath } = useParams();
|
||||||
|
const repo = repoPath ?? '';
|
||||||
|
|
||||||
|
const eventsQ = useQuery({
|
||||||
|
queryKey: ['project-events', source, repo],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchEvents({
|
||||||
|
sources: source ? [source as Source] : undefined,
|
||||||
|
repo,
|
||||||
|
limit: 500,
|
||||||
|
}),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = eventsQ.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row className="mb-3">
|
||||||
|
<Col>
|
||||||
|
<h2>{repo}</h2>
|
||||||
|
<small className="text-muted">{source}</small>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<p style={{ fontSize: '85%' }}>
|
||||||
|
{eventsQ.isLoading
|
||||||
|
? 'loading...'
|
||||||
|
: eventsQ.isError
|
||||||
|
? `error: ${(eventsQ.error as Error).message}`
|
||||||
|
: `${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
|
||||||
|
</p>
|
||||||
|
<VerticalTimeline>
|
||||||
|
{events.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</VerticalTimeline>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user