From 3b3f38bd94727259f415d2dbbd4be566db66122a Mon Sep 17 00:00:00 2001 From: Varun Singh Date: Thu, 11 Jun 2026 21:29:05 +0530 Subject: [PATCH] =?UTF-8?q?feat(overview):=20timeline-first=20landing=20?= =?UTF-8?q?=E2=80=94=20event=20feed,=20project=20overview,=20guided=20acti?= =?UTF-8?q?ons=20(UX=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - event_feed.py: deterministic event rules over per-(service, signature) time buckets — begins / spike / subsides / new_pattern / health transitions. Pure rule engine, no LLM cost per load; 15 unit tests. - GET /projects/{id}/overview: events + corpus-wide signature clusters (the un-deferred Path B) + services + derived suggested actions. Window anchors to now and falls back to the project's latest data so the landing page always tells the most recent story available. - UI: ProjectPicker (0 projects -> create, 1 -> auto-select, 2+ -> cards) replaces the empty-chat hero; ProjectOverview renders the timeline, clusters, services and suggested-action chips as the landing panel; chips route to Deep Research (grounded query: signature + service + time range pre-filled) or /chat; sidebar shows per-conversation project badges; chat + investigate carry the conversation's project. Verified live on real LogHub data: Infra overview shows 'zookeeper enters degraded state -> recovers' with clusters and 5 action chips; clicking an Investigate chip ran a scoped DR investigation end-to-end. 6/6 puppeteer flows pass against the new flow. --- repi/api/projects.py | 130 +++++++++ repi/retrieval/event_feed.py | 266 ++++++++++++++++++ tests/retrieval/test_event_feed.py | 150 ++++++++++ web/app/page.tsx | 67 ++++- .../conversations/ConversationSidebar.tsx | 10 +- web/components/projects/ProjectOverview.tsx | 191 +++++++++++++ web/components/projects/ProjectPicker.tsx | 114 ++++++++ web/lib/api.ts | 21 +- 8 files changed, 931 insertions(+), 18 deletions(-) create mode 100644 repi/retrieval/event_feed.py create mode 100644 tests/retrieval/test_event_feed.py create mode 100644 web/components/projects/ProjectOverview.tsx create mode 100644 web/components/projects/ProjectPicker.tsx diff --git a/repi/api/projects.py b/repi/api/projects.py index 3d3c2a3..c179be8 100644 --- a/repi/api/projects.py +++ b/repi/api/projects.py @@ -18,7 +18,9 @@ from sqlmodel import select from repi.core.container import get_container +from repi.core.dates import DateHandler from repi.models.schema import Project +from repi.retrieval.event_feed import derive_events, fetch_window_aggregates, parse_window logger = logging.getLogger("repi.api.projects") @@ -183,6 +185,134 @@ async def update_project(project_id: UUID, body: ProjectUpdate): ) +@router.get("/projects/{project_id}/overview") +async def project_overview( + project_id: UUID, + window: Optional[str] = None, + service: Optional[str] = None, +): + """Landing-page payload: heuristic timeline events, corpus-wide error + clusters, services, and suggested actions for one project + time window. + + Window anchors to NOW; when the window contains no data (historical + imports, idle systems) it re-anchors to the project's latest chunk so the + landing page always tells the most recent story available + (`anchored_to_latest: true` flags this for the UI). + """ + from datetime import datetime, timezone + + container = get_container() + async with container.get_session() as session: + p = await session.get(Project, project_id) + if p is None: + raise HTTPException(status_code=404, detail="Project not found") + settings = effective_settings(p) + window_str = window or settings["default_timeline_window"] + span = parse_window(window_str) + max_events = int(settings.get("max_events", 25)) + + pool = container.pool + time_to = datetime.now(timezone.utc) + time_from = time_to - span + + anchored_to_latest = False + in_window = await pool.fetchval( + "SELECT 1 FROM log_chunks WHERE project_id = $1 " + "AND timestamp_start >= $2 AND timestamp_start < $3 LIMIT 1", + project_id, time_from, time_to, + ) + if in_window is None: + latest = await pool.fetchval( + "SELECT max(timestamp_start) FROM log_chunks WHERE project_id = $1", + project_id, + ) + if latest is not None: + time_to = latest + (span / 100) # nudge so the latest row is < time_to + time_from = time_to - span + anchored_to_latest = True + + buckets, first_seen = await fetch_window_aggregates( + pool, project_id, time_from, time_to, service=service, + ) + events = derive_events(buckets, first_seen, time_from, time_to, max_events=max_events) + + cluster_rows = await pool.fetch( + """ + SELECT signature, count(*) AS n, + array_agg(DISTINCT source_service) AS services, + min(timestamp_start) AS first_ts, max(timestamp_start) AS last_ts + FROM log_chunks + WHERE project_id = $1 + AND timestamp_start >= $2 AND timestamp_start < $3 + AND signature IS NOT NULL AND signature <> '' + AND log_level IN ('ERROR', 'CRITICAL', 'FATAL', 'WARN', 'WARNING') + AND ($4::text IS NULL OR source_service = $4) + GROUP BY signature + ORDER BY n DESC + LIMIT 10 + """, + project_id, time_from, time_to, service, + ) + clusters = [ + { + "signature": r["signature"], + "count": r["n"], + "services": list(r["services"]), + "first_ts": DateHandler.to_iso(r["first_ts"]), + "last_ts": DateHandler.to_iso(r["last_ts"]), + } + for r in cluster_rows + ] + + svc_rows = await pool.fetch( + "SELECT source_service, count(*) AS n, max(timestamp_start) AS last_seen " + "FROM log_chunks WHERE project_id = $1 GROUP BY source_service ORDER BY n DESC", + project_id, + ) + services = [ + {"name": r["source_service"], "chunk_count": r["n"], + "last_seen": DateHandler.to_iso(r["last_seen"])} + for r in svc_rows + ] + + # Suggested actions — derived, never LLM-generated. Top clusters become + # Deep-Research entry points with the service + time range pre-filled so + # the investigation starts grounded instead of from a bare phrase. + suggested: list[dict] = [] + for c in clusters[:3]: + svc_part = f" on {c['services'][0]}" if c["services"] else "" + suggested.append({ + "kind": "investigate", + "label": f"Investigate: {c['signature'][:60]}", + "query": ( + f"Investigate '{c['signature']}'{svc_part} " + f"between {c['first_ts']} and {c['last_ts']}" + ), + }) + suggested.append({ + "kind": "chat", + "label": f"Summarize the last {window_str}", + "query": f"summarize what happened in the last {window_str}", + }) + suggested.append({ + "kind": "chat", + "label": "Show affected services", + "query": "which services are having problems?", + }) + + return { + "project_id": str(project_id), + "window": window_str, + "time_from": DateHandler.to_iso(time_from), + "time_to": DateHandler.to_iso(time_to), + "anchored_to_latest": anchored_to_latest, + "events": events, + "clusters": clusters, + "services": services, + "suggested_actions": suggested, + } + + @router.get("/projects/{project_id}/services", response_model=List[ProjectService]) async def list_project_services(project_id: UUID): container = get_container() diff --git a/repi/retrieval/event_feed.py b/repi/retrieval/event_feed.py new file mode 100644 index 0000000..5f35801 --- /dev/null +++ b/repi/retrieval/event_feed.py @@ -0,0 +1,266 @@ +"""Heuristic timeline events for the project overview (UX redesign P2). + +The landing page answers "what happened recently?" with EVENTS, not raw +logs: "JWT verification failures begin", "checkout retries spike (×340)", +"auth-service enters degraded state". Events are derived deterministically +from per-(service, signature) time-bucket aggregates — no LLM call, so the +overview is free and instant to load. + +Split into a SQL fetch (`fetch_window_aggregates`) and a pure rule engine +(`derive_events`) so the rules are unit-testable without a database. + +Rules (per error-class (service, signature) series over N buckets): +- begins: first active bucket has count ≥ BEGINS_MIN and the previous + bucket was quiet (skipped when the series is already active + at the window edge — we didn't see it begin). +- spike: a bucket ≥ SPIKE_RATIO × the trailing active average and + ≥ SPIKE_MIN — emitted once, for the biggest such bucket. +- subsides: activity stops ≥ QUIET_BUCKETS before the window end + (total ≥ SUBSIDE_MIN so one stray line doesn't "subside"). +- new_pattern: the signature's first-ever occurrence falls inside the + window (replaces `begins` for that series). +- health: per-service error fraction crosses DEGRADED_FRAC with at + least HEALTH_MIN rows in the bucket → "enters degraded + state"; falls back below RECOVERED_FRAC → "recovers". +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, asdict +from datetime import datetime, timedelta +from typing import Optional +from uuid import UUID + +import asyncpg + +from repi.core.dates import DateHandler + +logger = logging.getLogger(__name__) + +ERROR_CLASS = {"ERROR", "CRITICAL", "FATAL", "WARN", "WARNING"} + +N_BUCKETS = 24 +BEGINS_MIN = 3 +SPIKE_RATIO = 3.0 +SPIKE_MIN = 5 +QUIET_BUCKETS = 2 +SUBSIDE_MIN = 5 +DEGRADED_FRAC = 0.5 +RECOVERED_FRAC = 0.25 +HEALTH_MIN = 10 + +# Rank used when the event list exceeds max_events: keep the most significant, +# then re-sort chronologically so the story still reads in order. +KIND_PRIORITY = { + "health_degraded": 0, + "new_pattern": 1, + "begins": 2, + "spike": 3, + "health_recovered": 4, + "subsides": 5, +} + + +@dataclass +class TimelineEvent: + kind: str + ts: str # ISO8601 — bucket boundary the event is anchored to + service: Optional[str] + signature: Optional[str] + level: Optional[str] + title: str + count: int = 0 + + def to_dict(self) -> dict: + return asdict(self) + + +def parse_window(window: str) -> timedelta: + """'5h' → timedelta(hours=5). Supports m/h/d suffixes; defaults to 5h on + anything unparseable (the overview should degrade, not 500).""" + try: + unit = window.strip()[-1].lower() + value = int(window.strip()[:-1]) + if value <= 0: + raise ValueError + return {"m": timedelta(minutes=value), + "h": timedelta(hours=value), + "d": timedelta(days=value)}[unit] + except (ValueError, KeyError, IndexError): + logger.warning("parse_window: unparseable window %r — defaulting to 5h", window) + return timedelta(hours=5) + + +async def fetch_window_aggregates( + pool: asyncpg.Pool, + project_id: UUID, + time_from: datetime, + time_to: datetime, + n_buckets: int = N_BUCKETS, + service: Optional[str] = None, +) -> tuple[list[dict], dict[str, datetime]]: + """Bucket counts per (service, signature, level) + first-ever timestamp + per signature (for new_pattern detection).""" + tf = DateHandler.to_aware_utc(time_from) + tt = DateHandler.to_aware_utc(time_to) + rows = await pool.fetch( + """ + SELECT source_service AS service, signature, log_level AS level, + width_bucket(extract(epoch FROM timestamp_start), + extract(epoch FROM $2::timestamptz), + extract(epoch FROM $3::timestamptz), $4) AS bucket, + count(*) AS n + FROM log_chunks + WHERE project_id = $1 + AND timestamp_start >= $2 AND timestamp_start < $3 + AND signature IS NOT NULL AND signature <> '' + AND ($5::text IS NULL OR source_service = $5) + GROUP BY 1, 2, 3, 4 + """, + project_id, tf, tt, n_buckets, service, + ) + buckets = [dict(r) for r in rows] + + sigs = sorted({r["signature"] for r in buckets}) + first_seen: dict[str, datetime] = {} + if sigs: + fs_rows = await pool.fetch( + """ + SELECT signature, MIN(timestamp_start) AS first_ever + FROM log_chunks + WHERE project_id = $1 AND signature = ANY($2) + GROUP BY signature + """, + project_id, sigs, + ) + first_seen = {r["signature"]: r["first_ever"] for r in fs_rows} + return buckets, first_seen + + +def derive_events( + buckets: list[dict], + first_seen: dict[str, datetime], + time_from: datetime, + time_to: datetime, + n_buckets: int = N_BUCKETS, + max_events: int = 25, +) -> list[dict]: + """Pure rule engine: bucket aggregates → chronological event dicts. + + `buckets` rows: {service, signature, level, bucket (1-based, from + width_bucket), n}. `first_seen`: signature → first-ever timestamp. + """ + tf = DateHandler.to_aware_utc(time_from) + tt = DateHandler.to_aware_utc(time_to) + bucket_span = (tt - tf) / n_buckets + + def bucket_start(b: int) -> str: + return DateHandler.to_iso(tf + bucket_span * (b - 1)) + + def bucket_end(b: int) -> str: + return DateHandler.to_iso(tf + bucket_span * b) + + # ── series per (service, signature), error-class rows only ────────────── + series: dict[tuple, dict] = {} + # ── per-service totals per bucket for health events ────────────────────── + svc_total: dict[str, dict[int, int]] = {} + svc_err: dict[str, dict[int, int]] = {} + + for r in buckets: + b = int(r["bucket"]) + if b < 1 or b > n_buckets: + continue + svc = r["service"] + level = (r["level"] or "").upper() + n = int(r["n"]) + svc_total.setdefault(svc, {}) + svc_total[svc][b] = svc_total[svc].get(b, 0) + n + if level not in ERROR_CLASS: + continue + svc_err.setdefault(svc, {}) + svc_err[svc][b] = svc_err[svc].get(b, 0) + n + key = (svc, r["signature"]) + s = series.setdefault(key, {"counts": {}, "level": level}) + s["counts"][b] = s["counts"].get(b, 0) + n + # Keep the most severe level seen for display. + rank = {"FATAL": 3, "CRITICAL": 3, "ERROR": 2, "WARNING": 1, "WARN": 1} + if rank.get(level, 0) > rank.get(s["level"], 0): + s["level"] = level + + events: list[TimelineEvent] = [] + + for (svc, sig), s in series.items(): + counts = s["counts"] + level = s["level"] + active = sorted(counts.keys()) + first_b, last_b = active[0], active[-1] + total = sum(counts.values()) + + is_new = False + fe = first_seen.get(sig) + if fe is not None and DateHandler.to_aware_utc(fe) >= tf: + is_new = True + events.append(TimelineEvent( + kind="new_pattern", ts=bucket_start(first_b), service=svc, + signature=sig, level=level, + title=f"New error pattern: {sig}", count=counts[first_b], + )) + + if not is_new and first_b > 1 and counts[first_b] >= BEGINS_MIN: + events.append(TimelineEvent( + kind="begins", ts=bucket_start(first_b), service=svc, + signature=sig, level=level, + title=f"{sig} begins", count=counts[first_b], + )) + + # Spike: biggest bucket vs trailing active average before it. + best = None + for b in active[1:]: + prior = [counts[x] for x in active if x < b] + trailing_avg = sum(prior) / len(prior) + if counts[b] >= SPIKE_MIN and counts[b] >= SPIKE_RATIO * trailing_avg: + if best is None or counts[b] > counts[best]: + best = b + if best is not None: + events.append(TimelineEvent( + kind="spike", ts=bucket_start(best), service=svc, + signature=sig, level=level, + title=f"{sig} spikes (×{counts[best]})", count=counts[best], + )) + + if total >= SUBSIDE_MIN and last_b <= n_buckets - QUIET_BUCKETS: + events.append(TimelineEvent( + kind="subsides", ts=bucket_end(last_b), service=svc, + signature=sig, level=level, + title=f"{sig} subsides", count=0, + )) + + # ── health transitions per service ─────────────────────────────────────── + for svc, totals in svc_total.items(): + errs = svc_err.get(svc, {}) + degraded = False + for b in range(1, n_buckets + 1): + tot = totals.get(b, 0) + if tot < HEALTH_MIN: + continue + frac = errs.get(b, 0) / tot + if not degraded and frac >= DEGRADED_FRAC: + degraded = True + events.append(TimelineEvent( + kind="health_degraded", ts=bucket_start(b), service=svc, + signature=None, level="ERROR", + title=f"{svc} enters degraded state", count=errs.get(b, 0), + )) + elif degraded and frac <= RECOVERED_FRAC: + degraded = False + events.append(TimelineEvent( + kind="health_recovered", ts=bucket_start(b), service=svc, + signature=None, level="INFO", + title=f"{svc} recovers", count=0, + )) + + if len(events) > max_events: + events.sort(key=lambda e: (KIND_PRIORITY.get(e.kind, 9), -e.count)) + events = events[:max_events] + events.sort(key=lambda e: e.ts) + return [e.to_dict() for e in events] diff --git a/tests/retrieval/test_event_feed.py b/tests/retrieval/test_event_feed.py new file mode 100644 index 0000000..9b244ee --- /dev/null +++ b/tests/retrieval/test_event_feed.py @@ -0,0 +1,150 @@ +"""Rule-engine tests for the project-overview event feed (UX P2). + +derive_events is a pure function over bucket aggregates — these tests pin +each rule (begins / spike / subsides / new_pattern / health) without a DB. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from repi.retrieval.event_feed import N_BUCKETS, derive_events, parse_window + +TF = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +TT = TF + timedelta(hours=24) # bucket span = 1h with N_BUCKETS=24 + + +def _row(sig: str, bucket: int, n: int, service: str = "auth", level: str = "ERROR"): + return {"service": service, "signature": sig, "level": level, "bucket": bucket, "n": n} + + +def _events(buckets, first_seen=None, **kw): + return derive_events(buckets, first_seen or {}, TF, TT, **kw) + + +def _kinds(events, sig=None): + return [e["kind"] for e in events if sig is None or e.get("signature") == sig] + + +# ── parse_window ───────────────────────────────────────────────────────────── + +def test_parse_window_units(): + assert parse_window("5h") == timedelta(hours=5) + assert parse_window("30m") == timedelta(minutes=30) + assert parse_window("7d") == timedelta(days=7) + + +def test_parse_window_garbage_defaults_to_5h(): + assert parse_window("soon") == timedelta(hours=5) + assert parse_window("-3h") == timedelta(hours=5) + + +# ── begins ─────────────────────────────────────────────────────────────────── + +def test_begins_emitted_for_quiet_then_burst(): + events = _events([_row("jwt failed", bucket=5, n=10)]) + assert "begins" in _kinds(events, "jwt failed") + + +def test_no_begins_when_active_at_window_edge(): + """Already firing in bucket 1 — we didn't observe it begin.""" + events = _events([_row("jwt failed", bucket=1, n=10)]) + assert "begins" not in _kinds(events, "jwt failed") + + +def test_no_begins_below_threshold(): + events = _events([_row("jwt failed", bucket=5, n=2)]) + assert "begins" not in _kinds(events, "jwt failed") + + +def test_info_levels_never_produce_events(): + events = _events([_row("served request", bucket=5, n=500, level="INFO")]) + assert _kinds(events, "served request") == [] + + +# ── spike ──────────────────────────────────────────────────────────────────── + +def test_spike_on_3x_trailing_average(): + buckets = [ + _row("db timeout", 2, 4), + _row("db timeout", 3, 4), + _row("db timeout", 10, 40), # 10x the trailing average + ] + events = _events(buckets) + kinds = _kinds(events, "db timeout") + assert "spike" in kinds + spike = next(e for e in events if e["kind"] == "spike") + assert spike["count"] == 40 + assert "×40" in spike["title"] + + +def test_no_spike_for_flat_series(): + buckets = [_row("db timeout", b, 10) for b in range(2, 12)] + assert "spike" not in _kinds(_events(buckets), "db timeout") + + +# ── subsides ───────────────────────────────────────────────────────────────── + +def test_subsides_when_activity_stops_before_window_end(): + buckets = [_row("redis down", 4, 10), _row("redis down", 5, 8)] + events = _events(buckets) + assert "subsides" in _kinds(events, "redis down") + + +def test_no_subsides_when_still_active_at_end(): + buckets = [_row("redis down", N_BUCKETS - 1, 10), _row("redis down", N_BUCKETS, 8)] + assert "subsides" not in _kinds(_events(buckets), "redis down") + + +# ── new_pattern ────────────────────────────────────────────────────────────── + +def test_new_pattern_replaces_begins(): + first_seen = {"jwt sig mismatch": TF + timedelta(hours=4)} + events = _events([_row("jwt sig mismatch", 5, 10)], first_seen) + kinds = _kinds(events, "jwt sig mismatch") + assert "new_pattern" in kinds + assert "begins" not in kinds + + +def test_old_pattern_is_not_new(): + first_seen = {"jwt failed": TF - timedelta(days=30)} + events = _events([_row("jwt failed", 5, 10)], first_seen) + assert "new_pattern" not in _kinds(events, "jwt failed") + + +# ── health transitions ─────────────────────────────────────────────────────── + +def test_service_degraded_and_recovers(): + buckets = [ + # bucket 3: 20 rows, 15 errors → degraded (75%) + _row("boom", 3, 15, service="pay", level="ERROR"), + _row("ok", 3, 5, service="pay", level="INFO"), + # bucket 8: 20 rows, 2 errors → recovered (10%) + _row("boom", 8, 2, service="pay", level="ERROR"), + _row("ok", 8, 18, service="pay", level="INFO"), + ] + events = _events(buckets) + kinds = [e["kind"] for e in events if e["service"] == "pay"] + assert "health_degraded" in kinds + assert "health_recovered" in kinds + # Degraded must precede recovered chronologically. + assert kinds.index("health_degraded") < kinds.index("health_recovered") + + +def test_no_health_event_below_volume_floor(): + buckets = [ + _row("boom", 3, 4, service="pay", level="ERROR"), + _row("ok", 3, 1, service="pay", level="INFO"), + ] + events = _events(buckets) + assert all(e["kind"] != "health_degraded" for e in events) + + +# ── cap + ordering ─────────────────────────────────────────────────────────── + +def test_events_capped_and_chronological(): + buckets = [] + for i in range(40): + buckets.append(_row(f"sig-{i}", bucket=3 + (i % 10), n=10, service=f"svc{i}")) + events = _events(buckets, max_events=10) + assert len(events) == 10 + assert [e["ts"] for e in events] == sorted(e["ts"] for e in events) diff --git a/web/app/page.tsx b/web/app/page.tsx index 72ebb16..5e0d14a 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -6,6 +6,8 @@ import { ChatInput } from "@/components/chat/ChatInput" import { ChatMessageView, ChatMessageProps } from "@/components/chat/ChatMessage" import { ConversationSidebar } from "@/components/conversations/ConversationSidebar" import { InvestigationStepCard } from "@/components/investigation-step" +import { ProjectPicker } from "@/components/projects/ProjectPicker" +import { ProjectOverview, type SuggestedAction } from "@/components/projects/ProjectOverview" import { Step, useSSE } from "@/lib/sse" import { Badge } from "@/components/ui/badge" import { Sparkles } from "lucide-react" @@ -69,6 +71,9 @@ function buildChatHistory(turns: Turn[]): { role: "user" | "assistant"; content: export default function HomePage() { const [conversationId, setConversationId] = useState(null) + // Context-before-investigation: every conversation is scoped to a project. + // null → show the picker (which auto-selects when only one project exists). + const [project, setProject] = useState<{ id: string; name: string } | null>(null) const [deepResearch, setDeepResearch] = useState(false) const [turns, setTurns] = useState([]) const [busy, setBusy] = useState(false) @@ -97,11 +102,19 @@ export default function HomePage() { const loadConversation = useCallback(async (id: string | null) => { setConversationId(id) if (!id) { + // New conversation → back to project selection (the picker auto-skips + // itself when exactly one project exists). + setProject(null) setTurns([]) return } try { const detail = await api.conversations.get(id) + setProject( + detail.project_id + ? { id: detail.project_id, name: detail.project_name ?? "Project" } + : null, + ) const rendered: Turn[] = detail.turns.map((t: any, idx: number) => { if (t.mode === "chat") { return { @@ -136,7 +149,7 @@ export default function HomePage() { if (dr) { // Toggle ON → kick off a real investigation, embed its SSE stream. try { - const res = await api.investigations.create(query, conversationId ?? undefined) + const res = await api.investigations.create(query, conversationId ?? undefined, project?.id) if (!conversationId && res.conversation_id) { setConversationId(res.conversation_id) } @@ -170,6 +183,7 @@ export default function HomePage() { body: JSON.stringify({ query, conversation_id: conversationId ?? undefined, + project_id: project?.id ?? undefined, history, previous_chunk_ids: previousChunkIds, }), @@ -269,6 +283,34 @@ export default function HomePage() { const empty = turns.length === 0 + // Suggested-action chips from the overview: investigate → Deep Research + // path; chat → normal /chat turn. Both flow through handleSend so the + // conversation/threading behaviour is identical to typing the query. + function handleSuggestedAction(action: SuggestedAction) { + if (action.kind === "investigate") { + setDeepResearch(true) + handleSend(action.query, true) + } else { + handleSend(action.query, false) + } + } + + // New chat, no project yet → step 1 of the flow: pick (or create) a project. + if (!conversationId && !project) { + return ( +
+ +
+ setProject({ id: p.id, name: p.name })} /> +
+
+ ) + } + return (
{empty ? ( -
-
- -
-

Chat with your logs

-

- Hybrid retrieval over your ingested logs surfaces a chronological{" "} - timeline and the{" "} - event clusters behind your - question. Toggle{" "} - Deep Research for a - full autonomous root-cause investigation. -

-
+ // Timeline-first landing: no empty chat screen. The overview + // answers "what happened recently?" before the user types. + project ? ( + + ) : null ) : ( turns.map((t, i) => t.mode === "chat" ? ( diff --git a/web/components/conversations/ConversationSidebar.tsx b/web/components/conversations/ConversationSidebar.tsx index c9c1674..1f6be29 100644 --- a/web/components/conversations/ConversationSidebar.tsx +++ b/web/components/conversations/ConversationSidebar.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils" type ConversationSummary = { id: string title: string | null + project_name?: string | null created_at: string updated_at: string } @@ -67,7 +68,14 @@ export function ConversationSidebar({ activeId, onSelect, refreshKey }: Conversa )} > - {c.title || "Untitled"} + + {c.title || "Untitled"} + {c.project_name && ( + + {c.project_name} + + )} + ))}
diff --git a/web/components/projects/ProjectOverview.tsx b/web/components/projects/ProjectOverview.tsx new file mode 100644 index 0000000..e23688f --- /dev/null +++ b/web/components/projects/ProjectOverview.tsx @@ -0,0 +1,191 @@ +"use client" + +import { useEffect, useState } from "react" +import { api } from "@/lib/api" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Spinner } from "@/components/ui/spinner" +import { EventClusters, type Cluster } from "@/components/chat/EventClusters" +import { cn } from "@/lib/utils" +import { levelTone } from "@/lib/log-levels" +import { Activity, Microscope, MessageSquare, Server } from "lucide-react" +import { toast } from "sonner" + +export type OverviewEvent = { + kind: string + ts: string + service: string | null + signature: string | null + level: string | null + title: string + count: number +} + +export type SuggestedAction = { + kind: "investigate" | "chat" + label: string + query: string +} + +type Overview = { + window: string + time_from: string + time_to: string + anchored_to_latest: boolean + events: OverviewEvent[] + clusters: Cluster[] + services: { name: string; chunk_count: number; last_seen: string | null }[] + suggested_actions: SuggestedAction[] +} + +function shortTs(iso: string): string { + if (!iso) return "" + return iso.includes("T") ? iso.split("T")[1].slice(0, 8) : iso +} + +function shortDate(iso: string): string { + return iso?.split("T")[0] ?? "" +} + +const KIND_LABEL: Record = { + begins: "begins", + spike: "spike", + subsides: "subsides", + new_pattern: "new pattern", + health_degraded: "degraded", + health_recovered: "recovered", +} + +interface ProjectOverviewProps { + projectId: string + projectName: string + onAction: (action: SuggestedAction) => void +} + +/** + * The landing panel of a new project conversation: heuristic event timeline + * ("what happened recently?"), top error clusters ("what is breaking?"), + * services, and suggested next actions. Fetched live on mount — it reflects + * the data at view time, deliberately not persisted as a chat message. + */ +export function ProjectOverview({ projectId, projectName, onAction }: ProjectOverviewProps) { + const [overview, setOverview] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let cancelled = false + setOverview(null) + api.projects.overview(projectId) + .then((d: Overview) => { if (!cancelled) setOverview(d) }) + .catch((e: any) => { + if (!cancelled) { + setFailed(true) + toast.error("Could not load project overview: " + e.message) + } + }) + return () => { cancelled = true } + }, [projectId]) + + if (failed) return null + if (!overview) { + return ( +
+ +
+ ) + } + + const hasData = overview.events.length > 0 || overview.clusters.length > 0 + // Show the date alongside times when the window isn't "today". + const spansDays = shortDate(overview.time_from) !== shortDate(overview.time_to) + + return ( +
+
+ + {projectName} + last {overview.window} + {overview.anchored_to_latest && ( + + showing latest data ({shortDate(overview.time_to)}) + + )} +
+ + {!hasData ? ( +
+ No log data in this project yet. Ingest a file or register a watcher, + then this timeline fills in. +
+ ) : ( + <> + {overview.events.length > 0 && ( +
+
+ Timeline + + what happened recently + +
+
+ {overview.events.map((e, i) => ( +
+
+
{shortTs(e.ts)}
+ {spansDays &&
{shortDate(e.ts)}
} +
+
+ + {KIND_LABEL[e.kind] ?? e.kind} + + {e.service && ( + + {e.service} + + )} + {e.title} +
+
+ ))} +
+
+ )} + + {overview.clusters.length > 0 && ( + + )} + + )} + + {overview.services.length > 0 && ( +
+ + {overview.services.map((s) => ( + + {s.name} + + ))} +
+ )} + + {hasData && overview.suggested_actions.length > 0 && ( +
+ {overview.suggested_actions.map((a, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/components/projects/ProjectPicker.tsx b/web/components/projects/ProjectPicker.tsx new file mode 100644 index 0000000..65fea2f --- /dev/null +++ b/web/components/projects/ProjectPicker.tsx @@ -0,0 +1,114 @@ +"use client" + +import { useEffect, useState } from "react" +import { api } from "@/lib/api" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Spinner } from "@/components/ui/spinner" +import { FolderOpen, Plus } from "lucide-react" +import { toast } from "sonner" + +export type ProjectSummary = { + id: string + name: string + settings: Record + service_count: number +} + +interface ProjectPickerProps { + onSelect: (project: ProjectSummary) => void +} + +/** + * New-chat step 1: pick the project to scope the conversation to. + * - 0 projects → inline create form + * - exactly 1 → auto-select, no picker shown + * - 2+ → cards + */ +export function ProjectPicker({ onSelect }: ProjectPickerProps) { + const [projects, setProjects] = useState(null) + const [newName, setNewName] = useState("") + const [creating, setCreating] = useState(false) + + useEffect(() => { + let cancelled = false + api.projects.list() + .then((rows: ProjectSummary[]) => { + if (cancelled) return + if (rows.length === 1) onSelect(rows[0]) + else setProjects(rows) + }) + .catch((e: any) => { + if (!cancelled) toast.error("Could not load projects: " + e.message) + setProjects([]) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + async function createProject() { + const name = newName.trim() + if (!name) return + setCreating(true) + try { + const p = await api.projects.create(name) + onSelect(p) + } catch (e: any) { + toast.error("Could not create project: " + e.message) + } finally { + setCreating(false) + } + } + + if (projects === null) { + return ( +
+ +
+ ) + } + + return ( +
+
+ +
+

Select a project

+

+ Conversations, timelines and investigations are scoped to one project. +

+ + {projects.length > 0 && ( +
+ {projects.map((p) => ( + + ))} +
+ )} + +
+ setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") createProject() }} + /> + +
+
+ ) +} diff --git a/web/lib/api.ts b/web/lib/api.ts index dd7fff1..201794f 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -36,10 +36,14 @@ export const api = { investigations: { list: () => fetchApi("/investigations"), get: (id: string) => fetchApi(`/investigations/${id}`), - create: (query: string, conversation_id?: string) => + create: (query: string, conversation_id?: string, project_id?: string) => fetchApi("/investigate", { method: "POST", - body: JSON.stringify(conversation_id ? { query, conversation_id } : { query }), + body: JSON.stringify({ + query, + ...(conversation_id ? { conversation_id } : {}), + ...(project_id ? { project_id } : {}), + }), }), clarify: (id: string, reply: string) => fetchApi(`/investigations/${id}/clarify`, { method: "POST", body: JSON.stringify({ reply }) }), }, @@ -47,5 +51,18 @@ export const api = { list: () => fetchApi("/conversations"), get: (id: string) => fetchApi(`/conversations/${id}`), }, + projects: { + list: () => fetchApi("/projects"), + create: (name: string) => fetchApi("/projects", { method: "POST", body: JSON.stringify({ name }) }), + update: (id: string, data: any) => fetchApi(`/projects/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + services: (id: string) => fetchApi(`/projects/${id}/services`), + overview: (id: string, window?: string, service?: string) => { + const params = new URLSearchParams(); + if (window) params.set("window", window); + if (service) params.set("service", service); + const qs = params.toString(); + return fetchApi(`/projects/${id}/overview${qs ? `?${qs}` : ""}`); + }, + }, };