implemented unified operational timeline. situational awareness 'what changed before it broke?'
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user