From 06e96ba16a45b1600073b06e94ccb318ee357d37 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Mon, 11 May 2026 23:02:40 +0200 Subject: [PATCH] implemented unified operational timeline. situational awareness 'what changed before it broke?' --- AGENTS.md | 4 +- CHANGELOG.md | 8 +- README.md | 5 +- frontend/src/App.tsx | 2 + frontend/src/api/queries/timeline.ts | 58 ++++ frontend/src/components/layout/Sidebar.tsx | 4 +- frontend/src/pages/RepoTimelinePage.tsx | 362 +++++++++++++++++++++ frontend/src/types/api.ts | 37 +++ internal/api/handlers/timeline.go | 176 ++++++++++ internal/api/router.go | 2 + 10 files changed, 652 insertions(+), 6 deletions(-) create mode 100644 frontend/src/api/queries/timeline.ts create mode 100644 frontend/src/pages/RepoTimelinePage.tsx create mode 100644 internal/api/handlers/timeline.go diff --git a/AGENTS.md b/AGENTS.md index 2582707..694d31c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c329b..92a1ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8754c79..af5c1e8 100644 --- a/README.md +++ b/README.md @@ -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 | --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e34fcc..cb5dcc8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/queries/timeline.ts b/frontend/src/api/queries/timeline.ts new file mode 100644 index 0000000..1802f7e --- /dev/null +++ b/frontend/src/api/queries/timeline.ts @@ -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( + `/api/v1/repos/${owner}/${repo}/timeline?limit=${limit}`, + timelineSchema as z.ZodType, + ), + enabled: Boolean(owner && repo), + refetchInterval: 30_000, + }) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index fc4465b..35397fc 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -163,7 +163,8 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) { { label: 'Issues', to: `${base}/issues`, icon: }, { label: 'Pipelines', to: `${base}/pipelines`, icon: }, { label: 'Environments', to: `${base}/environments`, icon: }, - { label: 'Settings', to: `${base}/settings`, icon: }, + { label: 'Timeline', to: `${base}/timeline`, icon: }, + { label: 'Settings', to: `${base}/settings`, icon: }, ] return (
@@ -205,4 +206,5 @@ const BranchIcon = () => const EnvIcon = () => +const TimelineIcon = () => const SettingsSmIcon = () => diff --git a/frontend/src/pages/RepoTimelinePage.tsx b/frontend/src/pages/RepoTimelinePage.tsx new file mode 100644 index 0000000..be4bb26 --- /dev/null +++ b/frontend/src/pages/RepoTimelinePage.tsx @@ -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 = { + queued: 'Queued', running: 'Running', succeeded: 'Passed', + failed: 'Failed', cancelled: 'Cancelled', +} + +const DEPLOY_LABEL: Record = { + pending: 'Pending', in_progress: 'In progress', success: 'Active', + failure: 'Failed', cancelled: 'Cancelled', +} + +// ── Event icons ─────────────────────────────────────────────────────────────── + +function CommitIcon() { + return ( +
+ + + +
+ ) +} + +function RunIcon({ status }: { status: string }) { + const isRunning = status === 'running' + const isFailed = status === 'failed' + const isPassed = status === 'succeeded' + return ( +
+ + {isPassed + ? + : isFailed + ? + : isRunning + ? + : + } + +
+ ) +} + +function DeployIcon({ status }: { status: string }) { + const isSuccess = status === 'success' + const isFailure = status === 'failure' + const isActive = status === 'in_progress' + return ( +
+ + + +
+ ) +} + +// ── Event rows ───────────────────────────────────────────────────────────────── + +function CommitRow({ ev, owner, repo }: { ev: CommitEvent; owner: string; repo: string }) { + return ( +
+ +
+

{ev.message}

+
+ + {shortSHA(ev.sha)} + + by {ev.author} + {timeAgo(ev.timestamp)} +
+
+
+ ) +} + +function RunRow({ ev, owner, repo }: { ev: RunEvent; owner: string; repo: string }) { + const dur = duration(ev.startedAt, ev.finishedAt) + return ( +
+ +
+
+ + Run #{ev.runId} + + + {RUN_LABEL[ev.runStatus] ?? ev.runStatus} + +
+
+ {shortRef(ev.triggerRef)} + {shortSHA(ev.triggerSha)} + {dur && {dur}} + {timeAgo(ev.timestamp)} +
+
+
+ ) +} + +function DeployRow({ ev, owner, repo }: { ev: DeploymentEvent; owner: string; repo: string }) { + return ( +
+ +
+
+ + {ev.envName} + + + {DEPLOY_LABEL[ev.deployStatus] ?? ev.deployStatus} + +
+
+ {shortSHA(ev.deployedSha)} + {ev.deployRef && {shortRef(ev.deployRef)}} + by {ev.triggeredBy} + {ev.description && · {ev.description}} + {ev.runLink && ( + + run #{ev.runLink} + + )} + {timeAgo(ev.timestamp)} +
+
+
+ ) +} + +// ── 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 ( +
+ +
+ + +
+
+ ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function RepoTimelinePage() { + const { owner = '', repo = '' } = useParams() + const { data: events, isLoading } = useTimeline(owner, repo) + const [filter, setFilter] = useState('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() + 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 ( +
+ + {/* Header */} +
+

Timeline

+

+ Commits, CI runs, and deployments for{' '} + {owner}/{repo} +

+
+ + {/* Filter tabs */} +
+ {FILTERS.map(f => ( + + ))} +
+ + {/* Timeline */} + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => )} +
+ ) : filtered.length === 0 ? ( +
+ + + +
+

No activity yet

+

+ {filter === 'all' + ? 'Push commits, run pipelines, or create deployments to see activity here.' + : `No ${filter === 'commit' ? 'commits' : filter === 'run' ? 'CI runs' : 'deployments'} yet.`} +

+
+
+ ) : ( +
+ {days.map(day => ( +
+ {/* Day separator */} +
+
+ + {day.label} + +
+
+ + {/* Events for this day */} +
+ {/* Vertical line connecting events */} +
+ +
+ {day.events.map((ev, i) => ( +
+ {ev.type === 'commit' && ( + + )} + {ev.type === 'run' && ( + + )} + {ev.type === 'deployment' && ( + + )} +
+ ))} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cb26a9c..b61404f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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 diff --git a/internal/api/handlers/timeline.go b/internal/api/handlers/timeline.go new file mode 100644 index 0000000..3547408 --- /dev/null +++ b/internal/api/handlers/timeline.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go index f01dd60..57acd8a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) {