implemented unified operational timeline. situational awareness 'what changed before it broke?'
This commit is contained in:
@@ -59,8 +59,8 @@ Understand the phases before adding code — don't build Phase 3 infrastructure
|
||||
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** |
|
||||
| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** |
|
||||
| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Complete** |
|
||||
| 3A | Environment model + deployment tracking | **Active** |
|
||||
| 3B | Unified operational timeline | Planned |
|
||||
| 3A | Environment model + deployment tracking | **Complete** |
|
||||
| 3B | Unified operational timeline | **Active** |
|
||||
| 3C | Secret management hierarchy | Planned |
|
||||
| 3D | GitOps controller + drift detection | Planned |
|
||||
| 3E | Observability (Prometheus, health sparklines) | Planned |
|
||||
|
||||
+7
-1
@@ -9,7 +9,13 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### In Progress — Phase 3A (Environment model + deployment tracking)
|
||||
### In Progress — Phase 3B (Unified Operational Timeline)
|
||||
- `GET /api/v1/repos/:owner/:repo/timeline` — merges commits, pipeline runs, and deployments into a single chronological feed
|
||||
- `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type filter tabs
|
||||
- Sidebar "Timeline" nav item between Environments and Settings
|
||||
- Event types: commit (SHA, message, author), run (status, ref, duration), deployment (env, status, SHA)
|
||||
|
||||
### Completed — Phase 3A (Environment model + deployment tracking)
|
||||
- `Environment` model per repo (name, URL, protection rules)
|
||||
- `Deployment` model (sha, ref, status, triggered_by, run_id link)
|
||||
- Full CRUD API for environments
|
||||
|
||||
@@ -222,8 +222,9 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
|
||||
| Phase 2A | NATS event bus, WebSocket hub, audit log | Done |
|
||||
| Phase 2B | CI orchestrator, runner manager, Docker backend, artifact registry | Done |
|
||||
| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done |
|
||||
| Phase 3A | Environment model + deployment tracking | **In progress** |
|
||||
| Phase 3B–F | Unified timeline, secrets, drift detection, federation, observability | Planned |
|
||||
| Phase 3A | Environment model + deployment tracking | Done |
|
||||
| Phase 3B | Unified operational timeline | **In progress** |
|
||||
| Phase 3C–F | Secrets, drift detection, federation, observability | Planned |
|
||||
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +38,7 @@ const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
||||
const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage'))
|
||||
const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
|
||||
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
||||
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
||||
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
@@ -87,6 +88,7 @@ export default function App() {
|
||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pipelines" element={<S><RepoPipelinesPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} />
|
||||
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
||||
|
||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { api } from '../client'
|
||||
import type { TimelineEvent } from '../../types/api'
|
||||
|
||||
const commitEventSchema = z.object({
|
||||
type: z.literal('commit'),
|
||||
timestamp: z.string(),
|
||||
sha: z.string(),
|
||||
message: z.string(),
|
||||
author: z.string(),
|
||||
})
|
||||
|
||||
const runEventSchema = z.object({
|
||||
type: z.literal('run'),
|
||||
timestamp: z.string(),
|
||||
runId: z.number(),
|
||||
triggerRef: z.string(),
|
||||
triggerSha: z.string(),
|
||||
triggeredBy: z.string(),
|
||||
runStatus: z.string(),
|
||||
startedAt: z.string().nullable().optional(),
|
||||
finishedAt: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const deploymentEventSchema = z.object({
|
||||
type: z.literal('deployment'),
|
||||
timestamp: z.string(),
|
||||
deploymentId: z.number(),
|
||||
envName: z.string(),
|
||||
deployStatus: z.string(),
|
||||
deployedSha: z.string(),
|
||||
deployRef: z.string(),
|
||||
triggeredBy: z.string(),
|
||||
description: z.string(),
|
||||
runLink: z.number().nullable().optional(),
|
||||
})
|
||||
|
||||
const timelineEventSchema = z.discriminatedUnion('type', [
|
||||
commitEventSchema,
|
||||
runEventSchema,
|
||||
deploymentEventSchema,
|
||||
])
|
||||
|
||||
const timelineSchema = z.array(timelineEventSchema)
|
||||
|
||||
export function useTimeline(owner: string, repo: string, limit = 60) {
|
||||
return useQuery({
|
||||
queryKey: ['repos', owner, repo, 'timeline', limit],
|
||||
queryFn: () =>
|
||||
api.get<TimelineEvent[]>(
|
||||
`/api/v1/repos/${owner}/${repo}/timeline?limit=${limit}`,
|
||||
timelineSchema as z.ZodType<TimelineEvent[]>,
|
||||
),
|
||||
enabled: Boolean(owner && repo),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -163,6 +163,7 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) {
|
||||
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
|
||||
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
|
||||
{ label: 'Environments', to: `${base}/environments`, icon: <EnvIcon /> },
|
||||
{ label: 'Timeline', to: `${base}/timeline`, icon: <TimelineIcon /> },
|
||||
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
|
||||
]
|
||||
return (
|
||||
@@ -205,4 +206,5 @@ const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25
|
||||
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
|
||||
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
const EnvIcon = () => <I d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
|
||||
const TimelineIcon = () => <I d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useTimeline } from '../api/queries/timeline'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { TimelineEvent, CommitEvent, RunEvent, DeploymentEvent } from '../types/api'
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const min = Math.floor(diff / 60_000)
|
||||
if (min < 1) return 'just now'
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr}h ago`
|
||||
const d = Math.floor(hr / 24)
|
||||
if (d < 30) return `${d}d ago`
|
||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function duration(start: string | null | undefined, end: string | null | undefined): string {
|
||||
if (!start) return ''
|
||||
const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime()
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
|
||||
function shortSHA(sha: string) { return sha.slice(0, 7) }
|
||||
function shortRef(ref: string) { return ref.replace('refs/heads/', '').replace('refs/tags/', '') }
|
||||
|
||||
// ── Day grouping ──────────────────────────────────────────────────────────────
|
||||
|
||||
function dayKey(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// ── Status helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const RUN_LABEL: Record<string, string> = {
|
||||
queued: 'Queued', running: 'Running', succeeded: 'Passed',
|
||||
failed: 'Failed', cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
const DEPLOY_LABEL: Record<string, string> = {
|
||||
pending: 'Pending', in_progress: 'In progress', success: 'Active',
|
||||
failure: 'Failed', cancelled: 'Cancelled',
|
||||
}
|
||||
|
||||
// ── Event icons ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CommitIcon() {
|
||||
return (
|
||||
<div className="w-7 h-7 rounded-full bg-[var(--c-surface)] border-2 border-[var(--c-border)] flex items-center justify-center shrink-0">
|
||||
<svg width="12" height="12" fill="none" stroke="var(--c-muted)" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunIcon({ status }: { status: string }) {
|
||||
const isRunning = status === 'running'
|
||||
const isFailed = status === 'failed'
|
||||
const isPassed = status === 'succeeded'
|
||||
return (
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full border-2 flex items-center justify-center shrink-0',
|
||||
isPassed ? 'bg-[#E3FCEF] border-[#79F2C0]' :
|
||||
isFailed ? 'bg-[var(--c-danger-tint)] border-[#FF8F73]' :
|
||||
isRunning ? 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)]' :
|
||||
'bg-[var(--c-surface)] border-[var(--c-border)]',
|
||||
)}>
|
||||
<svg width="12" height="12" fill="none"
|
||||
stroke={isPassed ? 'var(--c-success)' : isFailed ? 'var(--c-danger)' : isRunning ? 'var(--c-brand)' : 'var(--c-muted)'}
|
||||
strokeWidth="2" viewBox="0 0 24 24"
|
||||
className={isRunning ? 'animate-spin' : ''}
|
||||
>
|
||||
{isPassed
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
: isFailed
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
: isRunning
|
||||
? <path strokeLinecap="round" d="M12 3a9 9 0 1 0 9 9" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeployIcon({ status }: { status: string }) {
|
||||
const isSuccess = status === 'success'
|
||||
const isFailure = status === 'failure'
|
||||
const isActive = status === 'in_progress'
|
||||
return (
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full border-2 flex items-center justify-center shrink-0',
|
||||
isSuccess ? 'bg-[#E3FCEF] border-[#79F2C0]' :
|
||||
isFailure ? 'bg-[var(--c-danger-tint)] border-[#FF8F73]' :
|
||||
isActive ? 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)]' :
|
||||
'bg-[var(--c-surface)] border-[var(--c-border)]',
|
||||
)}>
|
||||
<svg width="12" height="12" fill="none"
|
||||
stroke={isSuccess ? 'var(--c-success)' : isFailure ? 'var(--c-danger)' : isActive ? 'var(--c-brand)' : 'var(--c-muted)'}
|
||||
strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Event rows ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CommitRow({ ev, owner, repo }: { ev: CommitEvent; owner: string; repo: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<CommitIcon />
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<p className="text-sm text-[var(--c-text)] leading-snug line-clamp-2">{ev.message}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px] text-[var(--c-muted)] flex-wrap">
|
||||
<Link
|
||||
to={`/repos/${owner}/${repo}/commits`}
|
||||
className="font-mono text-[var(--c-brand)] hover:underline"
|
||||
>
|
||||
{shortSHA(ev.sha)}
|
||||
</Link>
|
||||
<span>by {ev.author}</span>
|
||||
<span>{timeAgo(ev.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunRow({ ev, owner, repo }: { ev: RunEvent; owner: string; repo: string }) {
|
||||
const dur = duration(ev.startedAt, ev.finishedAt)
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<RunIcon status={ev.runStatus} />
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/repos/${owner}/${repo}/runs/${ev.runId}`}
|
||||
className="text-sm font-medium text-[var(--c-text)] hover:text-[var(--c-brand)] transition-colors"
|
||||
>
|
||||
Run #{ev.runId}
|
||||
</Link>
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold',
|
||||
ev.runStatus === 'succeeded' ? 'text-[var(--c-success)]' :
|
||||
ev.runStatus === 'failed' ? 'text-[var(--c-danger)]' :
|
||||
ev.runStatus === 'running' ? 'text-[var(--c-brand)]' :
|
||||
'text-[var(--c-muted)]',
|
||||
)}>
|
||||
{RUN_LABEL[ev.runStatus] ?? ev.runStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px] text-[var(--c-muted)] flex-wrap">
|
||||
<span className="font-mono">{shortRef(ev.triggerRef)}</span>
|
||||
<span className="font-mono">{shortSHA(ev.triggerSha)}</span>
|
||||
{dur && <span>{dur}</span>}
|
||||
<span>{timeAgo(ev.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeployRow({ ev, owner, repo }: { ev: DeploymentEvent; owner: string; repo: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<DeployIcon status={ev.deployStatus} />
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/repos/${owner}/${repo}/environments`}
|
||||
className="text-sm font-medium text-[var(--c-text)] hover:text-[var(--c-brand)] transition-colors"
|
||||
>
|
||||
{ev.envName}
|
||||
</Link>
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold',
|
||||
ev.deployStatus === 'success' ? 'text-[var(--c-success)]' :
|
||||
ev.deployStatus === 'failure' ? 'text-[var(--c-danger)]' :
|
||||
ev.deployStatus === 'in_progress' ? 'text-[var(--c-brand)]' :
|
||||
'text-[var(--c-muted)]',
|
||||
)}>
|
||||
{DEPLOY_LABEL[ev.deployStatus] ?? ev.deployStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px] text-[var(--c-muted)] flex-wrap">
|
||||
<span className="font-mono">{shortSHA(ev.deployedSha)}</span>
|
||||
{ev.deployRef && <span className="font-mono">{shortRef(ev.deployRef)}</span>}
|
||||
<span>by {ev.triggeredBy}</span>
|
||||
{ev.description && <span>· {ev.description}</span>}
|
||||
{ev.runLink && (
|
||||
<Link to={`/repos/${owner}/${repo}/runs/${ev.runLink}`} className="text-[var(--c-brand)] hover:underline">
|
||||
run #{ev.runLink}
|
||||
</Link>
|
||||
)}
|
||||
<span>{timeAgo(ev.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Filter bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Filter = 'all' | 'commit' | 'run' | 'deployment'
|
||||
|
||||
const FILTERS: { label: string; value: Filter }[] = [
|
||||
{ label: 'All activity', value: 'all' },
|
||||
{ label: 'Commits', value: 'commit' },
|
||||
{ label: 'CI runs', value: 'run' },
|
||||
{ label: 'Deployments', value: 'deployment' },
|
||||
]
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function EventSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3">
|
||||
<Skeleton className="w-7 h-7 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2 pt-0.5">
|
||||
<Skeleton className="h-3.5 w-3/4 rounded" />
|
||||
<Skeleton className="h-2.5 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RepoTimelinePage() {
|
||||
const { owner = '', repo = '' } = useParams()
|
||||
const { data: events, isLoading } = useTimeline(owner, repo)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
|
||||
const filtered = events?.filter(e => filter === 'all' || e.type === filter) ?? []
|
||||
|
||||
// Group by calendar day
|
||||
const days: Array<{ key: string; label: string; events: TimelineEvent[] }> = []
|
||||
const dayMap = new Map<string, TimelineEvent[]>()
|
||||
for (const ev of filtered) {
|
||||
const k = dayKey(ev.timestamp)
|
||||
if (!dayMap.has(k)) dayMap.set(k, [])
|
||||
dayMap.get(k)!.push(ev)
|
||||
}
|
||||
for (const [k, evs] of dayMap) {
|
||||
days.push({ key: k, label: formatDate(evs[0].timestamp), events: evs })
|
||||
}
|
||||
|
||||
const counts = {
|
||||
commit: events?.filter(e => e.type === 'commit').length ?? 0,
|
||||
run: events?.filter(e => e.type === 'run').length ?? 0,
|
||||
deployment: events?.filter(e => e.type === 'deployment').length ?? 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-6 py-5 space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--c-text)]">Timeline</h1>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
||||
Commits, CI runs, and deployments for{' '}
|
||||
<span className="font-mono">{owner}/{repo}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-[var(--c-border)]">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
|
||||
filter === f.value
|
||||
? 'border-[var(--c-brand)] text-[var(--c-brand)]'
|
||||
: 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
{f.value !== 'all' && !isLoading && (
|
||||
<span className="ml-1.5 text-[10px] font-mono text-[var(--c-subtle)]">
|
||||
{counts[f.value]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => <EventSkeleton key={i} />)}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--c-text)]">No activity yet</p>
|
||||
<p className="text-xs text-[var(--c-muted)] mt-1">
|
||||
{filter === 'all'
|
||||
? 'Push commits, run pipelines, or create deployments to see activity here.'
|
||||
: `No ${filter === 'commit' ? 'commits' : filter === 'run' ? 'CI runs' : 'deployments'} yet.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{days.map(day => (
|
||||
<div key={day.key}>
|
||||
{/* Day separator */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-px flex-1 bg-[var(--c-border)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--c-muted)] shrink-0">
|
||||
{day.label}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-[var(--c-border)]" />
|
||||
</div>
|
||||
|
||||
{/* Events for this day */}
|
||||
<div className="relative">
|
||||
{/* Vertical line connecting events */}
|
||||
<div className="absolute left-3.5 top-7 bottom-0 w-px bg-[var(--c-border)]" aria-hidden />
|
||||
|
||||
<div className="space-y-4">
|
||||
{day.events.map((ev, i) => (
|
||||
<div key={i} className="relative">
|
||||
{ev.type === 'commit' && (
|
||||
<CommitRow ev={ev as CommitEvent} owner={owner} repo={repo} />
|
||||
)}
|
||||
{ev.type === 'run' && (
|
||||
<RunRow ev={ev as RunEvent} owner={owner} repo={repo} />
|
||||
)}
|
||||
{ev.type === 'deployment' && (
|
||||
<DeployRow ev={ev as DeploymentEvent} owner={owner} repo={repo} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -260,6 +260,43 @@ export interface EnvironmentWithLatest extends Environment {
|
||||
latestDeployment: Deployment | null
|
||||
}
|
||||
|
||||
// ── Unified Operational Timeline (Phase 3B) ──────────────────────────────────
|
||||
|
||||
export interface CommitEvent {
|
||||
type: 'commit'
|
||||
timestamp: string
|
||||
sha: string
|
||||
message: string
|
||||
author: string
|
||||
}
|
||||
|
||||
export interface RunEvent {
|
||||
type: 'run'
|
||||
timestamp: string
|
||||
runId: number
|
||||
triggerRef: string
|
||||
triggerSha: string
|
||||
triggeredBy: string
|
||||
runStatus: string
|
||||
startedAt: string | null
|
||||
finishedAt: string | null
|
||||
}
|
||||
|
||||
export interface DeploymentEvent {
|
||||
type: 'deployment'
|
||||
timestamp: string
|
||||
deploymentId: number
|
||||
envName: string
|
||||
deployStatus: string
|
||||
deployedSha: string
|
||||
deployRef: string
|
||||
triggeredBy: string
|
||||
description: string
|
||||
runLink: number | null
|
||||
}
|
||||
|
||||
export type TimelineEvent = CommitEvent | RunEvent | DeploymentEvent
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
status: number
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"xorm.io/xorm"
|
||||
|
||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
type TimelineHandler struct {
|
||||
db *xorm.Engine
|
||||
repoRoot string
|
||||
}
|
||||
|
||||
func NewTimelineHandler(db *xorm.Engine, repoRoot string) *TimelineHandler {
|
||||
return &TimelineHandler{db: db, repoRoot: repoRoot}
|
||||
}
|
||||
|
||||
// ── Event types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type timelineEventType string
|
||||
|
||||
const (
|
||||
eventTypeCommit timelineEventType = "commit"
|
||||
eventTypeRun timelineEventType = "run"
|
||||
eventTypeDeployment timelineEventType = "deployment"
|
||||
)
|
||||
|
||||
// TimelineEvent is a unified, sorted event across commits, CI runs, and deployments.
|
||||
// The `type` field is used by the frontend to discriminate which fields are present.
|
||||
type TimelineEvent struct {
|
||||
Type timelineEventType `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// commit fields
|
||||
SHA string `json:"sha,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
|
||||
// run fields
|
||||
RunID int64 `json:"runId,omitempty"`
|
||||
TriggerRef string `json:"triggerRef,omitempty"`
|
||||
TriggerSHA string `json:"triggerSha,omitempty"`
|
||||
TriggeredBy string `json:"triggeredBy,omitempty"`
|
||||
RunStatus string `json:"runStatus,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
|
||||
// deployment fields
|
||||
DeploymentID int64 `json:"deploymentId,omitempty"`
|
||||
EnvName string `json:"envName,omitempty"`
|
||||
DeployStatus string `json:"deployStatus,omitempty"`
|
||||
DeployedSHA string `json:"deployedSha,omitempty"`
|
||||
DeployRef string `json:"deployRef,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RunLink *int64 `json:"runLink,omitempty"` // links a deployment back to its pipeline run
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetTimeline returns a merged, time-sorted feed of commits, pipeline runs, and
|
||||
// deployments for the given repository. Defaults to the last 60 events.
|
||||
//
|
||||
// GET /api/v1/repos/:owner/:repo/timeline?limit=60
|
||||
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
repoName := chi.URLParam(r, "repo")
|
||||
|
||||
limit := 60
|
||||
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// ── Resolve repo ──────────────────────────────────────────────────────────
|
||||
var u models.User
|
||||
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var repo models.Repository
|
||||
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var events []TimelineEvent
|
||||
|
||||
// ── Commits ───────────────────────────────────────────────────────────────
|
||||
gitdomain.SetRepoRoot(h.repoRoot)
|
||||
if !gitdomain.IsEmpty(repo.DiskPath) {
|
||||
commits, err := gitdomain.Log(repo.DiskPath, repo.DefaultBranch, limit)
|
||||
if err == nil {
|
||||
for _, c := range commits {
|
||||
ts, _ := time.Parse("2006-01-02 15:04:05 -0700", c.Date)
|
||||
events = append(events, TimelineEvent{
|
||||
Type: eventTypeCommit,
|
||||
Timestamp: ts,
|
||||
SHA: c.Hash,
|
||||
Message: c.Message,
|
||||
Author: c.Author,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pipeline runs ─────────────────────────────────────────────────────────
|
||||
var runs []models.PipelineRun
|
||||
h.db.Where("repo_id = ?", repo.ID).Desc("id").Limit(limit).Find(&runs)
|
||||
for _, run := range runs {
|
||||
ev := TimelineEvent{
|
||||
Type: eventTypeRun,
|
||||
Timestamp: run.CreatedAt,
|
||||
RunID: run.ID,
|
||||
TriggerRef: run.TriggerRef,
|
||||
TriggerSHA: run.TriggerSHA,
|
||||
TriggeredBy: run.TriggeredBy,
|
||||
RunStatus: run.Status,
|
||||
}
|
||||
if run.StartedAt != nil {
|
||||
ev.StartedAt = run.StartedAt.Format(time.RFC3339)
|
||||
}
|
||||
if run.FinishedAt != nil {
|
||||
ev.FinishedAt = run.FinishedAt.Format(time.RFC3339)
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
// ── Deployments (with environment name) ───────────────────────────────────
|
||||
var deploys []models.Deployment
|
||||
h.db.Where("repo_id = ?", repo.ID).Desc("id").Limit(limit).Find(&deploys)
|
||||
|
||||
// Cache env names to avoid N+1 queries.
|
||||
envNameByID := map[int64]string{}
|
||||
for _, d := range deploys {
|
||||
if _, ok := envNameByID[d.EnvID]; !ok {
|
||||
var env models.Environment
|
||||
if found, _ := h.db.ID(d.EnvID).Cols("name").Get(&env); found {
|
||||
envNameByID[d.EnvID] = env.Name
|
||||
}
|
||||
}
|
||||
ev := TimelineEvent{
|
||||
Type: eventTypeDeployment,
|
||||
Timestamp: d.CreatedAt,
|
||||
DeploymentID: d.ID,
|
||||
EnvName: envNameByID[d.EnvID],
|
||||
DeployStatus: string(d.Status),
|
||||
DeployedSHA: d.SHA,
|
||||
DeployRef: d.Ref,
|
||||
TriggeredBy: d.TriggeredBy,
|
||||
Description: d.Description,
|
||||
RunLink: d.RunID,
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
// ── Merge-sort by timestamp descending ────────────────────────────────────
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
return events[i].Timestamp.After(events[j].Timestamp)
|
||||
})
|
||||
|
||||
// Cap at limit.
|
||||
if len(events) > limit {
|
||||
events = events[:limit]
|
||||
}
|
||||
if events == nil {
|
||||
events = []TimelineEvent{}
|
||||
}
|
||||
|
||||
jsonOK(w, events)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
||||
runnerH := handlers.NewRunnerHandler(engine)
|
||||
envH := handlers.NewEnvironmentHandler(engine, bus)
|
||||
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
||||
|
||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
||||
@@ -206,6 +207,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription)
|
||||
r.Get("/excluded-files", prSettingsH.GetExcludedFiles)
|
||||
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
|
||||
r.Get("/timeline", timelineH.GetTimeline)
|
||||
r.Get("/lfs-settings", lfsH.Get)
|
||||
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
||||
r.Route("/environments", func(r chi.Router) {
|
||||
|
||||
Reference in New Issue
Block a user