diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd395b..371adee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,82 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Ships the Cross-Project Dashboard — the killer feature for developers +with dozens of projects. Turns RunHQ from a per-service manager into a +bird's-eye view that answers "which ones are out of date? which have +uncommitted work? which are hogging resources?" without visiting each +project individually. + +### Features + +- **overview:** cross-project dashboard closing the full + [#32](https://github.com/erdembas/runhq/issues/32) scope — + Git Status Matrix, Resource Heatmap, Last Activity Tracker, + Dependency Outdatedness, Security Alerts, and Filter & Sort all in + one screen. +- **overview:** two-phase aggregator in `runhq-core::overview`. The + fast path (git status, resource samples, staleness, tags) returns in + tens of ms; the opt-in slow path runs `npm outdated` / `cargo + outdated` / `npm audit` / `cargo audit` in parallel with per-command + timeouts and memoises results for 5 minutes, so re-opening the + dashboard or flipping filters never re-spawns scans. +- **overview:** `ProjectDetailDrawer` — a per-project "triage + cockpit". Severity- and bump-tinted tabs, a **triage rail** where + tiles double as both count summary *and* filter, multi-select with + a sticky bulk bar that copies all selected upgrade commands as a + shell script, in-drawer rescan with scan-freshness indicator, and + an "Open in…" overflow submenu listing detected editors with a + Finder/Explorer fallback. Advisory rows keep the external-link + button persistently visible because reading the GHSA write-up is + the primary triage action, not a secondary one. +- **overview:** dashboard filter bar restructured into three logical + chambers — **Organize** (Group / Sort dropdowns) · **Git** (All / + Dirty / Clean / Ahead / Behind / No upstream) · **Attention** + (Stale / Risk / Outdated). Each chamber is a single flex-child so + its label stays glued to its pills; chambers are separated by a + vertical divider plus a larger `gap-x-5`, so the row reads as three + distinct units instead of a flat stream of controls. +- **overview:** WorstOffenders band surfaces the top N projects + weighted by CVE severity × outdated majors; chips are clickable and + jump straight to the relevant drawer tab. +- **overview:** resource heatmap sorts the running fleet by + RAM / CPU, non-running projects pushed to the bottom so they never + wedge between hot ones. +- **ui:** auto-hiding macOS-style scrollbars (visible only during + scroll), global `cursor: pointer` on interactive elements, floating + drawer (margin + radius) scoped to the content area so the sidebar + rail stays visible underneath. + +### Deferred + +- **Auto-execute upgrade / CVE-fix commands** — intentionally held + off. A one-click `npm i pkg@latest` can pull breaking majors, shift + peer deps, rewrite the lockfile, and burn minutes with no obvious + rollback; responsibility for that decision normally lives in CI, + tests, and review. The current *copy-as-script* pattern already + captures ~90% of the value with zero risk surface. If ever + revisited, the preferred shape is **"Run in RunHQ terminal"**: open + the embedded terminal with `cwd` set and the command pre-filled but + *not submitted* — the user sees the exact line and hits Enter + themselves. See ROADMAP.md §1 for the full reasoning. + +### Removed + +- **dashboard:** the two 4-up stat grids at the top of the dashboard + (Running / Starting / Stopped / Failed and CVE / Outdated / Stale / + Dirty). Each one duplicated information already present in the + page header summary, the filter bar chips, and the WorstOffenders + band — on a quiet day they burned ~200px of vertical real estate + to display mostly zeros, pushing the actual project cards below the + fold. Their filter affordances survived intact in the filter bar + chips, which are tighter and more honest about being *filters*. +- **components:** orphaned `StatTile` / `AttentionTile` components + and their barrel export. +- **components:** old `ProjectDashboard` modal replaced by the + integrated dashboard + drawer flow. + ## [0.6.0](https://github.com/erdembas/runhq/compare/v0.5.1...v0.6.0) (2026-04-23) diff --git a/ROADMAP.md b/ROADMAP.md index ef8e385..5c3ce53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ The overarching goal: transform RunHQ from a **service manager** into a **projec ## 1. Cross-Project Dashboard -**Priority:** High | **Effort:** Medium | **Status:** Planned +**Priority:** High | **Effort:** Medium | **Status:** Shipped (feature/32) The killer feature for developers with dozens of projects. Today, each service card is isolated — there is no way to see the big picture across all projects at once. @@ -21,6 +21,24 @@ The killer feature for developers with dozens of projects. Today, each service c - **Security Alerts** — Surface `npm audit`, `cargo audit`, and equivalent results across all projects in one view. - **Filter & Sort** — Filter by status (dirty, stale, running), runtime (node, rust, go), category, or custom tags. Sort by last activity, resource usage, name. +### Delivered + +- `runhq-core::overview` — two-phase aggregator: fast path (git, resources, staleness, tags) and opt-in slow path (`npm outdated` / `cargo outdated` / `npm audit` / `cargo audit`) in parallel with per-command timeouts and a 5-minute memoised cache. +- Dashboard with filter bar (status, runtime, tags), group / sort dropdowns, resource heatmap, and a worst-offenders panel whose chips jump straight to the relevant drawer tab. +- **ProjectDetailDrawer** ("triage cockpit") — severity / bump tiles that double as filters, hover-reveal row actions, sticky bulk bar with multi-select + copy-as-script, in-drawer rescan with scan-freshness indicator, and an overflow menu whose "Open in…" submenu lists detected editors and falls back to Finder/Explorer. +- Auto-hiding macOS-style scrollbars, global `cursor: pointer` on interactive elements, floating drawer (margin + radius) scoped to the content area so the sidebar rail stays visible. + +### Deferred — Auto-execute upgrade / CVE-fix commands + +Considered but intentionally held off (see discussion on feature/32): + +- **Risk surface**: a one-click `npm i pkg@latest` can pull a breaking major, shift peer deps, rewrite the lockfile, and burn minutes of wall time with no obvious rollback. Even with user confirmation, the app would be assuming responsibility for a decision that normally rides on CI, tests, and review. +- **Current pattern is already 90% of the value**: the drawer emits a ready-to-paste upgrade command per row and a `Copy as script` for the full selection. The user owns the paste into their terminal where their existing safety net (branch, tests, commit hooks) still applies. +- **If we ever revisit**, the preferred shape is _not_ a background "run and hope" execute. It's: + - **"Run in RunHQ terminal" (pre-filled, not submitted)** — open the embedded terminal (`LogPanel`) with `cwd` set and the command typed in, but require the user's Enter. Zero-surprise: the user sees the exact line before it runs. + - **Dry-run preflight** where the package manager supports it (`npm install --dry-run`, `cargo update --dry-run`) before the real invocation, so the diff / plan is surfaced first. + - **Per-runtime opt-in** — enable per-project, never as a global default. + ### Why When you have 20+ projects, answering "which ones are out of date?", "which have uncommitted work?", "which are hogging resources?" requires visiting each one individually. A bird's-eye view eliminates that. @@ -226,13 +244,13 @@ Currently, logs exist only in memory (ring buffers). Restarting the app clears e The suggested implementation sequence, balancing impact and dependencies: -| Phase | Features | Rationale | -| ----------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | -| **Phase 1** | Cross-Project Dashboard, Bulk Operations | Highest impact, lowest friction. Transform RunHQ from per-service to cross-project awareness. | -| **Phase 2** | Quick .env Editor | High daily value, relatively self-contained. | -| **Phase 3** | Internal Browser, Git Diff Viewer | Rich UI features that require new embedded components. | -| **Phase 4** | Service Health Checks, Log Persistence | Infrastructure improvements that other features can build on. | -| **Phase 5** | Workspace Snapshots, CLI Interface | Polish and reach — snapshots for convenience, CLI for new audiences. | +| Phase | Features | Rationale | +| ----------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| **Phase 1** | ~~Cross-Project Dashboard~~ (shipped), Bulk Operations | Highest impact, lowest friction. Transform RunHQ from per-service to cross-project awareness. | +| **Phase 2** | Quick .env Editor | High daily value, relatively self-contained. | +| **Phase 3** | Internal Browser, Git Diff Viewer | Rich UI features that require new embedded components. | +| **Phase 4** | Service Health Checks, Log Persistence | Infrastructure improvements that other features can build on. | +| **Phase 5** | Workspace Snapshots, CLI Interface | Polish and reach — snapshots for convenience, CLI for new audiences. | --- diff --git a/apps/desktop/src-tauri/src/ipc.rs b/apps/desktop/src-tauri/src/ipc.rs index 8bb41d5..e85c8f2 100644 --- a/apps/desktop/src-tauri/src/ipc.rs +++ b/apps/desktop/src-tauri/src/ipc.rs @@ -10,6 +10,7 @@ use runhq_core::editors::{self, DetectedEditor}; use runhq_core::error::{AppError, AppResult}; use runhq_core::git::{self, GitStatus}; use runhq_core::logs::LogLine; +use runhq_core::overview::{self, DependencyScanResult, OverviewSummary}; use runhq_core::paths; use runhq_core::ports::{self, ListeningPort}; use runhq_core::process::ServiceStatus; @@ -406,6 +407,28 @@ pub fn stop_stack(id: String, state: State<'_, AppState>) -> AppResult, + state: State<'_, AppState>, +) -> AppResult { + let threshold = stale_threshold_days.unwrap_or(30); + overview::gather_overview(&state.store, &state.supervisor, threshold).await +} + +/// Run the heavy per-project dependency/audit scans. Separated from +/// [`get_project_overview`] so the dashboard opens instantly and the user +/// can opt in to the expensive work with a button. +#[tauri::command] +pub async fn scan_project_dependencies( + force: Option, + state: State<'_, AppState>, +) -> AppResult { + overview::gather_dependency_scan(&state.store, force.unwrap_or(false)).await +} + // ---- Timeline ------------------------------------------------------------- #[tauri::command] @@ -414,6 +437,7 @@ pub fn record_timeline_event( service_id: Option, service_name: Option, description: String, + run_id: Option, state: State<'_, AppState>, ) -> AppResult<()> { let db_path = state @@ -443,6 +467,7 @@ pub fn record_timeline_event( service_id.as_deref(), service_name.as_deref(), &description, + run_id.as_deref(), ) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bb53c2e..1621d4d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -606,6 +606,8 @@ pub fn run() { ipc::git_stash_pop, ipc::git_undo_last_commit, ipc::git_amend_commit_message, + ipc::get_project_overview, + ipc::scan_project_dependencies, ipc::record_timeline_event, ipc::get_timeline, ipc::get_daily_summary, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8096d52..238c124 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -114,11 +114,80 @@ export default function App() { // `onStatus` emissions (multi-command services emit one status tick per // command transition) don't produce duplicate timeline events. const lastLifecycleRef = useRef>({}); + // Belt-and-suspenders time-based guard. If the same (service, lifecycle) + // pair was already recorded within this window, we drop the duplicate. + // Protects against: + // 1. Rust-side double-emit (e.g. a future refactor that adds a status + // tick we didn't anticipate — aggregate Running fired twice back to + // back). + // 2. React 18 StrictMode async-effect race that can leak the *previous* + // subscription when cleanup runs before the first `await listen` + // resolves, leading to two active listeners receiving the same event. + // The `ref`-based dedup already catches synchronous duplicate + // delivery, but some IPC transports schedule listeners via + // microtasks / postMessage and the two handlers can end up in + // separate ticks — long enough for both to pass the `prev !== cur` + // check before either updates the ref. The time guard closes that. + const lastLifecycleTsRef = useRef>({}); + const LIFECYCLE_DEDUP_MS = 1500; + + // Per-service "current event bucket id" used only to stamp DB-recorded + // child events (log_error / log_warning) so they can be rolled up under + // the owning lifecycle row in the timeline. + // + // Semantics by lifecycle: + // - `started` → take the id straight from Rust's `status.run_id`. The + // supervisor mints it BEFORE spawning any process, so + // the same id is already stamped on the `$ ` echo + // and every subsequent stdout/stderr line. Using it on + // the frontend side gives us a single, authoritative + // correlation key — no mint-on-arrival, no timestamp + // windowing, no IPC-jitter fragility. + // - `stopped`/`crashed` → Rust has already cleared its run id by the + // time we see this status (the last command's exit is + // what *produced* the transition). We mint a local id + // here purely to give those lifecycle rows their own + // bucket so any post-stop diagnostics surface under + // Stop rather than silently merging into the prior Run. + const runIdsRef = useRef>({}); + + const makeRunId = useCallback(() => { + // `crypto.randomUUID` is available in every modern webview Tauri targets; + // fall back to a timestamp-based id if the API is somehow missing so we + // never silently break the correlation. + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + }, []); useEffect(() => { + // StrictMode-safe subscription pattern. + // + // The naive version — `unsubs.push(await events.onStatus(…))` inside a + // fire-and-forget IIFE — has a subtle race in React 18 dev StrictMode: + // the effect runs → cleanup runs → effect runs again. If the first + // `await listen()` hasn't resolved by the time cleanup runs, the first + // subscription's unlisten never makes it into `unsubs`, the cleanup + // walks an empty array, and the first listener leaks. The re-mount + // then establishes a *second* listener, so every future + // `service://status` event fires both handlers. That's exactly the + // "two Started events for one start" symptom. + // + // Fix: check `cancelled` after each await and immediately drop the + // just-registered listener if the effect has already torn down. This + // keeps at most one live subscription per channel at all times. + let cancelled = false; const unsubs: Array<() => void> = []; + const register = (unlisten: () => void) => { + if (cancelled) { + unlisten(); + } else { + unsubs.push(unlisten); + } + }; (async () => { - unsubs.push( + register( await events.onStatus((status) => { setStatus(status); @@ -148,9 +217,40 @@ export default function App() { // timeline entry; the baseline is silent. if (prev === undefined && lifecycle !== 'started') { lastLifecycleRef.current[status.id] = lifecycle; + lastLifecycleTsRef.current[status.id] = Date.now(); + return; + } + // Second line of defense: even if `prev` got reset somehow (new + // subscription from a StrictMode re-mount, etc.), drop any event + // that duplicates the most recent lifecycle for this service + // within a short window. Different lifecycles in rapid succession + // (e.g. crashed → started on auto-restart) are legitimate and + // pass through because we key the timestamp by service only; + // what we're blocking is N copies of the *same* status. + const nowMs = Date.now(); + const lastTs = lastLifecycleTsRef.current[status.id]; + if ( + prev === undefined && + lifecycle === 'started' && + lastTs != null && + nowMs - lastTs < LIFECYCLE_DEDUP_MS + ) { + lastLifecycleRef.current[status.id] = lifecycle; + lastLifecycleTsRef.current[status.id] = nowMs; return; } lastLifecycleRef.current[status.id] = lifecycle; + lastLifecycleTsRef.current[status.id] = nowMs; + + // ── Event bucket correlation ────────────────────────────────── + // Prefer the run id that Rust has already stamped on every log + // line of this run (see `Supervisor::start_all`). For Started + // events it WILL be present — the supervisor inserts it before + // `start_one` emits the shell-prompt echo. For Stopped/Crashed + // the supervisor has already cleared it (the run is over), so + // we mint a fresh local id for that bucket. + const runId = lifecycle === 'started' && status.run_id ? status.run_id : makeRunId(); + runIdsRef.current[status.id] = runId; const svc = useAppStore.getState().services.find((s) => s.id === status.id); const name = svc?.name ?? status.id; @@ -188,11 +288,11 @@ export default function App() { } ipc - .recordTimelineEvent(eventType, status.id, svc?.name ?? null, description) + .recordTimelineEvent(eventType, status.id, svc?.name ?? null, description, runId) .catch(() => {}); }), ); - unsubs.push( + register( await events.onLog((ev) => { appendLog(logKey(ev.service_id, ev.cmd_name), ev.line); if (ev.line.stream === 'stderr') { @@ -202,6 +302,12 @@ export default function App() { text.includes('error') || text.includes('fatal') || text.includes('panic'); if (isError || isWarning) { const svc = useAppStore.getState().services.find((s) => s.id === ev.service_id); + // Attach the log to whatever run is currently open for this + // service. If it's null (e.g. stderr arriving between `stopped` + // and the next `started`, or before we ever saw a `started` + // for this service), the log goes in ungrouped — which is + // correct; there's no parent to collapse it into. + const runId = runIdsRef.current[ev.service_id] ?? null; // Preserve enough context for the timeline detail view to actually // show the useful part of a stack trace / error body, not just // the first 200 chars which usually cut off mid-message. @@ -211,13 +317,14 @@ export default function App() { ev.service_id, svc?.name ?? null, ev.line.text.slice(0, 1500), + runId, ) .catch(() => {}); } } }), ); - unsubs.push( + register( await events.onResources((ev) => setResources(ev.service_id, { cpu_percent: ev.cpu_percent, @@ -226,8 +333,11 @@ export default function App() { ), ); })(); - return () => unsubs.forEach((u) => u()); - }, [setStatus, appendLog, setResources]); + return () => { + cancelled = true; + unsubs.forEach((u) => u()); + }; + }, [setStatus, appendLog, setResources, makeRunId]); useEffect(() => { let alive = true; @@ -247,6 +357,34 @@ export default function App() { }; }, [setPorts]); + // Cross-project overview polling — drives the dashboard's Stale / Risk / + // Outdated pills and per-card chips. 30s cadence is deliberate: git status + // + last-commit lookups shell out per project, and the UI doesn't need + // sub-second freshness for "is this project stale?". The Rust side also + // caches dependency-scan output (5 min TTL), so an explicit "Scan + // dependencies" click is what actually refreshes audit/outdated numbers. + useEffect(() => { + const store = useAppStore.getState(); + let alive = true; + const poll = async () => { + try { + store.setOverviewLoading(true); + const data = await ipc.getProjectOverview(30); + if (alive) store.setOverview(data); + } catch (err) { + console.error('get_project_overview failed', err); + } finally { + if (alive) store.setOverviewLoading(false); + } + }; + void poll(); + const id = setInterval(poll, 30_000); + return () => { + alive = false; + clearInterval(id); + }; + }, []); + useEffect(() => { const unsubs: Array<() => void> = []; void (async () => { diff --git a/apps/desktop/src/components/ActivityTimeline.tsx b/apps/desktop/src/components/ActivityTimeline.tsx index 5fa215a..0aa1b6d 100644 --- a/apps/desktop/src/components/ActivityTimeline.tsx +++ b/apps/desktop/src/components/ActivityTimeline.tsx @@ -24,11 +24,81 @@ import { } from 'lucide-react'; import { ipc, events as ipcEvents } from '@/lib/ipc'; import { cn } from '@/lib/cn'; +import { makeAnsiConverter, renderAnsiToHtml } from '@/lib/ansi'; +import { useAppStore, logKey } from '@/store/useAppStore'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; -import type { TimelineEvent, DailySummary } from '@/types'; +import type { TimelineEvent, DailySummary, LogLine } from '@/types'; import { Select } from './ui/Select'; import { Dialog } from './ui/Dialog'; +/** Unified shape for console-rendering — lets ConsoleOutput draw the same + * block whether the lines came from the live in-memory log buffer (current + * session, full stdout+stderr) or from DB-persisted `log_error` / + * `log_warning` rows (historical runs). Severity drives the gutter color; + * the ANSI renderer handles the text. */ +interface ConsoleLine { + /** Stable key for React — `live-` or `db-`. */ + key: string; + ts_ms: number; + text: string; + severity: 'error' | 'warn' | 'info' | 'system'; +} + +/** One tab in the ConsoleOutput block — isolates a single command's transcript + * so 4 concurrently-spawned processes don't end up interleaved in one + * chronological blob (useless for debugging "which of the 4 backends logged + * this"). Each section carries its own severity counts so the tab header + * can surface "2 errors" / "1 warn" chips without re-scanning the lines at + * render time. */ +interface ConsoleSection { + /** Stable tab key: `cmd::` for live data, `all` for legacy DB fallback + * (log_error/log_warning rows aren't tagged with a cmd_name). */ + key: string; + /** Display label — typically the command name; `All` for the DB-only + * fallback when cmd-level splitting is not available. */ + label: string; + lines: ConsoleLine[]; + errors: number; + warnings: number; +} + +/** Heuristic severity for a live log line. Stream alone isn't enough — lots + * of dev tools emit non-error output on stderr (Vite progress, pnpm chatter, + * yarn "Port … in use" probes) — so we also scan for error/warn keywords. + * Matches the same filter App.tsx uses for the DB recorder, keeping the two + * code paths in agreement about what counts as "an error line". */ +function severityOfLive(line: LogLine): ConsoleLine['severity'] { + if (line.stream === 'system') return 'system'; + const t = line.text.toLowerCase(); + if (t.includes('error') || t.includes('fatal') || t.includes('panic')) return 'error'; + if (t.includes('warn')) return 'warn'; + return 'info'; +} + +function toConsoleLineFromLive(line: LogLine): ConsoleLine { + return { + key: `live-${line.seq}`, + ts_ms: line.ts_ms, + text: line.text, + severity: severityOfLive(line), + }; +} + +function toConsoleLineFromDb(child: TimelineEvent): ConsoleLine { + const severity: ConsoleLine['severity'] = + child.event_type === 'log_error' + ? 'error' + : child.event_type === 'log_warning' + ? 'warn' + : 'info'; + return { + key: `db-${child.id}`, + ts_ms: new Date(child.timestamp).getTime(), + text: child.description, + severity, + }; +} + /** Stable hue derived from a service/project name — same string → same color. */ function nameHue(name: string): number { let hash = 0; @@ -258,6 +328,48 @@ function saveTimelineCollapsed(collapsed: boolean): void { } } +// ─────────────── Width / resize constants ─────────────── +// Mirrors SidebarRail's model so both chrome panels behave identically: +// the user drags an edge gutter, we clamp to [MIN, MAX], and persist to +// localStorage so the next session remembers the exact width (not some +// rounded "small/medium/large" preset). +// +// MIN is 400 — below that the header action cluster (Standup + Refresh + +// Pin) starts chewing into the title and the filter pill row loses too +// many visible pills before the user has to scroll. MAX is 720 which +// still leaves room for the main log panel on a 1440px screen; going +// wider turns the activity bar into a second primary column, which is +// not what this panel is for. +const TIMELINE_COLLAPSED_W = 44; +const TIMELINE_MIN_W = 400; +const TIMELINE_MAX_W = 600; +const TIMELINE_DEFAULT_W = 420; +const TIMELINE_WIDTH_KEY = 'runhq.timeline.width.v1'; + +function loadTimelineWidth(): number { + if (typeof window === 'undefined') return TIMELINE_DEFAULT_W; + try { + const raw = window.localStorage.getItem(TIMELINE_WIDTH_KEY); + if (raw == null) return TIMELINE_DEFAULT_W; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n)) return TIMELINE_DEFAULT_W; + // Clamp on read too — if a previous build used a different range, we + // don't want a stuck-off-screen panel on upgrade. + return Math.max(TIMELINE_MIN_W, Math.min(TIMELINE_MAX_W, n)); + } catch { + return TIMELINE_DEFAULT_W; + } +} + +function saveTimelineWidth(width: number): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(TIMELINE_WIDTH_KEY, String(Math.round(width))); + } catch { + // non-fatal + } +} + // Hover debounce: short open avoids flicker when the cursor grazes the rail // in passing; longer close keeps the panel up if the user briefly strays. const HOVER_OPEN_DELAY_MS = 120; @@ -295,11 +407,11 @@ export function ActivityTimeline({ onClose, variant = 'overlay' }: ActivityTimel const [search, setSearch] = useState(''); const [selectedId, setSelectedId] = useState(null); const [modalEventId, setModalEventId] = useState(null); - const [showWeekly, setShowWeekly] = useState(false); const [standupCopied, setStandupCopied] = useState(false); const [detailCopied, setDetailCopied] = useState(false); const [collapsed, setCollapsedState] = useState(() => loadTimelineCollapsed()); const [hoverOpen, setHoverOpen] = useState(false); + const [width, setWidth] = useState(() => loadTimelineWidth()); // ─────────────── Persistence of pin/collapse state ─────────────── // Single source of truth: whenever `collapsed` flips, mirror it to @@ -310,19 +422,110 @@ export function ActivityTimeline({ onClose, variant = 'overlay' }: ActivityTimel saveTimelineCollapsed(collapsed); }, [collapsed]); + // Persist width too — only the final resting value, not every frame of + // the drag. `setWidth` already runs on every pointermove but localStorage + // is cheap enough that batching via rAF would be premature optimisation + // here; the delta is ~60 writes/sec for a one-second drag. + useEffect(() => { + saveTimelineWidth(width); + }, [width]); + // Cross-tab / cross-window sync — if another Runhq window toggles the - // pin state, reflect it here too. Harmless no-op in single-window usage. + // pin state or resizes the bar, reflect it here too. Harmless no-op in + // single-window usage. useEffect(() => { if (typeof window === 'undefined') return; const onStorage = (e: StorageEvent) => { - if (e.key !== TIMELINE_COLLAPSED_KEY) return; - setCollapsedState(e.newValue === '1'); + if (e.key === TIMELINE_COLLAPSED_KEY) { + setCollapsedState(e.newValue === '1'); + return; + } + if (e.key === TIMELINE_WIDTH_KEY && e.newValue != null) { + const n = Number.parseInt(e.newValue, 10); + if (Number.isFinite(n)) { + setWidth(Math.max(TIMELINE_MIN_W, Math.min(TIMELINE_MAX_W, n))); + } + } }; window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }, []); + + // ─────────────── Resize drag (left-edge gutter) ─────────────── + // The activity bar lives flush against the right window edge, so the + // user grabs its LEFT edge to resize — dragging leftward = grow wider, + // rightward = shrink. This is the inverse of SidebarRail (which grabs + // its right edge). Pointer capture keeps the drag tracking even when + // the cursor temporarily leaves the 6px handle, so fast drags don't + // "drop" mid-motion. + const resizing = useRef(false); + const resizeStartX = useRef(0); + const resizeStartW = useRef(0); + + const onResizeStart = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + resizing.current = true; + resizeStartX.current = e.clientX; + resizeStartW.current = width; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [width], + ); + + const onResizeMove = useCallback((e: React.PointerEvent) => { + if (!resizing.current) return; + // Inverted: dragging left (smaller clientX) should *grow* the panel + // because the panel's left edge is moving further from the window's + // right edge. + const delta = resizeStartX.current - e.clientX; + const next = resizeStartW.current + delta; + setWidth(Math.max(TIMELINE_MIN_W, Math.min(TIMELINE_MAX_W, next))); + }, []); + + const onResizeEnd = useCallback((e: React.PointerEvent) => { + resizing.current = false; + try { + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + } catch { + // pointer might already be released (e.g. pointercancel); non-fatal. + } + }, []); const [now, setNow] = useState(() => Date.now()); + // ─────────────── Live log buffer subscription ─────────────── + // The DB only persists keyword-matched stderr (log_error / log_warning), + // so "quiet" runs — a successful `yarn dev` that just prints "ready in + // 170 ms" — have NO child rows to show under their Started event. We + // supplement that by reading the in-memory log buffer (same data the + // LogPanel shows) and slicing it to the lifecycle event's time window. + // For current-session runs this is strictly richer than the DB: every + // stdout+stderr+system line ever emitted (up to a 5k cap) is available. + // For pre-restart/historical runs the buffer is empty — we fall back to + // the DB children so the modal still has something meaningful to show. + const logsBySvc = useAppStore((s) => s.logs); + const services = useAppStore((s) => s.services); + + // ─────────────── ANSI → HTML converter (theme-aware) ─────────────── + // Child log lines (log_error / log_warning) stored in the DB still carry + // their raw ANSI escape codes from the process's stderr. Rendering them + // through the same converter LogPanel uses means the per-event "console + // output" block at the bottom of an expanded lifecycle row looks exactly + // like the live terminal at the bottom of the screen — same palette, + // same colors, no jarring style shift between the two views. + const [isDark, setIsDark] = useState( + () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'), + ); + useEffect(() => { + if (typeof document === 'undefined') return; + const obs = new MutationObserver(() => + setIsDark(document.documentElement.classList.contains('dark')), + ); + obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => obs.disconnect(); + }, []); + const ansi = useMemo(() => makeAnsiConverter(isDark), [isDark]); + const hoverTimerRef = useRef(null); const searchInputRef = useRef(null); const listScrollRef = useRef(null); @@ -535,10 +738,62 @@ export function ActivityTimeline({ onClose, variant = 'overlay' }: ActivityTimel ); }, [events, search]); + // ─────────────── Event bucket grouping (parent ↔ child) ─────────────── + // `run_id` in our schema is now a *per-lifecycle-event* bucket id (see + // App.tsx): each `started` / `stopped` / `crashed` row mints its own id, + // and any `log_error` / `log_warning` / `file_changed` that arrives while + // that bucket is active stamps itself with that id. So Start owns the + // run's logs, Stop owns whatever trailing noise arrives after it, etc. + // + // We hide child events from the main feed and render them as an inline + // console-output block under their owning lifecycle row when expanded. + // (Legacy rows written before this model shared a run_id across started + + // stopped — `childrenByRun` still works there; the children just surface + // under whichever lifecycle row was kept in the current view.) + // + // Grouping is bypassed when the user filters to a child type (Errors, + // Warns, Files) — they want the flat list, not a single parent row. + const { visibleEvents, childrenByRun } = useMemo(() => { + const CHILD_TYPES: TimelineEvent['event_type'][] = ['log_error', 'log_warning', 'file_changed']; + const LIFECYCLE_TYPES: TimelineEvent['event_type'][] = [ + 'service_started', + 'service_stopped', + 'service_crashed', + ]; + const empty = new Map(); + if (filterType && CHILD_TYPES.includes(filterType as TimelineEvent['event_type'])) { + return { visibleEvents: filteredEvents, childrenByRun: empty }; + } + // Only roll children up into a run that actually HAS a lifecycle event + // in the current view. Otherwise an orphan error would vanish (hidden + // under a parent we'd never render). + const runsWithLifecycle = new Set(); + for (const e of filteredEvents) { + if (e.run_id && LIFECYCLE_TYPES.includes(e.event_type)) { + runsWithLifecycle.add(e.run_id); + } + } + const children = new Map(); + const hidden = new Set(); + for (const e of filteredEvents) { + if (!e.run_id) continue; + if (!CHILD_TYPES.includes(e.event_type)) continue; + if (!runsWithLifecycle.has(e.run_id)) continue; + const list = children.get(e.run_id); + if (list) list.push(e); + else children.set(e.run_id, [e]); + hidden.add(e.id); + } + return { + visibleEvents: filteredEvents.filter((e) => !hidden.has(e.id)), + childrenByRun: children, + }; + }, [filteredEvents, filterType]); + const grouped = useMemo(() => { const groups: Array<{ bucket: string; label: string; events: TimelineEvent[] }> = []; let currentBucket = ''; - for (const e of filteredEvents) { + for (const e of visibleEvents) { const b = dateBucket(e.timestamp); if (b !== currentBucket) { currentBucket = b; @@ -547,12 +802,188 @@ export function ActivityTimeline({ onClose, variant = 'overlay' }: ActivityTimel groups[groups.length - 1]?.events.push(e); } return groups; - }, [filteredEvents]); + }, [visibleEvents]); + + // ─────────────── Live console output per event, split by command ─────────────── + // Deterministic attribution by `run_id`, grouped by `cmd_name`. + // + // Both `LogLine` and the owning `service_started` row carry the same + // `run_id` — minted in the Rust supervisor BEFORE the child is spawned, + // stamped on every line the supervisor emits (prompt echo, pre-command + // banners, stdout/stderr, the `[exited]` closer), and surfaced on the + // `ServiceStatus` that produces the Started lifecycle event. Matching + // `line.run_id === event.run_id` is therefore a single lookup with zero + // dependence on IPC ordering or wall-clock jitter between `emit_log` + // and `emit_status`. + // + // Within each event, lines are kept bucketed by the command that + // produced them (we know the cmd_name from the `logsBySvc` key that + // held the line). This lets the UI render one tab per command inside + // a single lifecycle event — critical for multi-command services like + // a 4-process `dotnet run` stack, where merging all output into one + // chronological blob destroys the signal about *which* process logged + // what. + // + // A legacy time-window fallback stays in place ONLY for events that + // have no `run_id` — i.e. rows recorded by a previous app version + // (before run-id propagation) or log lines from a still-buffered + // pre-upgrade run. New rows will never fall into that branch. + const liveLinesByEventId = useMemo(() => { + const LIFECYCLE_TYPES: TimelineEvent['event_type'][] = [ + 'service_started', + 'service_stopped', + 'service_crashed', + ]; + const bySvc: Record = {}; + for (const e of events) { + if (!e.service_id) continue; + if (!LIFECYCLE_TYPES.includes(e.event_type)) continue; + (bySvc[e.service_id] ||= []).push(e); + } + // eventId → (cmdName → lines). Using a nested map (rather than flat + // `cmd::name` keys) keeps the intent explicit at the call site and + // avoids accidental key collisions if a cmd name ever contained `::`. + const result = new Map>(); + for (const [serviceId, lifecycleEvents] of Object.entries(bySvc)) { + const svc = services.find((s) => s.id === serviceId); + if (!svc) continue; + + // Primary path: for each cmd buffer, bucket ITS lines by run_id. + // Keeps (run_id, cmd_name) as a composite key without ever + // concatenating; when we later assemble the event's sections, we + // walk the cmd buffers in the service's declared order so tabs + // preserve the user's configured ordering. + type CmdBuckets = { + byRun: Map; + orphans: LogLine[]; + }; + const perCmd = new Map(); + let hasAnyLine = false; + let hasAnyOrphan = false; + for (const cmd of svc.cmds) { + const buf = logsBySvc[logKey(serviceId, cmd.name)]; + if (!buf || buf.lines.length === 0) continue; + hasAnyLine = true; + const buckets: CmdBuckets = { byRun: new Map(), orphans: [] }; + for (const l of buf.lines) { + if (l.run_id) { + const list = buckets.byRun.get(l.run_id); + if (list) list.push(l); + else buckets.byRun.set(l.run_id, [l]); + } else { + buckets.orphans.push(l); + hasAnyOrphan = true; + } + } + for (const list of buckets.byRun.values()) { + list.sort((a, b) => a.ts_ms - b.ts_ms || a.seq - b.seq); + } + buckets.orphans.sort((a, b) => a.ts_ms - b.ts_ms || a.seq - b.seq); + perCmd.set(cmd.name, buckets); + } + if (!hasAnyLine) continue; + + for (const ev of lifecycleEvents) { + if (!ev.run_id) continue; + const perCmdForEvent = new Map(); + for (const cmd of svc.cmds) { + const buckets = perCmd.get(cmd.name); + if (!buckets) continue; + const lines = buckets.byRun.get(ev.run_id); + if (lines && lines.length > 0) perCmdForEvent.set(cmd.name, lines); + } + if (perCmdForEvent.size > 0) result.set(ev.id, perCmdForEvent); + } + + // ── Fallback: time-window attribution for untagged data ── + // Only engage when we actually have untagged lines AND lifecycle + // events without a run_id. Skips the whole branch for freshly + // stamped runs (the common case) so we pay no extra cost there. + const legacyEvents = lifecycleEvents.filter((e) => !e.run_id); + if (legacyEvents.length === 0 || !hasAnyOrphan) continue; + const sortedLifecycle = [...lifecycleEvents].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + for (let i = 0; i < sortedLifecycle.length; i++) { + const ev = sortedLifecycle[i]!; + if (ev.run_id) continue; + const startMs = new Date(ev.timestamp).getTime(); + const endMs = + i + 1 < sortedLifecycle.length + ? new Date(sortedLifecycle[i + 1]!.timestamp).getTime() + : Date.now() + 1000; + const perCmdForEvent = new Map(); + for (const cmd of svc.cmds) { + const buckets = perCmd.get(cmd.name); + if (!buckets || buckets.orphans.length === 0) continue; + const windowLines = buckets.orphans.filter((l) => l.ts_ms >= startMs && l.ts_ms < endMs); + if (windowLines.length > 0) perCmdForEvent.set(cmd.name, windowLines); + } + if (perCmdForEvent.size > 0) result.set(ev.id, perCmdForEvent); + } + } + return result; + }, [events, logsBySvc, services]); + + // Assemble per-command sections for a lifecycle event. + // + // Live buffers (comprehensive, stream-complete) take precedence — we + // emit one tab per command that actually produced output during this + // run. The command order mirrors the service definition so the tabs + // don't reshuffle mid-session when a later command happens to log + // first. When the live buffer is empty (historical run whose in-memory + // transcript was flushed on app restart), we fall back to DB-persisted + // `log_error`/`log_warning` rows rolled up under one "All" tab — the + // DB schema doesn't carry `cmd_name` on those rows, so cmd-level + // splitting is not available for legacy data. + const consoleSectionsFor = useCallback( + (e: TimelineEvent): ConsoleSection[] => { + const live = liveLinesByEventId.get(e.id); + if (live && live.size > 0) { + const sections: ConsoleSection[] = []; + // Honor the service's declared command order so tabs are stable + // across sessions regardless of which cmd happened to log first. + const svc = e.service_id ? services.find((s) => s.id === e.service_id) : undefined; + const orderedNames = svc ? svc.cmds.map((c) => c.name) : [...live.keys()]; + for (const name of orderedNames) { + const lines = live.get(name); + if (!lines || lines.length === 0) continue; + const consoleLines = lines.map(toConsoleLineFromLive); + sections.push({ + key: `cmd::${name}`, + label: name, + lines: consoleLines, + errors: consoleLines.filter((c) => c.severity === 'error').length, + warnings: consoleLines.filter((c) => c.severity === 'warn').length, + }); + } + if (sections.length > 0) return sections; + } + const dbChildren = e.run_id ? childrenByRun.get(e.run_id) : undefined; + if (!dbChildren || dbChildren.length === 0) return []; + const consoleLines = dbChildren.map(toConsoleLineFromDb); + return [ + { + key: 'all', + label: 'All', + lines: consoleLines, + errors: consoleLines.filter((c) => c.severity === 'error').length, + warnings: consoleLines.filter((c) => c.severity === 'warn').length, + }, + ]; + }, + [liveLinesByEventId, childrenByRun, services], + ); + + // `all` is the logical opposite of a filter (= show everything), so it + // must NOT contribute to the active-filter count — otherwise picking + // "All" flips the empty state from "Nothing yet" to "Filtered out" and + // misleads the user into thinking something is hiding their events. const activeFilterCount = (filterType ? 1 : 0) + (filterProject ? 1 : 0) + - (timeRange !== '24h' ? 1 : 0) + + (timeRange !== '24h' && timeRange !== 'all' ? 1 : 0) + (search.trim() ? 1 : 0); const selectedEvent = events.find((e) => e.id === selectedId) ?? null; @@ -612,7 +1043,11 @@ export function ActivityTimeline({ onClose, variant = 'overlay' }: ActivityTimel }; const renderWeeklySparkline = () => { - if (!showWeekly || !weeklySummary || weeklySummary.length === 0) return null; + // Always visible — the 7-day rhythm is a low-cost, high-value glance + // (spot the errors spike, see which days were quiet) that used to be + // tucked behind a toggle nobody ever flipped. If there's literally no + // data yet we just skip rendering rather than show an empty frame. + if (!weeklySummary || weeklySummary.length === 0) return null; const maxTotal = Math.max( ...weeklySummary.map((s) => s.commits + s.services_started + s.errors), 1, @@ -711,8 +1146,11 @@ export function ActivityTimeline({ onClose, variant = 'overlay' }: ActivityTimel )} - {/* Type pills — horizontal scroll */} -
+ {/* Type pills — horizontal scroll. + Extra right padding (`pr-6`) keeps the last pill from hugging the + panel edge as the user scrolls, and the bottom padding gives the + overflow scrollbar room to sit without clipping the pill baseline. */} +
{FILTER_PILLS.map((f) => ( + )} +
+
+ + {shown === total ? `${total} total` : `${shown} of ${total}`} + + {selectedCount > 0 ? ( + + ) : ( + shown > 0 && ( + + ) + )} +
+
+ ); +} + +// ---- Bulk bar ----------------------------------------------------------- + +/** + * Sticky bottom bar that appears while one or more rows are selected. + * The primary win is **one click to copy a multi-package upgrade + * script** — a Monday-morning expo bump across 30 packages becomes one + * paste instead of 30 hover+copy cycles. + */ +function BulkBar({ + selectedCount, + commands, + onClear, +}: { + selectedCount: number; + commands: string[]; + onClear: () => void; +}) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (copied) { + const t = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(t); + } + }, [copied]); + + if (selectedCount === 0) return null; + + const script = commands.join('\n'); + const hasScript = commands.length > 0; + + return ( +
+
+ + {selectedCount} selected + + + {hasScript ? `(${commands.length} commands)` : '(no commands available)'} + +
+ + +
+
+
+ ); +} + +// ---- Misc cells --------------------------------------------------------- + +function SelectCheckbox({ selected, onToggle }: { selected: boolean; onToggle: () => void }) { + return ( + + ); +} + +function ZeroState({ icon, title, hint }: { icon: React.ReactNode; title: string; hint: string }) { + return ( +
+ {icon} +

{title}

+

{hint}

+
+ ); +} + +function NotScannedState({ + kind, + onRescan, + scanning, +}: { + kind: 'audit' | 'outdated'; + onRescan: () => void; + scanning: boolean; +}) { + const copy = + kind === 'audit' + ? { + title: 'No audit run yet', + hint: 'Scan dependencies to fetch open CVEs and advisories for this project.', + } + : { + title: 'No outdated check yet', + hint: 'Scan dependencies to compare the current lockfile against the latest registry versions.', + }; + + return ( +
+

{copy.title}

+

{copy.hint}

+ +
+ ); +} + +function EmptyState({ title, hint }: { title: string; hint: string }) { + return ( +
+

{title}

+

{hint}

+
+ ); +} + +function IconBtn({ + label, + onClick, + children, + size = 'md', +}: { + label: string; + onClick: () => void; + children: React.ReactNode; + size?: 'sm' | 'md'; +}) { + return ( + + ); +} + +// ---- Overflow menu ------------------------------------------------------ + +interface MenuItem { + label?: string; + icon?: React.ReactNode; + onClick?: () => void; + separator?: boolean; + /** + * Nested items turn this row into a submenu parent. The row opens on + * hover/focus and items inside close the whole menu on click — this is + * the standard desktop affordance (Finder, VS Code, Linear) and keeps + * the top-level list short while still exposing "Open in..." variants. + */ + children?: MenuItem[]; +} + +function OverflowMenu({ + open, + setOpen, + items, +}: { + open: boolean; + setOpen: (v: boolean) => void; + items: MenuItem[]; +}) { + const rootRef = useRef(null); + useEffect(() => { + if (!open) return; + function onDown(e: MouseEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open, setOpen]); + + return ( +
+ + {open && ( +
+ {items.map((it, i) => ( + setOpen(false)} + /> + ))} +
+ )} + {open && ( + + + + )} +
+ ); +} + +/** + * A single row inside the overflow menu. Handles three shapes: + * - separator (horizontal line) + * - terminal item (runs onClick, closes menu) + * - parent with `children` (expands a flyout submenu on hover/focus) + * + * The submenu pops to the **left** because the overflow menu itself is + * pinned to the drawer's right edge — opening rightward would clip off + * the viewport on a narrow window. Using a shared wrapper for hover + * ensures the submenu stays open while the cursor travels from the + * parent row to its children (no "closes mid-slide" frustration). + */ +function MenuRow({ item, index, close }: { item: MenuItem; index: number; close: () => void }) { + const [subOpen, setSubOpen] = useState(false); + + if (item.separator) { + return ; + } + + if (item.children && item.children.length > 0) { + return ( +
setSubOpen(true)} + onMouseLeave={() => setSubOpen(false)} + > + + {subOpen && ( +
+ {item.children.map((child, ci) => ( + + ))} +
+ )} +
+ ); + } + + return ( + + ); +} + +// ---- Upgrade command helpers ------------------------------------------- + +function upgradeCommandForAdvisory(runtime: string | null, a: Advisory): string | null { + // Only pin to fix_version when it looks like a plain semver — ranges + // like ">=2.3.4" don't translate cleanly into `@x.y.z` for the + // package manager, so we fall back to @latest. + const pinned = a.fix_version && /^\d+(\.\d+){0,2}$/.test(a.fix_version) ? a.fix_version : null; + return upgradeCommand(runtime, a.package, pinned); +} + +function upgradeCommandForOutdated(runtime: string | null, p: OutdatedPackage): string | null { + const pinned = p.latest && /^\d+(\.\d+){0,2}(-[\w.]+)?$/.test(p.latest) ? p.latest : null; + return upgradeCommand(runtime, p.name, pinned); +} + +/** + * Build the most-likely upgrade command for a package given the project's + * detected runtime. Returns `null` for runtimes we can't confidently + * generate for — better silence than a misleading `yarn upgrade` on a + * pnpm repo. + * + * These commands are intentionally *conservative*: they upgrade the + * specific package and leave the lockfile resolver to do the right thing. + * A full `npm audit fix --force` could cascade breaking changes, which is + * a decision the user should make, not a button. + */ +function upgradeCommand( + runtime: string | null, + pkg: string, + version?: string | null, +): string | null { + if (!pkg) return null; + const target = version ? `${pkg}@${version}` : `${pkg}@latest`; + switch (runtime) { + case 'node': + case 'npm': + return `npm install ${target}`; + case 'pnpm': + return version ? `pnpm update ${pkg}@${version}` : `pnpm update ${pkg} --latest`; + case 'yarn': + return `yarn up ${target}`; + case 'bun': + return `bun update ${target}`; + case 'rust': + case 'cargo': + return version ? `cargo add ${pkg}@${version}` : `cargo update -p ${pkg}`; + case 'go': + return `go get ${pkg}@${version ?? 'latest'}`; + case 'python': + case 'pip': + return version ? `pip install --upgrade ${pkg}==${version}` : `pip install --upgrade ${pkg}`; + case 'poetry': + return version ? `poetry add ${pkg}@${version}` : `poetry update ${pkg}`; + case 'uv': + return version + ? `uv pip install --upgrade ${pkg}==${version}` + : `uv pip install --upgrade ${pkg}`; + default: + return null; + } +} + +// ---- Copy button -------------------------------------------------------- + +/** + * Single-row copy button with a 1.5s "copied" visual confirmation. + * + * Silently swallows clipboard API failures (e.g. permission denied in + * locked-down environments). The user sees the normal icon state rather + * than an error, because there's nothing actionable they can do about it + * from the drawer. + */ +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +// ---- Registry fallback -------------------------------------------------- + +/** + * When the scanner didn't find an explicit `homepage` (common for npm when + * the package.json omits it), fall back to the npm registry page for the + * name. Better UX than a dead icon, and the registry page always has the + * changelog link + the README. + */ +function registryFallbackUrl(name: string): string | null { + if (!name) return null; + return `https://www.npmjs.com/package/${name}`; +} + +// ---- Filtering & sorting ------------------------------------------------ + +const SEVERITY_RANK: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + info: 4, +}; + +const BUMP_RANK: Record = { + major: 0, + minor: 1, + patch: 2, + other: 3, +}; + +function filterAndSortAdvisories( + items: Advisory[], + query: string, + severityFilter: Severity | 'all', +): Advisory[] { + const q = query.trim().toLowerCase(); + const out: Advisory[] = []; + for (const a of items) { + const sev = (SEVERITY_ORDER as readonly string[]).includes(a.severity) + ? (a.severity as Severity) + : 'low'; + if (severityFilter !== 'all' && sev !== severityFilter) continue; + if (q) { + const hay = `${a.package} ${a.id ?? ''} ${a.title}`.toLowerCase(); + if (!hay.includes(q)) continue; + } + out.push(a); + } + out.sort((a, b) => { + const ra = + SEVERITY_RANK[(a.severity as Severity) in SEVERITY_RANK ? (a.severity as Severity) : 'low']; + const rb = + SEVERITY_RANK[(b.severity as Severity) in SEVERITY_RANK ? (b.severity as Severity) : 'low']; + if (ra !== rb) return ra - rb; + return a.package.localeCompare(b.package); + }); + return out; +} + +function filterAndSortOutdated( + items: OutdatedPackage[], + query: string, + bumpFilter: BumpGroup | 'all', +): OutdatedPackage[] { + const q = query.trim().toLowerCase(); + const out: OutdatedPackage[] = []; + for (const p of items) { + const key = (p.bump as BumpGroup | null) ?? 'other'; + const group: BumpGroup = (BUMP_ORDER as readonly string[]).includes(key) ? key : 'other'; + if (bumpFilter !== 'all' && group !== bumpFilter) continue; + if (q) { + const hay = `${p.name} ${p.current} ${p.latest}`.toLowerCase(); + if (!hay.includes(q)) continue; + } + out.push(p); + } + out.sort((a, b) => { + const ka = ((a.bump as BumpGroup | null) ?? 'other') as BumpGroup; + const kb = ((b.bump as BumpGroup | null) ?? 'other') as BumpGroup; + const ra = BUMP_RANK[(ka in BUMP_RANK ? ka : 'other') as BumpGroup]; + const rb = BUMP_RANK[(kb in BUMP_RANK ? kb : 'other') as BumpGroup]; + if (ra !== rb) return ra - rb; + return a.name.localeCompare(b.name); + }); + return out; +} + +// ---- Row key helpers ---------------------------------------------------- + +function advisoryKey(a: Advisory, idx: number): string { + return `${a.package}::${a.id ?? ''}::${idx}`; +} + +function outdatedKey(p: OutdatedPackage): string { + return `${p.name}::${p.current}`; +} + +// ---- Utils -------------------------------------------------------------- + +function capitalise(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/apps/desktop/src/components/dashboard/Dashboard.tsx b/apps/desktop/src/components/dashboard/Dashboard.tsx index 99140ac..7181570 100644 --- a/apps/desktop/src/components/dashboard/Dashboard.tsx +++ b/apps/desktop/src/components/dashboard/Dashboard.tsx @@ -1,16 +1,22 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { - Activity, AlertTriangle, - CircleSlash, + ArrowDownWideNarrow, + Clock, + Cpu, FolderSearch, GitBranch, Layers, Loader2, + MemoryStick, + Package, Pencil, Play, Plus, + RefreshCw, RotateCcw, + ShieldAlert, + Sparkles, Square, Trash2, Zap, @@ -18,17 +24,22 @@ import { import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { Button } from '@/components/ui/Button'; import { Kbd } from '@/components/ui/Kbd'; -import { useAppStore, type DashboardGroupBy } from '@/store/useAppStore'; +import { Select } from '@/components/ui/Select'; +import { ProjectDetailDrawer, type DetailTab } from '@/components/ProjectDetailDrawer'; +import { useAppStore, type DashboardGroupBy, type DashboardSortBy } from '@/store/useAppStore'; import { ipc } from '@/lib/ipc'; import { cn } from '@/lib/cn'; +import { formatBytes, formatPercent } from '@/lib/format'; import { categoryForTags, CATEGORIES } from '@/lib/categories'; import { runtimeFromTags, inferRuntimeFromCmds, runtimeMeta, RUNTIMES } from '@/lib/runtimes'; import { sectionColor } from '@/lib/sectionColors'; import { modChord } from '@/lib/platform'; -import type { SectionId, ServiceDef, Status } from '@/types'; +import type { ProjectOverview, SectionId, ServiceDef, Status } from '@/types'; import type { GitStatus } from '@/types'; import { ServiceCard } from './ServiceCard'; -import { StatTile } from './StatTile'; +import { WorstOffenders } from './WorstOffenders'; +import { riskScore } from '@/lib/risk'; +import { ResourceHeatmap } from './ResourceHeatmap'; import { SectionHeader, HeaderAction } from './SectionHeader'; import { ActivityTimeline } from '@/components/ActivityTimeline'; @@ -46,15 +57,64 @@ type DashGroup = { services: ServiceDef[]; }; -const GROUP_OPTIONS: Array<{ key: DashboardGroupBy; label: string }> = [ - { key: 'none', label: 'None' }, - { key: 'category', label: 'Category' }, - { key: 'runtime', label: 'Runtime' }, - { key: 'status', label: 'Status' }, +/** + * Group axis options. Descriptions surface in the dropdown item's + * secondary line — "why would I pick this?" without a tooltip hover. + */ +const GROUP_OPTIONS: Array<{ + key: DashboardGroupBy; + label: string; + description: string; +}> = [ + // `none` is a misnomer in the store (kept for backward compatibility + // with existing prefs in localStorage); the actual behaviour is + // "group by user-defined Section" (Active / Archive / custom). + { key: 'none', label: 'Section', description: 'Your custom sections' }, + { key: 'category', label: 'Category', description: 'Backend, frontend, mobile…' }, + { key: 'runtime', label: 'Runtime', description: 'Node, Rust, Go, Python…' }, + { key: 'status', label: 'Status', description: 'Running vs stopped' }, +]; + +/** + * Sort axis options. + * + * Ordering here is intentional: + * 1. Name — the "boring" default that guarantees stable card layout. + * 2. Activity / Risk — health-focused (answers "what needs me?"). + * 3. Memory / CPU — resource-focused (answers "what's hot?"). + * The user's eye scans top-to-bottom; the most common answer ("which + * project should I fix first?") sits closest to the natural cursor. + */ +const SORT_OPTIONS: Array<{ + key: DashboardSortBy; + label: string; + description: string; +}> = [ + { key: 'name', label: 'Name', description: 'Alphabetical (stable)' }, + { key: 'activity', label: 'Last activity', description: 'Most recent commit first' }, + { key: 'risk', label: 'Risk', description: 'CVE + outdated composite' }, + { key: 'memory', label: 'Memory', description: 'Running projects by RSS' }, + { key: 'cpu', label: 'CPU', description: 'Running projects by CPU%' }, ]; const UNASSIGNED: SectionId = '__unassigned__'; +/** + * "Last scan" label for the dependency scan freshness indicator. + * + * Rolls up to minutes/hours/days because second-level precision on a + * scan that takes 10-60s would flicker annoyingly. For the first + * minute we just say "just now" — the user triggered it, they already + * know it was a moment ago. + */ +function scanFreshnessLabel(at: number, now: number): string { + const diff = Math.max(0, now - at); + if (diff < 60_000) return 'Scanned just now'; + if (diff < 3_600_000) return `Scanned ${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `Scanned ${Math.floor(diff / 3_600_000)}h ago`; + return `Scanned ${Math.floor(diff / 86_400_000)}d ago`; +} + function greeting(): string { const h = new Date().getHours(); if (h < 5) return 'Still up'; @@ -66,6 +126,7 @@ function greeting(): string { export function Dashboard({ onScan }: Props) { const services = useAppStore((s) => s.services); const statuses = useAppStore((s) => s.statuses); + const resources = useAppStore((s) => s.resources); const ports = useAppStore((s) => s.ports); const appVersion = useAppStore((s) => s.appVersion); const openEditor = useAppStore((s) => s.openEditor); @@ -77,11 +138,74 @@ export function Dashboard({ onScan }: Props) { const git = useAppStore((s) => s.git); const groupBy = useAppStore((s) => s.dashboardGroupBy); const setGroupBy = useAppStore((s) => s.setDashboardGroupBy); + const sortBy = useAppStore((s) => s.dashboardSortBy); + const setSortBy = useAppStore((s) => s.setDashboardSortBy); const sections = useAppStore((s) => s.sections); const serviceSection = useAppStore((s) => s.serviceSection); + const overview = useAppStore((s) => s.overview); + const overviewScanning = useAppStore((s) => s.overviewScanning); + const setOverviewScanning = useAppStore((s) => s.setOverviewScanning); + const patchOverviewScan = useAppStore((s) => s.patchOverviewScan); + const lastScanAt = useAppStore((s) => s.lastScanAt); + const editors = useAppStore((s) => s.editors); + // Tick `now` every 30s so the "Last scan: 4m ago" label re-renders + // as time passes. Using a local state (not a global store) keeps the + // re-render scoped to the dashboard — no point waking up the sidebar + // every 30s just because the deps label needs updating. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (lastScanAt == null) return; + const id = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(id); + }, [lastScanAt]); + + // Git filters stay as they were (single-select) — they slice the roster + // along the "what's in the repo?" axis. Attention filters (stale / risk / + // outdated) layer on top and use the "dependency-scan axis"; keeping + // them as a separate single-select avoids the combinatorial explosion + // of N×M filter chip states. type GitFilter = 'all' | 'dirty' | 'clean' | 'ahead' | 'behind' | 'no-upstream'; + type AttentionFilter = 'all' | 'stale' | 'risk' | 'outdated'; const [gitFilter, setGitFilter] = useState('all'); + const [attentionFilter, setAttentionFilter] = useState('all'); + + // Detail drawer lives at the dashboard level so flipping between + // different cards' deps/audit chips just swaps the drawer contents + // rather than unmount/remount on every click. + const [detail, setDetail] = useState<{ serviceId: string; tab: DetailTab } | null>(null); + const openDetail = useCallback((serviceId: string, tab: DetailTab) => { + setDetail({ serviceId, tab }); + }, []); + const closeDetail = useCallback(() => setDetail(null), []); + + // Index overview projects by service id for O(1) lookup while iterating + // the service list. Stable reference so `ServiceCard`s don't re-render + // every time *any* overview field changes unrelated to them. + const projectMetaById = useMemo(() => { + const map = new Map(); + if (overview) for (const p of overview.projects) map.set(p.service_id, p); + return map; + }, [overview]); + + const runScan = useCallback(async () => { + if (overviewScanning) return; + setOverviewScanning(true); + try { + const result = await ipc.scanProjectDependencies(true); + patchOverviewScan(result); + } catch (err) { + console.error('scan_project_dependencies failed', err); + } finally { + setOverviewScanning(false); + } + }, [overviewScanning, setOverviewScanning, patchOverviewScan]); + + const openDetailProject = useMemo( + () => + detail ? (overview?.projects.find((p) => p.service_id === detail.serviceId) ?? null) : null, + [detail, overview], + ); const [pendingConfirm, setPendingConfirm] = useState<{ message: string; @@ -103,6 +227,34 @@ export function Dashboard({ onScan }: Props) { return { running, starting, stopped, failed }; }, [services, statuses]); + // Two related memos derived from the same status sweep — kept + // separate because they have different change cadences: the Set is + // only used by the heatmap (cheap), the aggregates are read by the + // header on every resource tick (hot path). + const runningServiceIds = useMemo(() => { + const out = new Set(); + for (const svc of services) { + const st = statuses[svc.id]?.status ?? 'stopped'; + if (st === 'running' || st === 'starting') out.add(svc.id); + } + return out; + }, [services, statuses]); + + // Portfolio-wide resource totals — feeds the header aggregate line + // ("· 3.4 GB · 42% CPU"). Sums across *running* projects only so + // stale samples from crashed processes don't inflate the numbers. + const totals = useMemo(() => { + let cpu = 0; + let mem = 0; + for (const id of runningServiceIds) { + const s = resources[id]; + if (!s) continue; + cpu += s.cpu_percent; + mem += s.memory_bytes; + } + return { cpu, mem }; + }, [runningServiceIds, resources]); + const total = services.length; const gitStats = useMemo(() => { @@ -135,10 +287,144 @@ export function Dashboard({ onScan }: Props) { return fn[gitFilter] ?? (() => true); }, [gitFilter]); + // Aggregate attention metrics — feeds both the KPI strip and the + // secondary filter pills. One pass over `overview.projects` so we + // never recompute inside the JSX tree. Returns `null` before the + // first overview poll lands (prevents flashing "0 CVE" while data + // is still loading). + // + // `hasDepScan` is separate from "overview exists" because git/stale + // data is always live, while audit/outdated requires an explicit + // scan. Tiles use this to choose between *no-data* (em-dash) and + // *zero-data* (grey 0) rendering. + const attentionStats = useMemo(() => { + if (!overview) return null; + let stale = 0; + let dirty = 0; + let riskProjects = 0; + let outdatedProjects = 0; + let cveCritical = 0; + let cveHigh = 0; + let cveMedium = 0; + let cveLow = 0; + let outdatedPackages = 0; + for (const p of overview.projects) { + if (p.is_stale) stale++; + if (p.git_status?.is_dirty) dirty++; + const critical = p.audit?.critical ?? 0; + const high = p.audit?.high ?? 0; + const medium = p.audit?.medium ?? 0; + const low = p.audit?.low ?? 0; + if (critical + high > 0) riskProjects++; + cveCritical += critical; + cveHigh += high; + cveMedium += medium; + cveLow += low; + const outTotal = p.outdated?.total ?? 0; + if (outTotal > 0) outdatedProjects++; + outdatedPackages += outTotal; + } + return { + stale, + dirty, + risk: riskProjects, + outdated: outdatedProjects, + cveCritical, + cveHigh, + cveMedium, + cveLow, + outdatedPackages, + hasDepScan: overview.has_dependency_scan, + }; + }, [overview]); + + const attentionFilterFn = useCallback( + (svc: ServiceDef): boolean => { + if (attentionFilter === 'all') return true; + const meta = projectMetaById.get(svc.id); + if (!meta) return false; + switch (attentionFilter) { + case 'stale': + return meta.is_stale; + case 'risk': + return (meta.audit?.critical ?? 0) + (meta.audit?.high ?? 0) > 0; + case 'outdated': + return (meta.outdated?.total ?? 0) > 0; + default: + return true; + } + }, + [attentionFilter, projectMetaById], + ); + const eligibleServices = useMemo(() => { const stackServiceIds = new Set(stacks.flatMap((st) => st.service_ids)); - return services.filter((svc) => !stackServiceIds.has(svc.id) && gitFilterFn(svc, git[svc.id])); - }, [services, stacks, gitFilterFn, git]); + return services.filter( + (svc) => + !stackServiceIds.has(svc.id) && gitFilterFn(svc, git[svc.id]) && attentionFilterFn(svc), + ); + }, [services, stacks, gitFilterFn, git, attentionFilterFn]); + + /** + * Build a comparator for the current sort axis. + * + * Always falls back to `name` as a secondary key so ties (e.g. two + * projects both with zero CVEs, both idle) have a deterministic + * order — otherwise React's keyed reconciliation ping-pongs cards + * on every render. + * + * For resource axes, non-running projects are pushed to the bottom + * (their sample is either zero or stale) rather than sprinkling + * them between running ones — the user asked "who's hottest", a + * dormant project isn't an answer to that question. + */ + const comparator = useMemo(() => { + const byName = (a: ServiceDef, b: ServiceDef) => a.name.localeCompare(b.name); + switch (sortBy) { + case 'name': + return byName; + case 'activity': + return (a: ServiceDef, b: ServiceDef) => { + const ma = projectMetaById.get(a.id)?.last_activity; + const mb = projectMetaById.get(b.id)?.last_activity; + const ta = ma ? new Date(ma).getTime() : 0; + const tb = mb ? new Date(mb).getTime() : 0; + if (tb !== ta) return tb - ta; + return byName(a, b); + }; + case 'risk': + return (a: ServiceDef, b: ServiceDef) => { + const ra = riskScore(projectMetaById.get(a.id)); + const rb = riskScore(projectMetaById.get(b.id)); + if (rb !== ra) return rb - ra; + return byName(a, b); + }; + case 'memory': + return (a: ServiceDef, b: ServiceDef) => { + const ma = runningServiceIds.has(a.id) ? (resources[a.id]?.memory_bytes ?? 0) : -1; + const mb = runningServiceIds.has(b.id) ? (resources[b.id]?.memory_bytes ?? 0) : -1; + if (mb !== ma) return mb - ma; + return byName(a, b); + }; + case 'cpu': + return (a: ServiceDef, b: ServiceDef) => { + const ca = runningServiceIds.has(a.id) ? (resources[a.id]?.cpu_percent ?? 0) : -1; + const cb = runningServiceIds.has(b.id) ? (resources[b.id]?.cpu_percent ?? 0) : -1; + if (cb !== ca) return cb - ca; + return byName(a, b); + }; + default: + return byName; + } + }, [sortBy, projectMetaById, runningServiceIds, resources]); + + // Sorted copy of the ungrouped roster (used only when + // `groupBy === 'none'` and there are no section buckets). Grouped + // code paths sort inside each group via the same `comparator`. + const sortedEligibleServices = useMemo( + () => [...eligibleServices].sort(comparator), + [eligibleServices, comparator], + ); const groups = useMemo(() => { if (groupBy === 'none') { @@ -152,7 +438,7 @@ export function Dashboard({ onScan }: Props) { if (bucket) bucket.push(svc); else bySection.set(key, [svc]); } - for (const list of bySection.values()) list.sort((a, b) => a.name.localeCompare(b.name)); + for (const list of bySection.values()) list.sort(comparator); const result: DashGroup[] = []; for (const sec of sections) { const svcs = bySection.get(sec.id); @@ -182,8 +468,8 @@ export function Dashboard({ onScan }: Props) { if (st === 'running' || st === 'starting') running.push(svc); else stopped.push(svc); } - running.sort((a, b) => a.name.localeCompare(b.name)); - stopped.sort((a, b) => a.name.localeCompare(b.name)); + running.sort(comparator); + stopped.sort(comparator); const out: DashGroup[] = []; if (running.length > 0) out.push({ @@ -216,7 +502,7 @@ export function Dashboard({ onScan }: Props) { } group.services.push(svc); } - for (const g of byKey.values()) g.services.sort((a, b) => a.name.localeCompare(b.name)); + for (const g of byKey.values()) g.services.sort(comparator); const order = new Map(); RUNTIMES.forEach((r, i) => order.set(r.key, i)); return [...byKey.values()].sort( @@ -234,13 +520,13 @@ export function Dashboard({ onScan }: Props) { } group.services.push(svc); } - for (const g of byKey.values()) g.services.sort((a, b) => a.name.localeCompare(b.name)); + for (const g of byKey.values()) g.services.sort(comparator); const order = new Map(); CATEGORIES.forEach((c, i) => order.set(c.key, i)); return [...byKey.values()].sort( (a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999), ); - }, [eligibleServices, groupBy, statuses, sections, serviceSection]); + }, [eligibleServices, groupBy, statuses, sections, serviceSection, comparator]); if (total === 0) { return ( @@ -293,387 +579,605 @@ export function Dashboard({ onScan }: Props) { ); } - const healthPct = total > 0 ? Math.round((stats.running / total) * 100) : 0; const hasRunning = stats.running > 0; return (
-
- {hasRunning && ( -
- )} - -
-
-
-
- - - - RunHQ - {appVersion && ( - v{appVersion} - )} - · - - {greeting()} - -
-

- {hasRunning ? ( - <> - {stats.running} - service{stats.running > 1 ? 's' : ''} running - - ) : stats.failed > 0 ? ( - <> - {stats.failed} - needs attention - - ) : ( - All quiet - )} -

-

- {total} configured - · - {ports.length} listening ports - {stats.starting > 0 && ( - <> - · - - - {stats.starting} starting - - - )} -

-
-
-
- {GROUP_OPTIONS.map((opt) => ( - - ))} -
- - - -
-
- -
- } - badge={`${healthPct}%`} - active={stats.running > 0} - /> - 0 && 'animate-spin')} />} - active={stats.starting > 0} - /> - } + {/* + Main column holds the scrolling dashboard. The detail drawer is + rendered at the *root* level (below) with `absolute inset-0` so + it spans main column + activity panel together — otherwise the + activity panel squeezes against the drawer's right edge and the + two visually fight for the same "right side" space. + The overflow-hidden here guarantees card scroll doesn't bleed + past the column and behind a potential drawer backdrop. + */} +
+
+ {hasRunning && ( +
- 0 ? 'error' : 'stopped'} - icon={} - active={stats.failed > 0} - /> -
+ )} - {total > 0 && ( -
- - Uptime - -
-
+
+
+
+ + + + RunHQ + {appVersion && ( + v{appVersion} + )} + · + + {greeting()} + +
+

+ {hasRunning ? ( + <> + {stats.running} + + {' '} + service{stats.running > 1 ? 's' : ''} running + + + ) : stats.failed > 0 ? ( + <> + {stats.failed} + needs attention + + ) : ( + All quiet + )} +

+

+ {total} configured + · + {ports.length} listening ports + {stats.running > 0 && totals.mem > 0 && ( + <> + · + 1 ? 's' : ''}`} + > + + {formatBytes(totals.mem)} + + · + 1 ? 's' : ''}`} + > + + {formatPercent(totals.cpu)} + + + )} + {stats.starting > 0 && ( + <> + · + + + {stats.starting} starting + + + )} +

+
+
+ {lastScanAt != null && !overviewScanning && ( + + {scanFreshnessLabel(lastScanAt, now)} + + )} + + + +
- - {healthPct}% - -
- )} + - {gitStats.dirty + gitStats.clean > 0 && ( -
- - - Git - -
- setGitFilter('all')} - label="All" - count={gitStats.dirty + gitStats.clean} - /> - setGitFilter('dirty')} - label="Dirty" - count={gitStats.dirty} - tone="dirty" - /> - setGitFilter('clean')} - label="Clean" - count={gitStats.clean} - tone="clean" - /> - setGitFilter('ahead')} - label="Ahead" - count={gitStats.ahead} - /> - setGitFilter('behind')} - label="Behind" - count={gitStats.behind} + {/* + Process-state and health-debt tiles used to sit here as two + 4-up grids (Running/Starting/Stopped/Failed + CVE/Outdated/ + Stale/Dirty). They were dropped because every signal they + carried is surfaced more densely elsewhere — process state + in the page header's summary line ("N running · M starting"), + health debt as clickable chips in the filter bar below, and + the actual outliers in the WorstOffenders band. Keeping the + tiles burned ~200px of vertical real estate to show mostly + zeros on a calm day, pushing the real work (project cards) + below the fold. + */} + + {overview && overview.projects.length > 0 && ( + + )} + + {/* + Resource heatmap — ROADMAP §1 "which projects are burning + most RAM/CPU?". Only rendered when there's at least one + running sample; sits below the health bands so the reading + order is: health → risk → load. + */} + {stats.running > 0 && ( + useAppStore.getState().setSelected(id)} + limit={5} + /> + )} + + {/* + Card controls bar — sits immediately above the roster and + owns every control that rearranges or filters it. + Reading order (left → right): organize (Group / Sort), then + filter (Git, Attention). Group/Sort are always present so + the user has a stable mental model; Git/Attention pills + only appear when there's data to filter on, otherwise + they'd be noise. + */} + {/* + Each logical cluster — Organize · Git · Attention — is a + single flex child so its label stays glued to its pills and + the group reads as one unit. Between clusters we use a + larger `gap-x-5` + a vertical divider so the eye sees three + distinct "chambers" on a single row, not a flat stream of + controls. Within a cluster, gaps are deliberately tighter + (gap-2 / gap-1) to amplify the same contrast. + */} +
+ + setSortBy(v as DashboardSortBy)} + options={SORT_OPTIONS.map((o) => ({ + value: o.key, + label: o.label, + description: o.description, + }))} + ariaLabel="Sort cards by" + leading={} /> -
+ + + {gitStats.dirty + gitStats.clean > 0 && ( + <> + + }> + setGitFilter('all')} + label="All" + count={gitStats.dirty + gitStats.clean} + /> + setGitFilter('dirty')} + label="Dirty" + count={gitStats.dirty} + tone="dirty" + /> + setGitFilter('clean')} + label="Clean" + count={gitStats.clean} + tone="clean" + /> + setGitFilter('ahead')} + label="Ahead" + count={gitStats.ahead} + /> + setGitFilter('behind')} + label="Behind" + count={gitStats.behind} + /> + setGitFilter('no-upstream')} + label="No upstream" + count={gitStats.noUpstream} + /> + + + )} + + {attentionStats && + attentionStats.stale + attentionStats.risk + attentionStats.outdated > 0 && ( + <> + + } + > + {attentionStats.stale > 0 && ( + + setAttentionFilter((a) => (a === 'stale' ? 'all' : 'stale')) + } + label="Stale" + count={attentionStats.stale} + icon={} + /> + )} + {attentionStats.risk > 0 && ( + setAttentionFilter((a) => (a === 'risk' ? 'all' : 'risk'))} + label="Risk" + count={attentionStats.risk} + tone="risk" + icon={} + /> + )} + {attentionStats.outdated > 0 && ( + + setAttentionFilter((a) => (a === 'outdated' ? 'all' : 'outdated')) + } + label="Outdated" + count={attentionStats.outdated} + tone="outdated" + icon={} + /> + )} + + + )}
- )} - {stacks.map((stack) => { - const stackServices = stack.service_ids - .map((sid) => services.find((s) => s.id === sid)) - .filter((svc): svc is ServiceDef => !!svc && gitFilterFn(svc, git[svc.id])); - const runningCount = stackServices.filter( - (svc) => (statuses[svc.id]?.status ?? 'stopped') === 'running', - ).length; - const anyRunning = runningCount > 0; - return ( -
{ - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }} - onDrop={async (e) => { - e.preventDefault(); - const svcId = e.dataTransfer.getData('application/x-service-id'); - if (!svcId || stack.service_ids.includes(svcId)) return; - const updated = { ...stack, service_ids: [...stack.service_ids, svcId] }; - await ipc.updateStack(updated); - upsertStack(updated); - }} - > - } - label={stack.name} - tone="accent" - count={stackServices.length} - runningCount={runningCount} - onClick={() => setSelectedStack(stack.id)} - actions={ - <> - {anyRunning ? ( + {stacks.map((stack) => { + const stackServices = stack.service_ids + .map((sid) => services.find((s) => s.id === sid)) + .filter( + (svc): svc is ServiceDef => + !!svc && gitFilterFn(svc, git[svc.id]) && attentionFilterFn(svc), + ); + const runningCount = stackServices.filter( + (svc) => (statuses[svc.id]?.status ?? 'stopped') === 'running', + ).length; + const anyRunning = runningCount > 0; + return ( +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }} + onDrop={async (e) => { + e.preventDefault(); + const svcId = e.dataTransfer.getData('application/x-service-id'); + if (!svcId || stack.service_ids.includes(svcId)) return; + const updated = { ...stack, service_ids: [...stack.service_ids, svcId] }; + await ipc.updateStack(updated); + upsertStack(updated); + }} + > + } + label={stack.name} + tone="accent" + count={stackServices.length} + runningCount={runningCount} + onClick={() => setSelectedStack(stack.id)} + actions={ + <> + {anyRunning ? ( + void ipc.stopStack(stack.id)} + tone="danger" + > + + + ) : ( + void ipc.startStack(stack.id)} + tone="run" + > + + + )} void ipc.stopStack(stack.id)} - tone="danger" + title="Restart all" + onClick={() => void ipc.restartStack(stack.id)} > - + + + openStackEditor(stack)}> + - ) : ( void ipc.startStack(stack.id)} - tone="run" + title="Delete stack" + tone="danger" + onClick={() => { + setPendingConfirm({ + message: `Delete stack "${stack.name}"?`, + onConfirm: async () => { + setPendingConfirm(null); + await ipc.removeStack(stack.id); + removeStack(stack.id); + }, + }); + }} > - + - )} - void ipc.restartStack(stack.id)} - > - - - openStackEditor(stack)}> - - - { - setPendingConfirm({ - message: `Delete stack "${stack.name}"?`, - onConfirm: async () => { - setPendingConfirm(null); - await ipc.removeStack(stack.id); - removeStack(stack.id); - }, - }); - }} - > - - - - } - /> -
- {stackServices.map((svc) => ( - - ))} -
-
- ); - })} - - {groups.length > 0 - ? groups.map((group) => { - const runningInGroup = group.services.filter( - (svc) => (statuses[svc.id]?.status ?? 'stopped') === 'running', - ).length; - return ( -
- -
- {group.services.map((svc) => ( - - ))} -
-
- ); - }) - : eligibleServices.length > 0 && ( -
- {eligibleServices.map((svc) => ( - - ))} -
- )} + + } + /> +
+ {stackServices.map((svc) => ( + + ))} +
+
+ ); + })} -
- Everything runs locally. No telemetry. - - {modChord('K')} - quick jump - -
+ {groups.length > 0 + ? groups.map((group) => { + const runningInGroup = group.services.filter( + (svc) => (statuses[svc.id]?.status ?? 'stopped') === 'running', + ).length; + return ( +
+ +
+ {group.services.map((svc) => ( + + ))} +
+
+ ); + }) + : sortedEligibleServices.length > 0 && ( +
+ {sortedEligibleServices.map((svc) => ( + + ))} +
+ )} + +
+ Everything runs locally. No telemetry. + + {modChord('K')} + quick jump + +
+
+ {pendingConfirm && ( + setPendingConfirm(null)} + /> + )}
- {pendingConfirm && ( - setPendingConfirm(null)} - /> - )}
+ {openDetailProject && detail && ( + void runScan()} + onClose={closeDetail} + onJump={(id) => { + closeDetail(); + useAppStore.getState().setSelected(id); + }} + editors={editors} + onOpenPath={(path) => void ipc.openPath(path)} + onOpenInEditor={async (command, path) => { + try { + await ipc.openInEditor(command, path); + } catch { + // Fallback so the click is never a dead-end — if the editor + // binary can't be spawned (uninstalled, path changed, + // permission denied), surface the folder in the OS file + // manager instead of silently failing. + void ipc.openPath(path); + } + }} + onOpenUrl={(url) => void ipc.openUrl(url)} + /> + )} +
+ ); +} + +/** + * Chamber wrapper for one cluster of filter-bar controls (e.g. the Git + * pill group, or the Organize selects). Keeps label + children glued + * together so the row reads as {Organize} · {Git} · {Attention} + * instead of a flat stream where the eye can't tell where one group + * ends and the next begins. + */ +function FilterGroup({ + label, + icon, + children, +}: { + label?: string; + icon?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+ {(icon || label) && ( +
+ {icon} + {label && ( + + {label} + + )} +
+ )} +
{children}
); } +/** Vertical rule between filter chambers. */ +function GroupDivider() { + return ; +} + function FilterPill({ active, onClick, label, count, tone, + icon, }: { active: boolean; onClick: () => void; label: string; count: number; - tone?: 'dirty' | 'clean'; + tone?: 'dirty' | 'clean' | 'risk' | 'outdated'; + icon?: React.ReactNode; }) { + const activeTone = + tone === 'dirty' + ? 'bg-status-starting/20 text-status-starting' + : tone === 'clean' + ? 'bg-status-running/20 text-status-running' + : tone === 'risk' + ? 'bg-status-error/20 text-status-error' + : tone === 'outdated' + ? 'bg-orange-500/20 text-orange-300' + : 'bg-accent/15 text-accent'; return ( diff --git a/apps/desktop/src/components/dashboard/ResourceHeatmap.tsx b/apps/desktop/src/components/dashboard/ResourceHeatmap.tsx new file mode 100644 index 0000000..95fafd9 --- /dev/null +++ b/apps/desktop/src/components/dashboard/ResourceHeatmap.tsx @@ -0,0 +1,152 @@ +import { Flame, ArrowUpRight, Cpu, MemoryStick } from 'lucide-react'; +import { cn } from '@/lib/cn'; +import { formatBytes, formatPercent } from '@/lib/format'; +import { cpuToneClass, memoryToneClass } from '@/lib/resourceTone'; +import type { ResourceSample, ServiceDef } from '@/types'; + +/** + * Resource heatmap — answers ROADMAP §1's "which 3 projects are burning + * 4GB combined?" without the user having to hunt through card-level + * `ResourceBadge`s. + * + * Design choices: + * • Memory is the primary sort axis. CPU burst + returns to baseline; + * memory tends to be sticky and more predictive of "this process is + * leaking / misconfigured". Users who care more about CPU still see + * the CPU% per row, we just don't sort by it. + * • Relative bar (width = this-process / top-process) instead of + * absolute (width = this-process / machine-total). The user has + * 20 projects, they want to know *which* is hot relative to its + * peers, not how much of their 64GB is still free. + * • Only running projects make the list. An idle service with a + * ghost sample from 20 minutes ago would pollute the ranking. + * • Renders nothing when there are no running samples — a portfolio + * at rest shouldn't eat a whole section of dashboard space + * announcing zero-usage. + */ +interface HeatmapEntry { + service: ServiceDef; + cpu: number; + mem: number; +} + +export function ResourceHeatmap({ + services, + resources, + runningIds, + onJump, + limit = 5, +}: { + services: ServiceDef[]; + resources: Record; + /** + * Set of service ids with status === "running" (or "starting"). We + * filter on this rather than "has a resource sample" because crashed + * services retain their last sample for ~60s before GC. + */ + runningIds: Set; + onJump: (serviceId: string) => void; + limit?: number; +}) { + const entries: HeatmapEntry[] = []; + let totalMem = 0; + let totalCpu = 0; + for (const svc of services) { + if (!runningIds.has(svc.id)) continue; + const sample = resources[svc.id]; + if (!sample) continue; + entries.push({ service: svc, cpu: sample.cpu_percent, mem: sample.memory_bytes }); + totalMem += sample.memory_bytes; + totalCpu += sample.cpu_percent; + } + if (entries.length === 0) return null; + + entries.sort((a, b) => b.mem - a.mem); + const shown = entries.slice(0, limit); + const maxMem = shown[0]?.mem ?? 1; + const hiddenCount = entries.length - shown.length; + + return ( +
+
+ + + Running hot + + {entries.length} running +
+ + + {formatPercent(totalCpu)} + + + + {formatBytes(totalMem)} + +
+
+
    + {shown.map((entry) => ( + + ))} + {hiddenCount > 0 && ( +
  • + +{hiddenCount} more running below top {limit} +
  • + )} +
+
+ ); +} + +function HeatmapRow({ + entry, + maxMem, + onJump, +}: { + entry: HeatmapEntry; + maxMem: number; + onJump: (serviceId: string) => void; +}) { + const { service: svc, cpu, mem } = entry; + // Relative bar width (min 4% so even the smallest process shows a + // visible sliver rather than a dot). + const pct = Math.max(4, Math.round((mem / Math.max(maxMem, 1)) * 100)); + const cpuTone = cpuToneClass(cpu); + const memTone = memoryToneClass(mem); + return ( +
  • + +
  • + ); +} diff --git a/apps/desktop/src/components/dashboard/ServiceCard.tsx b/apps/desktop/src/components/dashboard/ServiceCard.tsx index 13d5f50..d0c8104 100644 --- a/apps/desktop/src/components/dashboard/ServiceCard.tsx +++ b/apps/desktop/src/components/dashboard/ServiceCard.tsx @@ -1,5 +1,18 @@ import { useState } from 'react'; -import { FolderOpen, Globe, Pencil, Play, RotateCcw, Square, Trash2 } from 'lucide-react'; +import { + Clock, + FolderOpen, + Globe, + Loader2, + Package, + Pencil, + Play, + RotateCcw, + Shield, + Square, + Trash2, +} from 'lucide-react'; +import type { DetailTab } from '@/components/ProjectDetailDrawer'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { EditorDropdown } from '@/components/EditorDropdown'; import { GitStatusChip } from '@/components/GitStatusChip'; @@ -11,7 +24,34 @@ import { ipc } from '@/lib/ipc'; import { cn } from '@/lib/cn'; import { localUrl } from '@/lib/url'; import { runtimeFromTags, inferRuntimeFromCmds, runtimeMeta } from '@/lib/runtimes'; -import type { ServiceDef, Status } from '@/types'; +import type { AuditResult, OutdatedResult, ProjectOverview, ServiceDef, Status } from '@/types'; + +/** + * Human-readable "how stale is this project?" label for the card badge. + * + * Rolled up to weeks / months / years because at 30+ days the specific + * day count stops being useful — the point is "really old", not + * precision. `45d idle` would technically fit but feels clinical; + * `6w idle` rolls up the same span more comfortably. + * + * Returns a fallback `Stale` label when we can't compute an age + * (missing timestamp, malformed date). Better to be honest that we + * don't know than to show a synthetic zero. + */ +function staleLabel(lastActivity: string | null): string { + if (!lastActivity) return 'Stale'; + try { + const diffMs = Date.now() - new Date(lastActivity).getTime(); + if (!Number.isFinite(diffMs) || diffMs < 0) return 'Stale'; + const days = Math.floor(diffMs / 86_400_000); + if (days >= 365) return `${Math.floor(days / 365)}y idle`; + if (days >= 30) return `${Math.floor(days / 30)}mo idle`; + if (days >= 7) return `${Math.floor(days / 7)}w idle`; + return `${days}d idle`; + } catch { + return 'Stale'; + } +} function CardAction({ title, @@ -46,9 +86,24 @@ function CardAction({ export function ServiceCard({ svc, draggable: cardDraggable, + projectMeta, + onOpenDetail, }: { svc: ServiceDef; draggable?: boolean; + /** + * Cross-project overview slice for this service (stale flag, outdated + * counts, audit counts). Optional because the overview poll runs + * lazily — the card renders fine without it, it just hides the + * attention chips until the data arrives. + */ + projectMeta?: ProjectOverview | null; + /** + * Called when the user clicks a dep / audit chip. Opens the shared + * `ProjectDetailDrawer` on the requested tab. Dashboard owns the + * drawer so it survives switching between cards without flicker. + */ + onOpenDetail?: (serviceId: string, tab: DetailTab) => void; }) { const [pendingConfirm, setPendingConfirm] = useState<{ message: string; @@ -62,6 +117,7 @@ export function ServiceCard({ const logs = useAppStore((s) => s.logs); const resourceSample = useAppStore((s) => s.resources[svc.id]); const resourceHistory = useAppStore((s) => s.resourceHistory[svc.id]); + const overviewScanning = useAppStore((s) => s.overviewScanning); const st: Status = statuses[svc.id]?.status ?? 'stopped'; const isRunning = st === 'running' || st === 'starting'; const logLines = @@ -105,12 +161,65 @@ export function ServiceCard({ /> )} + {/* + Card header — two clusters, opposite alignment: + Left = identity : status dot + name + `stale` badge + Right = signals+tech : health chips (Dep / CVE), runtime, port + + Dep / CVE chips live UP here (not in the action row below) on + purpose: they describe what this project *is* right now (out of + date, vulnerable), which is the same axis as `stale` and + `runtime`. Mixing them with action buttons was a semantic + error — "20" next to `[Delete]` read as "20 of something you + can delete". Here they read as "20 pending upgrades", which + is the intended message. + */}
    {svc.name} + {projectMeta?.is_stale && ( + + + {staleLabel(projectMeta.last_activity)} + + )}
    -
    +
    e.stopPropagation()}> + {/* + Scan-in-progress spinner. Only shown when the global scan is + running *and* this project has a runtime the scanners actually + touch — otherwise the icon would spin forever for a + no-runtime project (e.g. a pure docker-compose stack). + */} + {overviewScanning && projectMeta?.runtime && ( + + + + )} + {projectMeta?.outdated && onOpenDetail && ( + onOpenDetail(svc.id, 'outdated')} + /> + )} + {projectMeta?.audit && onOpenDetail && ( + onOpenDetail(svc.id, 'advisories')} + /> + )} {runtime && ( )} + {/* + Right-aligned cluster in the action row — now holds only git + and editor, both of which are interactive popovers (not + "signals"). They belong with the action buttons semantically. + Health chips (Dep / CVE / Stale) moved up to the header row. + */}
    @@ -235,3 +350,92 @@ export function ServiceCard({
    ); } + +/** + * Dependency-freshness chip for the ServiceCard action row. + * + * Design decisions (card-level chips must work in ~60px of horizontal + * space across 20+ cards without tooltip): + * • Icon carries the *domain* (📦 = deps). Users learn it fast. + * • Number carries the *magnitude*. Tabular-nums keeps columns tidy + * when you scan a stack of cards vertically. + * • Colour carries the *severity*. Same token as the Worst Offenders + * band and the Outdated filter pill → one visual language across + * the dashboard. + * • Tooltip carries the *breakdown* for users who pause on a specific + * card. No label text on the chip itself — "14 pkg" / "14 old" add + * noise without information the icon doesn't already convey. + * + * Hidden when the project has zero outdated packages. Showing "0" would + * be visual noise that rewards the clean-state project with a yellow + * badge, which is backwards. + */ +function OutdatedChip({ outdated, onClick }: { outdated: OutdatedResult; onClick: () => void }) { + if (outdated.total === 0) return null; + const tone = + outdated.major > 0 + ? 'bg-orange-500/15 text-orange-200 hover:bg-orange-500/25 border-orange-500/25' + : outdated.minor > 0 + ? 'bg-yellow-500/15 text-yellow-200 hover:bg-yellow-500/25 border-yellow-500/25' + : 'bg-emerald-500/15 text-emerald-200 hover:bg-emerald-500/25 border-emerald-500/25'; + return ( + + ); +} + +/** + * Audit/CVE chip. Same design principles as `OutdatedChip` (icon = domain, + * number = magnitude, colour = severity). The one asymmetry: when any + * critical CVE is present, the chip gets a subtle pulse ring — security + * criticality *must* outbid visual hierarchy of neighbouring elements + * (port badge, runtime tag). A quiet red chip next to a bold `:3000` + * badge would bury the signal. + * + * Hidden when no vulnerabilities exist. + */ +function AuditChip({ audit, onClick }: { audit: AuditResult; onClick: () => void }) { + const total = audit.critical + audit.high + audit.medium + audit.low; + if (total === 0) return null; + const hasCritical = audit.critical > 0; + const tone = hasCritical + ? 'bg-red-500/20 text-red-200 hover:bg-red-500/30 border-red-500/40' + : audit.high > 0 + ? 'bg-orange-500/15 text-orange-200 hover:bg-orange-500/25 border-orange-500/25' + : audit.medium > 0 + ? 'bg-yellow-500/15 text-yellow-200 hover:bg-yellow-500/25 border-yellow-500/25' + : 'bg-blue-500/15 text-blue-200 hover:bg-blue-500/25 border-blue-500/25'; + return ( + + ); +} diff --git a/apps/desktop/src/components/dashboard/StatTile.tsx b/apps/desktop/src/components/dashboard/StatTile.tsx deleted file mode 100644 index b5ac1ec..0000000 --- a/apps/desktop/src/components/dashboard/StatTile.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { cn } from '@/lib/cn'; - -export function StatTile({ - label, - value, - tone, - icon, - badge, - active, -}: { - label: string; - value: number; - tone: 'running' | 'starting' | 'stopped' | 'error'; - icon: React.ReactNode; - badge?: string; - active?: boolean; -}) { - const toneCfg = { - running: { - text: 'text-status-running', - chip: 'bg-status-running/12 text-status-running', - activeBg: 'bg-gradient-to-br from-status-running/8 to-transparent border-status-running/25', - }, - starting: { - text: 'text-status-starting', - chip: 'bg-status-starting/15 text-status-starting', - activeBg: 'bg-gradient-to-br from-status-starting/8 to-transparent border-status-starting/25', - }, - stopped: { - text: 'text-fg-muted', - chip: 'bg-surface-muted text-fg-muted', - activeBg: '', - }, - error: { - text: 'text-status-error', - chip: 'bg-status-error/12 text-status-error', - activeBg: 'bg-gradient-to-br from-status-error/8 to-transparent border-status-error/30', - }, - }[tone]; - - return ( -
    - - {icon} - -
    -
    - - {value} - - {badge != null && ( - {badge} - )} -
    -
    - {label} -
    -
    -
    - ); -} diff --git a/apps/desktop/src/components/dashboard/WorstOffenders.tsx b/apps/desktop/src/components/dashboard/WorstOffenders.tsx new file mode 100644 index 0000000..19b91b0 --- /dev/null +++ b/apps/desktop/src/components/dashboard/WorstOffenders.tsx @@ -0,0 +1,249 @@ +import { Flame, Package, ShieldAlert, Clock, ArrowUpRight } from 'lucide-react'; +import { cn } from '@/lib/cn'; +import { riskScore } from '@/lib/risk'; +import type { ProjectOverview } from '@/types'; +import type { DetailTab } from '@/components/ProjectDetailDrawer'; + +/** + * "Worst offenders" band — the short list of projects that most urgently + * need attention. Sits below the KPI tiles and above the card roster + * so a fresh-open user flow reads: + * + * 1. Glance at KPIs → "do we have a CVE problem?" + * 2. Glance at offenders → "which 2-3 projects?" + * 3. Click one → drawer opens on the appropriate tab, fix in place + * + * Without this band, the user has to visually hunt for the coloured + * chip across 20+ cards, which defeats the dashboard's "bird's-eye" + * promise from the ROADMAP. The band makes the critical 20% jump to + * the top regardless of sort/filter state. + * + * Renders nothing when all scores are zero — a clean portfolio + * shouldn't eat vertical space announcing its cleanness. + */ +interface OffenderEntry { + project: ProjectOverview; + score: number; + /** Which tab to land on when the row is clicked (worst axis first). */ + primaryTab: DetailTab; + /** Short reason string rendered on the right of the row. */ + reason: string; +} + +/** + * Composite risk score. + * + * Weights reflect user-facing urgency, not raw numerical severity: + * • A single critical CVE ranks a project higher than 10 major + * version bumps — you *ship tonight* for the CVE, you *plan next + * sprint* for the bumps. + * • Stale adds a flat penalty: stale projects with any issues are + * worse than active projects with the same issues (nobody's + * watching, so rot accumulates). + * • Dirty is intentionally *not* part of the score — a dirty + * working tree is a developer-choice, not a portfolio problem. + * It's surfaced via its own KPI tile / pill. + */ +function pickPrimaryTab(p: ProjectOverview): DetailTab { + const hasCritOrHigh = p.audit != null && (p.audit.critical > 0 || p.audit.high > 0); + if (hasCritOrHigh) return 'advisories'; + // Anything else that surfaces here (low CVEs, outdated, stale) is + // most usefully surfaced on the Outdated tab — medium/low CVEs still + // live inside the audit payload but the drawer will jump to the + // advisories tab anyway via the tab header chip counts. + return 'outdated'; +} + +function describe(p: ProjectOverview): string { + const audit = p.audit; + const outdated = p.outdated; + const parts: string[] = []; + if (audit && audit.critical > 0) { + parts.push(`${audit.critical} critical`); + } else if (audit && audit.high > 0) { + parts.push(`${audit.high} high`); + } + if (outdated && outdated.major > 0) { + parts.push(`${outdated.major} major bump${outdated.major > 1 ? 's' : ''}`); + } else if (outdated && outdated.total > 0) { + parts.push(`${outdated.total} outdated`); + } + if (p.is_stale) parts.push('stale'); + return parts.join(' · '); +} + +export function WorstOffenders({ + projects, + onOpenDetail, + limit = 5, +}: { + projects: ProjectOverview[]; + onOpenDetail: (serviceId: string, tab: DetailTab) => void; + limit?: number; +}) { + const offenders: OffenderEntry[] = []; + for (const p of projects) { + const score = riskScore(p); + if (score <= 0) continue; + offenders.push({ + project: p, + score, + primaryTab: pickPrimaryTab(p), + reason: describe(p), + }); + } + offenders.sort((a, b) => b.score - a.score); + const shown = offenders.slice(0, limit); + const worst = shown[0]; + if (!worst) return null; + + // Determine band tone — the first row's severity drives the band + // accent so a portfolio with any critical CVE gets a red tint, not + // just the row itself. + const hasCritical = (worst.project.audit?.critical ?? 0) > 0; + const hasHigh = (worst.project.audit?.high ?? 0) > 0; + const accent = hasCritical + ? 'border-status-error/30 bg-status-error/[0.04]' + : hasHigh + ? 'border-orange-500/30 bg-orange-500/[0.04]' + : 'border-border'; + + return ( +
    +
    + + + Needs attention + + + {shown.length} of {offenders.length} + +
    +
      + {shown.map((entry) => ( + + ))} +
    +
    + ); +} + +/** + * Single row — the click targets are deliberately layered: + * + * • The *row* (name, reason, arrow) is a role="button" div that + * opens `primaryTab` (the "worst axis" for this project). This + * gives a "just click anywhere" fast path. + * • The *CVE chip* is an actual + )} + {outTotal > 0 && ( + + )} + {p.is_stale && ( + + )} +
    + + {reason} + + +
    + + ); +} diff --git a/apps/desktop/src/components/dashboard/index.ts b/apps/desktop/src/components/dashboard/index.ts index 77dc661..6f07a5a 100644 --- a/apps/desktop/src/components/dashboard/index.ts +++ b/apps/desktop/src/components/dashboard/index.ts @@ -1,4 +1,3 @@ export { Dashboard } from './Dashboard'; export { ServiceCard } from './ServiceCard'; -export { StatTile } from './StatTile'; export { SectionHeader, HeaderAction } from './SectionHeader'; diff --git a/apps/desktop/src/lib/ansi.ts b/apps/desktop/src/lib/ansi.ts new file mode 100644 index 0000000..42256f5 --- /dev/null +++ b/apps/desktop/src/lib/ansi.ts @@ -0,0 +1,88 @@ +import AnsiToHtml from 'ansi-to-html'; + +/** Strip non-SGR ANSI control sequences before handing text to `ansi-to-html`. + * + * `ansi-to-html` only understands SGR (`ESC [ … m`, color + style). For + * anything else — cursor moves, erase, scroll, save/restore, DEC private + * modes, OSC/APC/DCS/PM strings — it drops the `ESC [` prefix and leaks + * the trailing verb into rendered HTML. So we pre-strip those sequences, + * keeping only SGR. + * + * Kept in sync with `LogPanel.tsx`'s copy — see that file for the detailed + * reasoning. Not deduped into this module yet because LogPanel embeds the + * palette tightly into a virtualized renderer; extracting both without + * regressing the live log view needs a bigger refactor. */ +const ANSI_NON_SGR_RE = + // eslint-disable-next-line no-control-regex + /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-9;?]*[@A-HJKSTfhlnpsuDEMLR]|[()][A-Z0-9]|[=>DEMHcp78])/g; + +/** Collapse `\r`-overwritten lines down to just the last segment — + * progress indicators like "Receiving objects: 10% -> 100%" show as the + * final frame, not a mashed run. */ +function collapseCarriageReturn(input: string): string { + const idx = input.lastIndexOf('\r'); + return idx === -1 ? input : input.slice(idx + 1); +} + +export function sanitizeAnsi(input: string): string { + return collapseCarriageReturn(input).replace(ANSI_NON_SGR_RE, ''); +} + +/** VS Code "Dark+" / "Light+" palette, mirroring the embedded PTY so the + * Activity Timeline's per-event console block looks identical to the + * live log panel at the bottom of the screen. */ +export function makeAnsiConverter(isDark: boolean): AnsiToHtml { + const fg = isDark ? '#d4d4d4' : '#383a42'; + const bg = isDark ? '#1e1e1e' : '#ffffff'; + const palette = isDark + ? { + 0: '#000000', + 1: '#cd3131', + 2: '#0dbc79', + 3: '#e5e510', + 4: '#2472c8', + 5: '#bc3fbc', + 6: '#11a8cd', + 7: '#e5e5e5', + 8: '#666666', + 9: '#f14c4c', + 10: '#23d18b', + 11: '#f5f543', + 12: '#3b8eea', + 13: '#d670d6', + 14: '#29b8db', + 15: '#e5e5e5', + } + : { + 0: '#000000', + 1: '#cd3131', + 2: '#00bc00', + 3: '#949800', + 4: '#0451a5', + 5: '#bc05bc', + 6: '#0598bc', + 7: '#555555', + 8: '#666666', + 9: '#cd3131', + 10: '#14ce14', + 11: '#b5ba00', + 12: '#0451a5', + 13: '#bc05bc', + 14: '#0598bc', + 15: '#a5a5a5', + }; + return new AnsiToHtml({ + fg, + bg, + escapeXML: true, + newline: false, + colors: palette, + }); +} + +/** Sanitize + render a single log line's text as ANSI-colored HTML. + * Safe to use with `dangerouslySetInnerHTML` — input is escaped by + * `ansi-to-html` (`escapeXML: true`), then SGR colors applied. */ +export function renderAnsiToHtml(converter: AnsiToHtml, text: string): string { + return converter.toHtml(sanitizeAnsi(text)); +} diff --git a/apps/desktop/src/lib/ipc.ts b/apps/desktop/src/lib/ipc.ts index ad034de..fcab54c 100644 --- a/apps/desktop/src/lib/ipc.ts +++ b/apps/desktop/src/lib/ipc.ts @@ -4,11 +4,13 @@ import type { AppInfo, CommandEntry, DailySummary, + DependencyScanResult, DetectedEditor, GitStatus, ListeningPort, LogEvent, LogLine, + OverviewSummary, Prefs, ProjectCandidate, ResourceEvent, @@ -112,17 +114,26 @@ export const ipc = { gitAmendCommitMessage: (id: ServiceId, message: string) => invoke('git_amend_commit_message', { id, message }), + getProjectOverview: (staleThresholdDays?: number) => + invoke('get_project_overview', { + staleThresholdDays: staleThresholdDays ?? 30, + }), + scanProjectDependencies: (force = false) => + invoke('scan_project_dependencies', { force }), + recordTimelineEvent: ( eventType: TimelineEventType, serviceId?: string | null, serviceName?: string | null, description?: string, + runId?: string | null, ) => invoke('record_timeline_event', { eventType, serviceId: serviceId ?? null, serviceName: serviceName ?? null, description: description ?? '', + runId: runId ?? null, }), getTimeline: ( serviceId?: string | null, diff --git a/apps/desktop/src/lib/risk.ts b/apps/desktop/src/lib/risk.ts new file mode 100644 index 0000000..ec4eaf3 --- /dev/null +++ b/apps/desktop/src/lib/risk.ts @@ -0,0 +1,31 @@ +import type { ProjectOverview } from '@/types'; + +/** + * Composite risk score for a project — used by both the Worst Offenders + * band (ranks the highest-risk projects) and the dashboard's "sort by + * risk" comparator (ranks within a group / across the roster). + * + * Weights reflect user-facing urgency, not raw numerical severity: + * • A single critical CVE ranks a project higher than 10 major version + * bumps — you *ship tonight* for the CVE, you *plan next sprint* for + * the bumps. + * • Stale adds a flat penalty only when there's actual risk present — + * a dormant project with no CVEs and no outdated deps is just + * dormant, not dangerous. Adding a penalty for pure-stale would + * misdirect the "sort by risk" reading. + * • Dirty is intentionally *not* part of the score — a dirty working + * tree is a developer choice, not a portfolio problem. It's + * surfaced via its own KPI tile / filter pill. + * + * Accepts `undefined` to let callers avoid a null-check boilerplate: + * a project with no metadata (never scanned, never polled) scores 0. + */ +export function riskScore(p: ProjectOverview | undefined | null): number { + if (!p) return 0; + const audit = p.audit; + const outdated = p.outdated; + const cve = audit ? audit.critical * 100 + audit.high * 25 + audit.medium * 5 + audit.low * 1 : 0; + const old = outdated ? outdated.major * 8 + outdated.minor * 2 + outdated.patch * 0.5 : 0; + const stalePenalty = p.is_stale && cve + old > 0 ? 10 : 0; + return cve + old + stalePenalty; +} diff --git a/apps/desktop/src/lib/scrollIdle.ts b/apps/desktop/src/lib/scrollIdle.ts new file mode 100644 index 0000000..7b633c4 --- /dev/null +++ b/apps/desktop/src/lib/scrollIdle.ts @@ -0,0 +1,52 @@ +/** + * Global "is this element currently scrolling?" tracker. + * + * Partners with the CSS in `styles.css` that makes scrollbar thumbs + * transparent by default and visible only when a scrollable element + * is either (a) hovered or (b) marked `data-scrolling="true"`. + * + * This module handles case (b) — mouse-wheel / trackpad / keyboard / + * programmatic scroll, where the cursor may not be over the scrolling + * element. We flag the element on `scroll`, then clear the flag after + * a short idle window so the thumb fades back out. + * + * Implementation notes: + * • Listener is attached in the *capture* phase at the document + * root so it catches scroll events from every descendant without + * each scroll container having to opt in. + * • `passive: true` — we never call preventDefault, and passive + * listeners keep scrolling silky on trackpads. + * • Per-element idle timers live in a WeakMap so detached DOM nodes + * get garbage-collected without us manually cleaning up. + * • IDLE_MS is tuned to match the visual fade: too short and the + * thumb strobes between frames during a slow scroll; too long and + * the thumb hangs around after the user has stopped scrolling, + * defeating the auto-hide promise. + */ + +const IDLE_MS = 700; + +// eslint-disable-next-line no-undef -- DOM globals (Element, Event) are provided by lib.dom.d.ts; the `no-undef` rule pre-dates TypeScript's own resolution and flags them redundantly. +const timers = new WeakMap(); + +function onScroll(event: Event) { + const target = event.target; + // eslint-disable-next-line no-undef + if (!(target instanceof Element)) return; + target.setAttribute('data-scrolling', 'true'); + const existing = timers.get(target); + if (existing !== undefined) window.clearTimeout(existing); + const handle = window.setTimeout(() => { + target.removeAttribute('data-scrolling'); + timers.delete(target); + }, IDLE_MS); + timers.set(target, handle); +} + +let installed = false; + +export function installScrollIdleTracker(): void { + if (installed || typeof document === 'undefined') return; + document.addEventListener('scroll', onScroll, { capture: true, passive: true }); + installed = true; +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index d1228a4..d8459ac 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,8 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { installScrollIdleTracker } from '@/lib/scrollIdle'; import './styles.css'; +installScrollIdleTracker(); + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/apps/desktop/src/store/useAppStore.ts b/apps/desktop/src/store/useAppStore.ts index f27b88f..f91736b 100644 --- a/apps/desktop/src/store/useAppStore.ts +++ b/apps/desktop/src/store/useAppStore.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; import type { + DependencyScanResult, DetectedEditor, GitStatus, ListeningPort, LogLine, + OverviewSummary, ResourceSample, Section, SectionColor, @@ -22,6 +24,16 @@ interface LogBuffer { export type SidebarGroupBy = 'none' | 'category' | 'runtime' | 'status'; export type DashboardGroupBy = 'none' | 'category' | 'runtime' | 'status'; +/** + * Dashboard intra-group sort axis. + * • name — alphabetical (default, stable) + * • activity — most-recent commit first, never-touched last + * • risk — CVE+outdated composite, worst first (zero-risk projects + * keep their alphabetical order to avoid a weird reshuffle) + * • memory — running projects by RSS desc, then non-running alphabetical + * • cpu — running projects by CPU% desc, then non-running alphabetical + */ +export type DashboardSortBy = 'name' | 'activity' | 'risk' | 'memory' | 'cpu'; export type SidebarStatusFilter = 'all' | 'running' | 'stopped'; interface AppStore { @@ -63,6 +75,7 @@ interface AppStore { */ sidebarGroupBy: SidebarGroupBy; dashboardGroupBy: DashboardGroupBy; + dashboardSortBy: DashboardSortBy; search: string; editorService: ServiceDef | null | undefined; stacks: StackDef[]; @@ -100,6 +113,7 @@ interface AppStore { setSidebarStatusFilter: (v: SidebarStatusFilter) => void; setSidebarGroupBy: (v: SidebarGroupBy) => void; setDashboardGroupBy: (v: DashboardGroupBy) => void; + setDashboardSortBy: (v: DashboardSortBy) => void; resetSidebarFilters: () => void; setSearch: (q: string) => void; openEditor: (service: ServiceDef | null) => void; @@ -123,6 +137,46 @@ interface AppStore { timelineOpen: boolean; openTimeline: () => void; closeTimeline: () => void; + + /** + * Cross-project overview data (git staleness, last activity, running + * state, dependency & audit counts). Populated by a background poll + * in `App.tsx` — the dashboard reads it to surface `Stale`/`Risk`/ + * `Outdated` filter pills and the per-card deps/audit chips that open + * the `ProjectDetailDrawer`. + * + * `null` until the first poll resolves; the UI treats that as "no data + * yet, hide attention affordances" rather than "zero attention". + */ + overview: OverviewSummary | null; + overviewLoading: boolean; + /** + * True while `scan_project_dependencies` is running on the Rust side. + * The dashboard "Scan dependencies" button reads this to swap in a + * spinner and prevent double-submits; per-card shimmer is driven off + * the same flag. + */ + overviewScanning: boolean; + /** + * Timestamp (millis since epoch) of the last successful dependency + * scan merge. `null` until the first scan completes — the dashboard + * reads this to show a "Last scan: 4m ago" indicator so the user + * knows whether the Outdated/CVE numbers are current or stale. + * + * Stored as `number` (not `Date`) because Zustand shallow-equals by + * reference; primitives avoid unnecessary re-renders. + */ + lastScanAt: number | null; + setOverview: (v: OverviewSummary | null) => void; + setOverviewLoading: (v: boolean) => void; + setOverviewScanning: (v: boolean) => void; + /** + * Merge a `DependencyScanResult` into the currently-cached overview + * in place — preserves git / runtime / process state so the scan + * button doesn't blow away a freshly-polled snapshot. Stamps + * `lastScanAt` with the current wall-clock time. + */ + patchOverviewScan: (result: DependencyScanResult) => void; } const MAX_UI_LOG_LINES = 5_000; @@ -186,25 +240,29 @@ const initialSidebarPrefs = loadSidebarPrefs(); interface DashboardPrefs { groupBy: DashboardGroupBy; + sortBy: DashboardSortBy; } +const VALID_GROUP_BYS: DashboardGroupBy[] = ['none', 'category', 'runtime', 'status']; +const VALID_SORT_BYS: DashboardSortBy[] = ['name', 'activity', 'risk', 'memory', 'cpu']; + function loadDashboardPrefs(): DashboardPrefs { - if (typeof window === 'undefined') return { groupBy: 'category' }; + const defaults: DashboardPrefs = { groupBy: 'category', sortBy: 'name' }; + if (typeof window === 'undefined') return defaults; try { const raw = window.localStorage.getItem(DASHBOARD_PREFS_KEY); - if (!raw) return { groupBy: 'category' }; + if (!raw) return defaults; const parsed = JSON.parse(raw) as Partial; return { - groupBy: - parsed.groupBy === 'none' || - parsed.groupBy === 'category' || - parsed.groupBy === 'runtime' || - parsed.groupBy === 'status' - ? parsed.groupBy - : 'category', + groupBy: VALID_GROUP_BYS.includes(parsed.groupBy as DashboardGroupBy) + ? (parsed.groupBy as DashboardGroupBy) + : defaults.groupBy, + sortBy: VALID_SORT_BYS.includes(parsed.sortBy as DashboardSortBy) + ? (parsed.sortBy as DashboardSortBy) + : defaults.sortBy, }; } catch { - return { groupBy: 'category' }; + return defaults; } } @@ -308,6 +366,7 @@ export const useAppStore = create((set, get) => ({ sidebarStatusFilter: initialSidebarPrefs.statusFilter, sidebarGroupBy: initialSidebarPrefs.groupBy, dashboardGroupBy: initialDashboardPrefs.groupBy, + dashboardSortBy: initialDashboardPrefs.sortBy, search: '', editorService: undefined, stacks: [], @@ -437,7 +496,11 @@ export const useAppStore = create((set, get) => ({ }, setDashboardGroupBy: (v) => { set({ dashboardGroupBy: v }); - saveDashboardPrefs({ groupBy: v }); + saveDashboardPrefs({ groupBy: v, sortBy: get().dashboardSortBy }); + }, + setDashboardSortBy: (v) => { + set({ dashboardSortBy: v }); + saveDashboardPrefs({ groupBy: get().dashboardGroupBy, sortBy: v }); }, resetSidebarFilters: () => { set({ @@ -620,4 +683,31 @@ export const useAppStore = create((set, get) => ({ timelineOpen: false, openTimeline: () => set({ timelineOpen: true }), closeTimeline: () => set({ timelineOpen: false }), + + overview: null, + overviewLoading: false, + overviewScanning: false, + lastScanAt: null, + setOverview: (v) => set({ overview: v }), + setOverviewLoading: (v) => set({ overviewLoading: v }), + setOverviewScanning: (v) => set({ overviewScanning: v }), + patchOverviewScan: (result) => + set((s) => { + if (!s.overview) return s; + const byId = new Map(result.entries.map((e) => [e.service_id, e])); + const projects = s.overview.projects.map((p) => { + const hit = byId.get(p.service_id); + return hit ? { ...p, outdated: hit.outdated, audit: hit.audit } : p; + }); + return { + overview: { + ...s.overview, + projects, + total_outdated: result.total_outdated, + total_vulnerabilities: result.total_vulnerabilities, + has_dependency_scan: true, + }, + lastScanAt: Date.now(), + }; + }), })); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 21201dd..4ee55db 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -191,20 +191,87 @@ pre, rgb(var(--surface)); } +/* + * Tailwind v4 removed `cursor: pointer` from buttons in the preflight + * reset, citing "native browser default is auto for