implemented unified operational timeline. situational awareness 'what changed before it broke?'

This commit is contained in:
2026-05-11 23:02:40 +02:00
parent 24bf4706e1
commit 06e96ba16a
10 changed files with 652 additions and 6 deletions
+362
View File
@@ -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>
)
}