diff --git a/litellm/proxy/lens/endpoints.py b/litellm/proxy/lens/endpoints.py index a20258853c0..b6c033fd17b 100644 --- a/litellm/proxy/lens/endpoints.py +++ b/litellm/proxy/lens/endpoints.py @@ -37,18 +37,24 @@ from litellm.proxy.lens.models import ( RunRequest, Sample, Scope, + Step, + WatchAllResult, + WatchSkipped, Worker, WorkerCreated, ) from litellm.proxy.lens.repository import LensRepository, WriterDatabase from litellm.proxy.lens.sources import ActivityAvailability, SourceReader, Storage, parse_execution from litellm.proxy.lens.state import ( + add_step, can_access, claim_job, current_job, merge_finding, + next_scan_start, queue_job, replace_job, + scheduled_window, snapshot_finding, ) from litellm.proxy.tracing_runtime import provide_storage @@ -227,6 +233,40 @@ async def list_agents(auth: Auth, storage: StorageDep) -> tuple[str, ...]: return await source_reader(storage).agents(scope) if storage is not None else () +def watching(lens: Lens) -> Lens: + if lens.settings.enabled: + return lens + return lens.model_copy( + update=MappingProxyType( + { + "settings": lens.settings.model_copy(update=MappingProxyType({"enabled": True})), + "revision": lens.revision + 1, + } + ) + ) + + +async def watchable(lens: Lens, auth: UserAPIKeyAuth) -> WatchSkipped | None: + try: + await validate_model(lens.settings.model_copy(update=MappingProxyType({"enabled": True})), auth) + except HTTPException as exc: + return WatchSkipped(id=lens.id, name=lens.settings.name, reason=str(exc.detail)) + return None + + +@router.post("/watch-all", response_model=WatchAllResult) +async def watch_all(auth: Auth) -> WatchAllResult: + scope: Final = user_scope(auth, write=True) + paused: Final = tuple( + e for e in await repository().lenses() if can_access(scope, e.scope) and not e.settings.enabled + ) + checks: Final = tuple([(lens, await watchable(lens, auth)) for lens in paused]) + skipped: Final = tuple(skip for _, skip in checks if skip is not None) + ready: Final = tuple(lens for lens, skip in checks if skip is None) + updated: Final = tuple([await repository().update(lens.id, watching) for lens in ready]) + return WatchAllResult(watching=tuple(u.id for u in updated if u is not None), skipped=skipped) + + @router.put("/{lens_id}", response_model=Lens) async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens: lens: Final = await get_lens(lens_id, user_scope(auth, write=True)) @@ -248,6 +288,20 @@ async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens: ) +def run_window(lens: Lens, body: RunRequest, now: datetime) -> tuple[datetime, datetime] | None: + if body.start is not None and body.end is not None: + return body.start, body.end + if body.lookback_hours is None and body.settings is None: + return scheduled_window(lens, now) + return None + + +def run_settings(lens: Lens, body: RunRequest) -> LensSettings | None: + if body.agent_name is None: + return body.settings + return (body.settings or lens.settings).model_copy(update=MappingProxyType({"agent_name": body.agent_name})) + + @router.post("/{lens_id}/runs", response_model=Lens) async def run_lens(lens_id: str, body: RunRequest, auth: Auth) -> Lens: lens: Final = await get_lens(lens_id, user_scope(auth, write=True)) @@ -257,7 +311,18 @@ async def run_lens(lens_id: str, body: RunRequest, auth: Auth) -> Lens: now: Final = datetime.now(timezone.utc) job_id: Final = str(uuid4()) return required( - await repository().update(lens_id, lambda e: queue_job(e, now, job_id, body.lookback_hours, body.settings)) + await repository().update( + lens_id, + lambda e: queue_job( + e, + now, + job_id, + body.lookback_hours, + run_settings(e, body), + run_window(e, body, now), + "manual", + ), + ) ) @@ -425,13 +490,14 @@ async def progress(lens_id: str, job_id: str, body: Progress, worker: WorkerAuth job: Final = current_job(e) if job is None or job.id != job_id or job.worker_id != worker.id: return e + renewed: Final = job.model_copy( + update=MappingProxyType( + {"stage": body.stage, "coverage": body.coverage, "lease_until": now + timedelta(minutes=5)} + ) + ) return replace_job( e, - job.model_copy( - update=MappingProxyType( - {"stage": body.stage, "coverage": body.coverage, "lease_until": now + timedelta(minutes=5)} - ) - ), + renewed if body.stage == job.stage else add_step(renewed, Step(at=now, kind="stage", label=body.stage)), ) required(await repository().update(lens_id, renew)) @@ -575,7 +641,7 @@ async def result(lens_id: str, job_id: str, body: Result, worker: WorkerAuth, st update=MappingProxyType( { "findings": (*merged, *(f for f in e.findings if f.id not in merged_ids)), - "last_scan_at": e.last_scan_at if body.error else max(e.last_scan_at or job.end, job.end), + "last_scan_at": next_scan_start(e, job, failed=bool(body.error)), "next_run_at": now + timedelta(minutes=e.settings.interval_minutes), } ) diff --git a/litellm/proxy/lens/inference.py b/litellm/proxy/lens/inference.py index 7208504cd1f..b5c4d128c6e 100644 --- a/litellm/proxy/lens/inference.py +++ b/litellm/proxy/lens/inference.py @@ -13,9 +13,9 @@ from litellm.integrations.clickhouse.context import lens_analysis from litellm.litellm_core_utils.initialize_dynamic_callback_params import inherit_message_logging_privacy from litellm.litellm_core_utils.token_counter import get_modified_max_tokens from litellm.proxy.lens.billing import complete, validate_key -from litellm.proxy.lens.models import Job, Lens, ModelRequest, ModelResult, Worker +from litellm.proxy.lens.models import Job, Lens, ModelRequest, ModelResult, Step, Worker from litellm.proxy.lens.repository import LensRepository -from litellm.proxy.lens.state import current_job, renew_budget, replace_job +from litellm.proxy.lens.state import add_step, current_job, renew_budget, replace_job from litellm.types.utils import CostPerToken, ModelResponse @@ -199,12 +199,14 @@ async def analyze( if e.budget_month == now.strftime("%Y-%m") else e ) - return ( - replace_job( - adjusted, charged.model_copy(update=MappingProxyType({"cost": max(0, charged.cost - estimate + cost)})) - ) - if charged - else adjusted + if charged is None: + return adjusted + return replace_job( + adjusted, + add_step( + charged.model_copy(update=MappingProxyType({"cost": max(0, charged.cost - estimate + cost)})), + model_step(response, body, job.settings.model, cost), + ), ) @asynccontextmanager @@ -253,6 +255,37 @@ async def analyze( ) +class Usage(BaseModel): + model_config = ConfigDict(extra="ignore") + prompt_tokens: int | None = None + completion_tokens: int | None = None + + +class UsageEnvelope(BaseModel): + model_config = ConfigDict(extra="ignore") + model: str | None = None + usage: Usage | None = None + + +_PURPOSE_LABELS: Final = MappingProxyType( + {"extract": "Reviewed a run", "cluster": "Compared observations", "investigate": "Checked a pattern"} +) + + +def model_step(response: ModelResponse, body: ModelRequest, requested: str, cost: float) -> Step: + envelope: Final = UsageEnvelope.model_validate_json(response.model_dump_json()) + return Step( + at=datetime.now(timezone.utc), + kind="model", + label=_PURPOSE_LABELS[body.purpose], + model=envelope.model or requested, + purpose=body.purpose, + prompt_tokens=(envelope.usage.prompt_tokens if envelope.usage else None) or 0, + completion_tokens=(envelope.usage.completion_tokens if envelope.usage else None) or 0, + cost=cost, + ) + + def completion_charge(deployments: tuple[Deployment, ...], response: ModelResponse, estimate: float) -> float: custom: Final = deployments[0].litellm_params if len(deployments) == 1 else None if custom and custom.input_cost_per_token is not None and custom.output_cost_per_token is not None: diff --git a/litellm/proxy/lens/models.py b/litellm/proxy/lens/models.py index d967d18d0d1..de39dd9de74 100644 --- a/litellm/proxy/lens/models.py +++ b/litellm/proxy/lens/models.py @@ -190,6 +190,20 @@ class RunAssessment(Record): cannot_assess: bool = False +MAX_STEPS = 200 + + +class Step(Record): + at: datetime + kind: Literal["stage", "model", "error"] + label: str = Field(max_length=200) + model: str = Field(default="", max_length=200) + purpose: str = Field(default="", max_length=40) + prompt_tokens: int = 0 + completion_tokens: int = 0 + cost: float = 0 + + class Job(Record): id: str status: Literal["queued", "running", "completed", "failed", "cancelled"] = "queued" @@ -209,6 +223,8 @@ class Job(Record): cost: float = 0 findings: tuple[Finding, ...] | None = None assessments: tuple[RunAssessment, ...] = () + steps: tuple[Step, ...] = () + trigger: Literal["schedule", "manual"] = "schedule" class Lens(Record): @@ -249,6 +265,28 @@ class LensList(Record): class RunRequest(Record): settings: LensSettings | None = None lookback_hours: LookbackHours | None = None + start: datetime | None = None + end: datetime | None = None + agent_name: str | None = Field(default=None, max_length=200) + + @model_validator(mode="after") + def ordered_window(self) -> "RunRequest": + if (self.start is None) != (self.end is None): + raise ValueError("Choose both a start and an end time") + if self.start is not None and self.end is not None and self.start >= self.end: + raise ValueError("Start time must be before end time") + return self + + +class WatchSkipped(Record): + id: str + name: str + reason: str + + +class WatchAllResult(Record): + watching: tuple[str, ...] + skipped: tuple[WatchSkipped, ...] = () class FindingUpdate(Record): diff --git a/litellm/proxy/lens/state.py b/litellm/proxy/lens/state.py index f366ce46f25..3a26078ec82 100644 --- a/litellm/proxy/lens/state.py +++ b/litellm/proxy/lens/state.py @@ -1,9 +1,19 @@ import hashlib from datetime import datetime, timedelta from types import MappingProxyType -from typing import Final +from typing import Final, Literal -from litellm.proxy.lens.models import Finding, FindingDraft, Job, Lens, LensSettings, Scope, Worker +from litellm.proxy.lens.models import ( + MAX_STEPS, + Finding, + FindingDraft, + Job, + Lens, + LensSettings, + Scope, + Step, + Worker, +) def can_access(viewer: Scope, target: Scope) -> bool: @@ -24,27 +34,54 @@ def replace_job(lens: Lens, job: Job) -> Lens: ) +SETTLE_DELAY = timedelta(minutes=2) + + +def scheduled_window(lens: Lens, now: datetime) -> tuple[datetime, datetime]: + end: Final = now - SETTLE_DELAY + floor: Final = now - timedelta(hours=lens.settings.lookback_hours) + start: Final = max(lens.last_scan_at, floor) if lens.last_scan_at else floor + return min(start, end), end + + +def next_scan_start(lens: Lens, job: Job, failed: bool) -> datetime | None: + if failed or job.trigger == "manual": + return lens.last_scan_at + return max(lens.last_scan_at or job.end, job.end) + + def queue_job( lens: Lens, now: datetime, job_id: str, lookback_hours: int | None = None, settings: LensSettings | None = None, + window: tuple[datetime, datetime] | None = None, + trigger: Literal["schedule", "manual"] = "schedule", ) -> Lens: if current_job(lens): return lens selected: Final = settings or lens.settings + hours: Final = lookback_hours if lookback_hours is not None else (selected.lookback_hours if settings else None) + start, end = window or ( + (now - timedelta(hours=hours), now - SETTLE_DELAY) if hours is not None else scheduled_window(lens, now) + ) job: Final = Job( id=job_id, created_at=now, - start=now - timedelta(hours=lookback_hours if lookback_hours is not None else selected.lookback_hours), - end=now - timedelta(minutes=2), + start=start, + end=end, settings=selected, revision=lens.revision, + trigger=trigger, ) return lens.model_copy(update=MappingProxyType({"jobs": (job,)})) +def add_step(job: Job, step: Step) -> Job: + return job.model_copy(update=MappingProxyType({"steps": (*job.steps, step)[-MAX_STEPS:]})) + + def claim_job(lens: Lens, worker: Worker, now: datetime) -> Lens: job: Final = current_job(lens) if job is None or not can_access(worker.scope, lens.scope): diff --git a/tests/unit/proxy/lens/test_endpoints.py b/tests/unit/proxy/lens/test_endpoints.py index 40a6fd55044..783730b7c8f 100644 --- a/tests/unit/proxy/lens/test_endpoints.py +++ b/tests/unit/proxy/lens/test_endpoints.py @@ -1,13 +1,24 @@ +from datetime import datetime, timedelta, timezone from typing import Final import pytest from fastapi import HTTPException +from pydantic import ValidationError import litellm from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm import Router -from litellm.proxy.lens.endpoints import list_agents, user_scope, validate_model, worker_supports_model -from litellm.proxy.lens.models import LensSettings +from litellm.proxy.lens.endpoints import ( + list_agents, + run_settings, + run_window, + user_scope, + watchable, + watching, + validate_model, + worker_supports_model, +) +from litellm.proxy.lens.models import Lens, LensSettings, RunRequest, Scope @pytest.fixture @@ -167,6 +178,79 @@ def test_regular_keys_cannot_read_lens_results(role: LitellmUserRoles | None) -> assert error.value.status_code == 403 +def saved_lens() -> Lens: + now: Final = datetime(2026, 1, 15, tzinfo=timezone.utc) + return Lens( + id="lens", + scope=Scope(all_teams=True), + settings=LensSettings(name="Support", model="analysis", context="Answer questions", agent_name="support"), + created_at=now, + next_run_at=now, + budget_month="2026-01", + ) + + +def test_run_now_agent_override_only_changes_the_agent_for_that_run() -> None: + lens: Final = saved_lens() + overridden: Final = run_settings(lens, RunRequest(agent_name="billing")) + assert overridden is not None + assert overridden.agent_name == "billing" + assert overridden.model_copy(update={"agent_name": "support"}) == lens.settings + + +def test_run_now_without_overrides_keeps_the_saved_settings() -> None: + assert run_settings(saved_lens(), RunRequest()) is None + + +def test_run_now_rejects_a_window_that_is_missing_an_edge_or_backwards() -> None: + now: Final = datetime(2026, 1, 15, tzinfo=timezone.utc) + with pytest.raises(ValidationError, match="both a start and an end"): + RunRequest(start=now) + with pytest.raises(ValidationError, match="before end"): + RunRequest(start=now, end=now - timedelta(hours=1)) + + +def test_watching_switches_a_paused_investigation_on_and_records_the_change() -> None: + paused: Final = saved_lens().model_copy( + update={"settings": saved_lens().settings.model_copy(update={"enabled": False})} + ) + watched: Final = watching(paused) + assert watched.settings.enabled is True + assert watched.revision == paused.revision + 1 + assert watched.settings.model_copy(update={"enabled": False}) == paused.settings + + +def test_watching_leaves_an_investigation_that_is_already_on_untouched() -> None: + on: Final = saved_lens() + assert watching(on) is on + + +async def test_watch_all_skips_an_investigation_whose_model_is_gone_instead_of_failing_them_all( + analysis_router: Router, +) -> None: + stale: Final = saved_lens().model_copy( + update={"settings": saved_lens().settings.model_copy(update={"model": "retired-model", "enabled": False})} + ) + skipped: Final = await watchable(stale, UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)) + assert skipped is not None + assert skipped.id == stale.id + assert skipped.reason + + +def test_run_now_since_last_run_keeps_scanning_only_new_traces_even_with_an_agent_override() -> None: + now: Final = datetime(2026, 1, 15, 12, tzinfo=timezone.utc) + resumed: Final = saved_lens().model_copy(update={"last_scan_at": now - timedelta(hours=1)}) + window: Final = run_window(resumed, RunRequest(agent_name="billing"), now) + assert window is not None + assert window[0] == now - timedelta(hours=1) + + +def test_run_now_with_a_lookback_scans_that_lookback_instead_of_since_last_run() -> None: + now: Final = datetime(2026, 1, 15, 12, tzinfo=timezone.utc) + resumed: Final = saved_lens().model_copy(update={"last_scan_at": now - timedelta(hours=1)}) + assert run_window(resumed, RunRequest(lookback_hours=24), now) is None + + @pytest.mark.parametrize("provider", (False, True)) def test_model_errors_reach_worker_with_status_and_redacted_provider_message(provider: bool) -> None: import httpx diff --git a/tests/unit/proxy/lens/test_inference.py b/tests/unit/proxy/lens/test_inference.py index e249693c4d5..eaff32323a4 100644 --- a/tests/unit/proxy/lens/test_inference.py +++ b/tests/unit/proxy/lens/test_inference.py @@ -4,7 +4,8 @@ import pytest from fastapi import HTTPException import litellm -from litellm.proxy.lens.inference import Deployment, DeploymentParams, completion_charge, quote +from litellm.proxy.lens.inference import Deployment, DeploymentParams, completion_charge, model_step, quote +from litellm.proxy.lens.models import ModelRequest from litellm.types.utils import ModelResponse @@ -120,3 +121,17 @@ def test_unknown_model_capacity_requires_explicit_operator_metadata() -> None: assert "model_info.max_output_tokens" in error.value.detail configured: Final = Deployment(litellm_params=params, model_info=ModelCapacity(max_output_tokens=32000)) assert output_tokens(configured) == 32000 + + +def test_a_model_step_records_the_serving_model_and_its_tokens() -> None: + response: Final = ModelResponse(model="gpt-5.6", usage={"prompt_tokens": 1200, "completion_tokens": 80}) + step: Final = model_step(response, ModelRequest(prompt="review", purpose="extract"), "analysis", 0.02) + assert (step.model, step.prompt_tokens, step.completion_tokens, step.cost) == ("gpt-5.6", 1200, 80, 0.02) + + +def test_a_response_without_usage_still_records_a_step_instead_of_failing_settlement() -> None: + response: Final = ModelResponse(model="gpt-5.6") + unpriced: Final = response.model_copy(update={"usage": None}) + step: Final = model_step(unpriced, ModelRequest(prompt="review", purpose="cluster"), "analysis", 0.0) + assert (step.prompt_tokens, step.completion_tokens) == (0, 0) + assert step.label == "Compared observations" diff --git a/tests/unit/proxy/lens/test_state.py b/tests/unit/proxy/lens/test_state.py index fac43d6e850..f5ad36ecfeb 100644 --- a/tests/unit/proxy/lens/test_state.py +++ b/tests/unit/proxy/lens/test_state.py @@ -1,9 +1,11 @@ from datetime import datetime, timedelta, timezone +from functools import reduce from typing import Final import pytest from litellm.proxy.lens.models import ( + MAX_STEPS, AgentTestCase, Check, Evidence, @@ -12,9 +14,19 @@ from litellm.proxy.lens.models import ( Lens, LensSettings, Scope, + Step, Worker, ) -from litellm.proxy.lens.state import can_access, claim_job, current_job, merge_finding, queue_job, renew_budget +from litellm.proxy.lens.state import ( + add_step, + can_access, + claim_job, + current_job, + merge_finding, + next_scan_start, + queue_job, + renew_budget, +) NOW: Final = datetime(2026, 1, 15, tzinfo=timezone.utc) @@ -163,15 +175,42 @@ def test_monthly_budget_renews_without_erasing_job_costs() -> None: @pytest.mark.parametrize("hours", (24, 168, 720, 4800, 8760)) -def test_every_scan_uses_the_configured_lookback_window(hours: int) -> None: +def test_first_scan_covers_the_configured_lookback_window(hours: int) -> None: original: Final = lens() configured: Final = original.model_copy( update={"settings": LensSettings.model_validate({**original.settings.model_dump(), "lookback_hours": hours})} ) first: Final = queue_job(configured, NOW, "first") assert first.jobs[0].start == NOW - timedelta(hours=hours) - resumed: Final = configured.model_copy(update={"last_scan_at": NOW - timedelta(hours=1)}) - assert queue_job(resumed, NOW, "next").jobs[0].start == NOW - timedelta(hours=hours) + assert first.jobs[0].trigger == "schedule" + + +def test_later_scheduled_scans_only_cover_traces_since_the_last_scan() -> None: + resumed: Final = lens().model_copy(update={"last_scan_at": NOW - timedelta(hours=1)}) + job: Final = queue_job(resumed, NOW, "next").jobs[0] + assert job.start == NOW - timedelta(hours=1) + assert job.end == NOW - timedelta(minutes=2) + + +def test_a_scan_after_a_long_outage_never_reaches_past_the_lookback_window() -> None: + stale: Final = lens().model_copy(update={"last_scan_at": NOW - timedelta(days=400)}) + assert queue_job(stale, NOW, "next").jobs[0].start == NOW - timedelta(hours=stale.settings.lookback_hours) + + +def test_run_now_with_an_exact_window_scans_that_window_and_is_marked_manual() -> None: + window: Final = (NOW - timedelta(hours=5), NOW - timedelta(hours=3)) + job: Final = queue_job(lens(), NOW, "manual", window=window, trigger="manual").jobs[0] + assert (job.start, job.end) == window + assert job.trigger == "manual" + + +def test_steps_keep_only_the_most_recent_entries() -> None: + job: Final = queue_job(lens(), NOW, "job").jobs[0] + steps: Final = tuple(Step(at=NOW, kind="stage", label=f"step {i}") for i in range(MAX_STEPS + 5)) + grown: Final = reduce(add_step, steps, job) + assert len(grown.steps) == MAX_STEPS + assert grown.steps[0].label == "step 5" + assert grown.steps[-1].label == f"step {MAX_STEPS + 4}" def test_finding_keeps_uncertainty_separate_from_the_main_summary() -> None: @@ -287,6 +326,17 @@ def test_legacy_finding_identity_preserves_feedback_only_for_same_kind_and_check assert separate.status == "open" and separate.reason == "" +def test_only_successful_scheduled_scans_move_the_next_scan_forward() -> None: + previous: Final = lens().model_copy(update={"last_scan_at": NOW - timedelta(hours=3)}) + scheduled: Final = queue_job(previous, NOW, "scheduled").jobs[0] + manual: Final = queue_job( + previous, NOW, "manual", window=(NOW - timedelta(hours=2), NOW - timedelta(hours=1)), trigger="manual" + ).jobs[0] + assert next_scan_start(previous, scheduled, failed=False) == scheduled.end + assert next_scan_start(previous, scheduled, failed=True) == previous.last_scan_at + assert next_scan_start(previous, manual, failed=False) == previous.last_scan_at + + @pytest.mark.parametrize("field", ("lookback_hours", "interval_minutes")) def test_calendar_overflow_is_rejected_without_the_old_history_and_interval_caps(field: str) -> None: from pydantic import ValidationError diff --git a/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx index b5b43349636..bdfe49c1d00 100644 --- a/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx @@ -32,9 +32,9 @@ describe("Lens navigation", () => { expect(screen.getByText("Trace polling active")).toBeVisible(); }); - it("opens existing lens links directly in investigations", () => { + it("opens existing lens links on findings", () => { renderWithProviders(, { searchParams: "?lens=saved-lens" }); - expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByText("Manage investigations")).toBeVisible(); }); diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx index db5d679a6c4..87adbe2adb3 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx @@ -41,7 +41,7 @@ describe("Lens interactive demo", () => { await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed"); expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4); await user.click(screen.getByRole("tab", { name: "Investigations" })); - expect(await screen.findByRole("button", { name: /Support quality/ })).toBeVisible(); + expect(await screen.findByRole("row", { name: /Support quality/ })).toBeVisible(); expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument(); expect(network).not.toHaveBeenCalled(); expect(onUrlUpdate).not.toHaveBeenCalled(); @@ -60,9 +60,9 @@ describe("Lens interactive demo", () => { }); await screen.findByRole("button", { name: "Preview sample" }); await user.click(screen.getByRole("button", { name: "Preview sample" })); - await user.click(await screen.findByRole("button", { name: /Support quality/ })); + await user.click(await screen.findByRole("tab", { name: "Findings" })); network.mockClear(); - await user.click(screen.getByRole("button", { name: /Repeated lookups leave customers without an answer/ })); + await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ })); const finding = screen.getByRole("dialog"); expect(within(finding).getByText(/The support agent retries/)).toBeVisible(); const summaries = within(finding).getAllByText("support_agent", { exact: true }); @@ -80,9 +80,7 @@ describe("Lens interactive demo", () => { within(screen.getByRole("dialog", { name: "Original run" })).getByRole("button", { name: "Close" }), ); await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" })); - await user.click(screen.getByRole("tab", { name: "History" })); - await user.click(screen.getAllByRole("button", { name: /runs reviewed/ })[1]); - expect(await screen.findByText(/1 linked run · high priority/)).toBeVisible(); + expect(await screen.findByRole("table", { name: "Findings" })).toBeVisible(); expect(network).not.toHaveBeenCalled(); expect(onUrlUpdate).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Exit demo" })); @@ -127,7 +125,7 @@ describe("Lens interactive demo", () => { expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible(); const tabs = within(screen.getByRole("tablist", { name: "Lens" })); await user.click(tabs.getByRole("tab", { name: "Investigations" })); - expect(await screen.findByRole("button", { name: new RegExp(saved.settings.name) })).toBeVisible(); + expect(await screen.findByRole("row", { name: new RegExp(saved.settings.name) })).toBeVisible(); expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument(); await user.click(tabs.getByRole("tab", { name: "Traces" })); await user.click(await screen.findByRole("button", { name: "Preview sample" })); diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx index a0ac3e3e59c..9912fc37c21 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx @@ -14,7 +14,7 @@ import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles"; import { InvestigationsView } from "./investigations/InvestigationsView"; import { createLensDemo } from "./demo/createLensDemo"; -type Tab = "traces" | "investigations"; +type Tab = "traces" | "findings" | "investigations"; type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean }; export function LensWorkspace(props: WorkspaceProps) { @@ -59,12 +59,12 @@ function LensContent({ const demo = useLensDemo(); const [tab, setTab] = useQueryState( "tab", - parseAsStringLiteral(["traces", "investigations"]).withOptions({ history: "push" }), + parseAsStringLiteral(["traces", "findings", "investigations"]).withOptions({ history: "push" }), ); const [lensId] = useQueryState("lens", parseAsString); const [demoTab, setDemoTab] = useState(initialTab); const [previewTarget, setPreviewTarget] = useState(null); - const defaultTab = lensId ? "investigations" : "traces"; + const defaultTab = lensId ? "findings" : "traces"; const activeTab = demo ? demoTab : tab ?? defaultTab; const openDemo = onDemo ? () => onDemo(activeTab) : undefined; return ( @@ -86,6 +86,9 @@ function LensContent({ Traces + + Findings + Investigations @@ -102,19 +105,23 @@ function LensContent({ onDemo={activeTab === "traces" ? openDemo : undefined} /> - - {demo || isProxyAdminTierRole(userRole) ? ( - - ) : ( -

- Investigations require proxy administrator access. You can still view your traces. -

- )} -
+ {(["findings", "investigations"] as const).map((view) => ( + + {demo || isProxyAdminTierRole(userRole) ? ( + + ) : ( +

+ Investigations require proxy administrator access. You can still view your traces. +

+ )} +
+ ))} diff --git a/ui/litellm-dashboard/src/components/lens/api/service.ts b/ui/litellm-dashboard/src/components/lens/api/service.ts index b784cc582d3..0383efa3e20 100644 --- a/ui/litellm-dashboard/src/components/lens/api/service.ts +++ b/ui/litellm-dashboard/src/components/lens/api/service.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { RunWindow } from "../investigations/detail/RunNowDialog"; import type { ApiClient } from "@/lib/http/client"; import type { components } from "@/lib/http/schema"; import type { ActivitySelection, Job, Lens, LensList, Sample, Settings, WorkerCreated } from "../model/types"; @@ -44,7 +45,8 @@ export interface LensApi { keys(alias: string, page: number, signal: AbortSignal): Promise; keyInfo(keyId: string): Promise; saveLens(id: string | undefined, settings: Settings): Promise; - startRun(lensId: string): Promise; + startRun(lensId: string, request?: RunWindow): Promise; + watchAll(): Promise; cancelRun(lensId: string): Promise; reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise; registerWorker(analysisKeyId: string | null): Promise; @@ -109,7 +111,9 @@ export function liveLensApi(apiClient: ApiClient, accessToken: string): LensApi keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info, saveLens: (id, settings) => apiClient.request(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }), - startRun: (lensId) => apiClient.post(`/lens/${lensId}/runs`, { accessToken, body: {} }), + startRun: (lensId, request = {}) => apiClient.post(`/lens/${lensId}/runs`, { accessToken, body: request }), + watchAll: () => + apiClient.post("/lens/watch-all", { accessToken, body: {} }), cancelRun: (lensId) => apiClient.post(`/lens/${lensId}/cancel`, { accessToken, body: {} }), reviewFinding: (lensId, findingId, status, reason) => apiClient.patch(`/lens/${lensId}/findings/${findingId}`, { accessToken, body: { status, reason } }), diff --git a/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts b/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts index c971bcf8821..bc75214127d 100644 --- a/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts +++ b/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts @@ -296,6 +296,8 @@ export function createLensDemoData(now = Date.now()) { : [], })), attempts: 1, + steps: [], + trigger: "schedule" as const, error: "", cost: sample.length * 0.012, coverage: { @@ -359,6 +361,7 @@ function demoLensApi(data: LensDemoData): LensApi { keyInfo: notInDemo, saveLens: readOnly, startRun: readOnly, + watchAll: async () => ({ watching: [], skipped: [] }), cancelRun: readOnly, reviewFinding: readOnly, registerWorker: readOnly, diff --git a/ui/litellm-dashboard/src/components/lens/investigations/FindingSheet.tsx b/ui/litellm-dashboard/src/components/lens/investigations/FindingSheet.tsx index adf61580bb7..c3f8b2b3a7e 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/FindingSheet.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/FindingSheet.tsx @@ -10,6 +10,7 @@ import { type Finding, type Sample } from "../model/types"; export function FindingSheet({ finding, + agents = [], sampledRuns, readOnly, busy, @@ -18,6 +19,7 @@ export function FindingSheet({ changeFinding, }: { finding?: Finding; + agents?: readonly string[]; sampledRuns: Sample["executions"]; readOnly: boolean; busy: boolean; @@ -46,6 +48,7 @@ export function FindingSheet({ {finding.title} + {agents.length > 0 && {agents.join(", ")} · } {finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} ·{" "} {finding.occurrences?.length ?? 0} linked {finding.occurrences?.length === 1 ? "run" : "runs"} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/FindingsInbox.tsx b/ui/litellm-dashboard/src/components/lens/investigations/FindingsInbox.tsx new file mode 100644 index 00000000000..f2b68615eed --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/FindingsInbox.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { useState } from "react"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useNow } from "@/hooks/useNow"; +import { formatActivityTimestamp } from "@/utils/activityTimestamp"; +import { agoLabel } from "@/components/view_logs/TraceView/lensField"; +import { cn } from "@/lib/cva.config"; + +import { ALL_AGENTS, filterInbox, inboxAgents, inboxRows, type InboxRow, type Priority } from "../model/inbox"; +import type { Lens } from "../model/types"; + +const PRIORITY_DOT = { high: "bg-[#e5484d]", medium: "bg-amber-500", low: "bg-muted-foreground/50" } as const; +const PRIORITY_ITEMS: { value: Priority | "all"; label: string }[] = [ + { value: "all", label: "All priorities" }, + { value: "high", label: "High" }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, +]; + +const TH = "px-3 font-medium"; +const TH_NUM = "px-3 text-right font-medium"; + +function FilterSelect({ + label, + value, + items, + onChange, + width, +}: { + label: string; + value: T; + items: { value: T; label: string }[]; + onChange: (value: T) => void; + width: string; +}) { + return ( + + ); +} + +export function FindingsInbox({ lenses, onOpen }: { lenses: readonly Lens[]; onOpen: (row: InboxRow) => void }) { + const [agent, setAgent] = useState(ALL_AGENTS); + const [priority, setPriority] = useState("all"); + const now = useNow(30000); + const all = inboxRows(lenses); + const rows = filterInbox(all, { agent, priority }); + const agentItems = [ + { value: ALL_AGENTS, label: "All agents" }, + ...inboxAgents(all).map((a) => ({ value: a, label: a })), + ]; + return ( +
+
+ + +
+
+ + + + + + + + + + + + {rows.map((row) => ( + onOpen(row)} + data-testid="inbox-row" + aria-label={row.title} + className="h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none" + > + + + + + + + + ))} + +
PriorityFindingAgentRunsLast seen +
+ + + {row.priority} + + + {row.title} + + {row.agents.join(", ")} + {row.runs} + {agoLabel(Date.parse(row.lastSeen), now)} + + +
+ {rows.length === 0 && ( +
+ {all.length === 0 + ? "No open findings yet. New problems show up here as soon as an investigation spots them." + : "No findings match these filters."} +
+ )} +
+
+ {rows.length} {rows.length === 1 ? "finding" : "findings"} + {rows.length !== all.length && ` of ${all.length}`} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/HeaderActions.tsx b/ui/litellm-dashboard/src/components/lens/investigations/HeaderActions.tsx new file mode 100644 index 00000000000..cd362e2178d --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/HeaderActions.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useContext, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { LensPreviewTarget } from "../LensPreviewButton"; + +export function HeaderActions({ children }: { children: ReactNode }) { + const target = useContext(LensPreviewTarget); + const actions =
{children}
; + if (target === null) return null; + return target ? createPortal(actions, target) : actions; +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationList.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationList.tsx index 81dcff6d42e..1ee82bd5c81 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationList.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationList.tsx @@ -1,64 +1,151 @@ -import { ListRow } from "@/components/shared/ListRow"; -import { useState } from "react"; -import { ChevronRight, Search } from "lucide-react"; -import { Input } from "@/components/ui/input"; +"use client"; +import { Pencil, Play, Search } from "lucide-react"; +import { useState } from "react"; + +import { useNow } from "@/hooks/useNow"; +import { Input } from "@/components/ui/input"; +import { formatActivityTimestamp } from "@/utils/activityTimestamp"; +import { cn } from "@/lib/cva.config"; +import { agoLabel } from "@/components/view_logs/TraceView/lensField"; + +import { scheduleLabel } from "../model/inbox"; import { lensStatus } from "../model/status"; -import { runTime, scopeLabel } from "../model/format"; +import { scopeLabel } from "../model/format"; import { type Lens } from "../model/types"; +const TH = "px-3 font-medium"; +const TH_NUM = "px-3 text-right font-medium"; + export function InvestigationList({ lenses, connected, - onSelect, + readOnly = false, + onEdit, + onRunNow, }: { lenses: Lens[]; connected: boolean; - onSelect: (id: string) => void; + readOnly?: boolean; + onEdit: (id: string) => void; + onRunNow: (id: string) => void; }) { const [search, setSearch] = useState(""); + const now = useNow(15000); const shown = lenses.filter((lens) => `${lens.settings.name} ${scopeLabel(lens.settings)}`.toLowerCase().includes(search.toLowerCase()), ); return ( -
-
- - setSearch(e.target.value)} - /> +
+
+
+ + setSearch(e.target.value)} + /> +
-
- {shown.map((lens) => ( - onSelect(lens.id)} - className="group grid w-full grid-cols-[minmax(0,1fr)_16px] items-center gap-x-4 gap-y-2 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring sm:grid-cols-[minmax(0,1fr)_auto_16px]" - > -
-

{lens.settings.name}

-

{scopeLabel(lens.settings)}

-
-
-

- {lensStatus(lens, connected)} -

-

- {lens.jobs[0] ? runTime(lens.jobs[0].created_at) : "Not run yet"} -

-
- -
- ))} - {!shown.length &&

No investigations match your search.

} +
+ + + + + + + + + + + + {shown.map((lens) => { + const latest = lens.jobs[0]; + const failed = latest?.status === "failed"; + const open = lens.findings.filter((f) => f.status === "open").length; + return ( + onEdit(lens.id)} + data-testid="investigation-row" + aria-label={lens.settings.name} + className={cn( + "h-9 border-b border-border/60 text-[12px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none", + !readOnly && "cursor-pointer", + )} + > + + + + + + + + ); + })} + +
InvestigationAgentScheduleLast runOpen +
{lens.settings.name}{scopeLabel(lens.settings)} + + + {scheduleLabel(lens, now)} + + + + {lensStatus(lens, connected).toLowerCase()} + + {latest && ( + · {agoLabel(Date.parse(latest.created_at), now)} + )} + {open} + {!readOnly && ( + + + + + )} +
+ {!shown.length && ( +
+ No investigations match your search. +
+ )}
-
+
+ {shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "} + {lenses.filter((l) => l.settings.enabled).length} watching +
+ ); } diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationNavigation.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationNavigation.tsx index 6e5933502ca..022ae820ef8 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationNavigation.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationNavigation.tsx @@ -25,21 +25,26 @@ export function InvestigationNavigation({ setEditing: (mode: "new") => void; }) { return ( -
+
{lens ? ( - ) : ( -

Investigations

- )} + ) : null} {showActions && (
- -
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx index f7c9c1e6ae8..b7343717aa1 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx @@ -1,4 +1,4 @@ -import { act, screen, within, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, within, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders as renderProviders, testQueryClient } from "@/../tests/test-utils"; @@ -15,7 +15,7 @@ function renderWithProviders(ui: React.ReactElement, options?: Parameters ({ - apiClient: { get: vi.fn(), post: vi.fn(), request: vi.fn() }, + apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), request: vi.fn() }, proxyBaseUrl: "", })); @@ -86,6 +86,8 @@ const lens: Lens = { id: "scan", findings: [pattern, issue], assessments: [], + steps: [], + trigger: "schedule", attempts: 0, error: "", cost: 0, @@ -232,7 +234,7 @@ describe("Lens findings and runs", () => { }); }); -it("runs saved settings immediately without opening setup", async () => { +it("runs with saved settings from Run now without opening setup, then accepts an agent and window", async () => { testQueryClient.clear(); vi.mocked(apiClient.get).mockImplementation(async (path) => { if (path === "/lens") @@ -258,8 +260,22 @@ it("runs saved settings immediately without opening setup", async () => { const user = userEvent.setup(); renderWithProviders(); await user.click(await screen.findByRole("button", { name: "Run now" })); + const choices = await screen.findByRole("dialog", { name: "Run now" }); + expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true"); + await user.click(within(choices).getByRole("button", { name: "Run now" })); expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/runs", { accessToken: "test", body: {} }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + vi.mocked(apiClient.post).mockClear(); + await user.click(screen.getByRole("button", { name: "Run now" })); + const custom = await screen.findByRole("dialog", { name: "Run now" }); + fireEvent.change(within(custom).getByRole("combobox", { name: "Agent" }), { target: { value: "billing" } }); + await user.click(within(custom).getByRole("button", { name: "Last 24h" })); + await user.click(within(custom).getByRole("button", { name: "Run now" })); + expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/runs", { + accessToken: "test", + body: { agent_name: "billing", lookback_hours: 24 }, + }); }); it("offers the interactive demo without starting an investigation", async () => { @@ -507,6 +523,46 @@ it("closes editing when browser navigation leaves the investigation", async () = expect(apiClient.request).not.toHaveBeenCalled(); }); +it("resolves every investigation's copy of a merged finding from one row", async () => { + window.history.replaceState({}, "", "/lens/"); + testQueryClient.clear(); + const twin: Lens = { ...lens, id: "twin", settings: { ...lens.settings, name: "Twin reviews" } }; + vi.mocked(apiClient.get).mockImplementation(async (path) => { + if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] }; + if (path === "/lens/activity/available") return { traces: true, requests: false }; + return { data: [] }; + }); + vi.mocked(apiClient.patch).mockResolvedValue(undefined); + const user = userEvent.setup(); + renderWithProviders(); + const rows = await screen.findAllByRole("row", { name: issue.title }); + expect(rows).toHaveLength(1); + await user.click(rows[0]); + await user.click(await screen.findByRole("button", { name: "Mark resolved" })); + await waitFor(() => expect(apiClient.patch).toHaveBeenCalledTimes(2)); + const resolved = vi + .mocked(apiClient.patch) + .mock.calls.map(([path]) => path) + .sort(); + expect(resolved).toEqual(["/lens/lens/findings/issue", "/lens/twin/findings/issue"]); +}); + +it("lists investigations without edit or run controls for read-only viewers", async () => { + window.history.replaceState({}, "", "/lens/"); + testQueryClient.clear(); + vi.mocked(apiClient.get).mockImplementation(async (path) => { + if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] }; + if (path === "/lens/activity/available") return { traces: true, requests: false }; + return { data: [] }; + }); + const user = userEvent.setup(); + renderWithProviders(); + const row = await screen.findByRole("row", { name: lens.settings.name }); + expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument(); + await user.click(row); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); + it("shows the actual saved failure and run context without opening backend logs", async () => { testQueryClient.clear(); const error = @@ -526,3 +582,24 @@ it("shows the actual saved failure and run context without opening backend logs" expect(failure.getByText(job.settings.model)).toBeVisible(); expect(failure.queryByText(/find the error in proxy and worker logs/)).not.toBeInTheDocument(); }); + +it("keeps a merged finding open to retry when one investigation's update fails", async () => { + window.history.replaceState({}, "", "/lens/"); + testQueryClient.clear(); + const twin: Lens = { ...lens, id: "twin", settings: { ...lens.settings, name: "Twin reviews" } }; + vi.mocked(apiClient.get).mockImplementation(async (path) => { + if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] }; + if (path === "/lens/activity/available") return { traces: true, requests: false }; + return { data: [] }; + }); + vi.mocked(apiClient.patch).mockReset(); + vi.mocked(apiClient.patch).mockImplementation(async (path) => { + if (String(path).startsWith("/lens/twin/")) throw new Error("Twin reviews could not be updated"); + }); + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole("row", { name: issue.title })); + await user.click(await screen.findByRole("button", { name: "Mark resolved" })); + expect(await screen.findByText("Twin reviews could not be updated")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mark resolved" })).toBeVisible(); +}); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx index 25ab4622b21..308da35e528 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx @@ -25,16 +25,25 @@ import { InvestigationSetupDialog } from "../setup/InvestigationSetupDialog"; import { WorkerDialog } from "../setup/worker/WorkerDialog"; import { useAnalysisKeyInfo } from "../setup/worker/AnalysisKeyDetails"; import { InvestigationList } from "./InvestigationList"; +import { HeaderActions } from "./HeaderActions"; +import { RunNowDialog } from "./detail/RunNowDialog"; +import { FindingsInbox } from "./FindingsInbox"; +import { findingAgents, sampledExecutions, type InboxRow } from "../model/inbox"; +import { WatchAllBanner } from "./WatchAllBanner"; import { MonitoringDialog } from "../setup/MonitoringDialog"; import { InvestigationsWelcome } from "./InvestigationsWelcome"; import { workerConnected, readiness } from "../model/status"; import { type Finding, type Settings } from "../model/types"; export function InvestigationsView({ + view = "findings", + active = true, accessToken, readOnly = false, onDemo, }: { + view?: "findings" | "investigations"; + active?: boolean; accessToken: string; readOnly?: boolean; onDemo?: () => void; @@ -50,11 +59,17 @@ export function InvestigationsView({ const query = useQuery(lensQueries.list(api, !!demo, workerSetup)); const models = useQuery(lensQueries.models(api)); const modelDetails = useQuery(lensQueries.modelDetails(api)); + const [agentsAsOf] = useState(() => new Date().toISOString()); + const agents = useQuery(lensQueries.agents(api, agentsAsOf, "traces")); const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" })); const [demoSelected, setDemoSelected] = useState(null); const selected = demo ? demoSelected : liveSelected; const setSelected = demo ? setDemoSelected : setLiveSelected; const [editing, setEditing] = useState<"new" | "edit" | "duplicate" | null>(null); + const [peek, setPeek] = useState(false); + const [peeked, setPeeked] = useState(null); + const [runNowId, setRunNowId] = useState(null); + const [skipped, setSkipped] = useState([]); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const lenses = [...(query.data?.lenses ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); @@ -113,15 +128,17 @@ export function InvestigationsView({ void client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }); void client.invalidateQueries({ queryKey: lensKeys.histories() }); }; - const update = async (write: LensWrite) => { + const update = async (write: LensWrite): Promise => { setBusy(true); setError(""); try { await updateLens.mutateAsync(write); - await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }); + return true; } catch (e) { setError(e instanceof Error ? e.message : "Could not update lens"); + return false; } finally { + await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }); setBusy(false); } }; @@ -131,18 +148,51 @@ export function InvestigationsView({ if (editing !== "edit" && !ready) throw new Error("Wait for recorded activity and a connected worker before starting an investigation"); const saved = await saveLens.mutateAsync({ id: editing === "edit" ? lens?.id : undefined, settings }); - selectLens(saved.id); + selectLens(peek ? null : saved.id); + setPeek(false); setEditing(null); refresh(); }; + const editFromTable = (id: string) => { + setPeek(true); + selectLens(id); + setEditing("edit"); + }; + const openFinding = (row: InboxRow) => { + setPeek(true); + setPeeked(row); + selectLens(row.sources[0].lens.id); + }; + const closeFinding = () => { + setFindingId(null); + setPeeked(null); + if (!peek) return; + setPeek(false); + selectLens(null); + }; const changeFinding = async (status: Finding["status"], reason: string) => { + if (peeked) { + const saved = await update((current) => + Promise.all(peeked.sources.map((s) => current.reviewFinding(s.lens.id, s.finding.id, status, reason))), + ); + if (saved) closeFinding(); + return; + } if (!lens || !finding) return; await update((current) => current.reviewFinding(lens.id, finding.id, status, reason)); }; + const sheetFinding = peeked ? peeked.sources[0].finding : finding; + const sheetRuns = peeked ? peeked.sources.flatMap((s) => sampledExecutions(s.lens)) : sampledRuns; + const detailAgents = lens && finding ? findingAgents(lens, finding) : []; + const sheetAgents = peeked ? peeked.agents : detailAgents; + const onDetail = !!selected && !peek; + const showDetailNav = onDetail && !showEmpty; + const showTables = !onDetail && lenses.length > 0; + const showMissing = missingSelection && !lens && !peek; return ( -
- {!showEmpty && ( +
+ {showDetailNav && ( )} - {showReadiness && !ready && } - {!selected && lenses.length > 0 && ( - + {showReadiness && !ready && } + {showTables && ( +
+ {active && ( + + {!readOnly && ( + + update(async (api) => { + const result = await api.watchAll(); + setSkipped(result.skipped); + }) + } + /> + )} + + + )} + {view === "findings" ? ( + + ) : ( + setRunNowId(id)} + /> + )} +
)} - {missingSelection && !lens && } - {lens && ( + {showMissing && } + {lens && !peek && ( )} {editing && ( @@ -203,10 +293,28 @@ export function InvestigationsView({ modelsLoading={models.isLoading} modelsError={models.error?.message} accessToken={accessToken} - onClose={() => setEditing(null)} + onClose={() => { + setEditing(null); + if (peek) { + setPeek(false); + selectLens(null); + } + }} onSave={save} /> )} + {runNowId && ( + l.id === runNowId) ?? lenses[0]} + agents={Array.isArray(agents.data) ? agents.data : []} + busy={busy} + onClose={() => setRunNowId(null)} + onRun={async (request) => { + await update((api) => api.startRun(runNowId, request)); + setRunNowId(null); + }} + /> + )} {workerSetup && ( )} setFindingId(null)} + onClose={closeFinding} changeFinding={changeFinding} onEvidence={(value) => { setRequestOffset(0); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx b/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx new file mode 100644 index 00000000000..1ccba20c03f --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/lib/cva.config"; + +import { modelsUsed, stepLine, windowLabel } from "../model/inbox"; +import type { Job } from "../model/types"; + +const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const; +const STEP_TONE = { + model: "text-foreground", + stage: "text-[#0011b3] dark:text-[#8b9bff]", + error: "text-[#e5484d]", +} as const; + +export function StepFeed({ job }: { job: Job }) { + const [open, setOpen] = useState(true); + const steps = job.steps ?? []; + const models = modelsUsed(steps); + const calls = steps.filter((s) => s.kind === "model").length; + return ( +
+ + {open && ( +
    + {steps.length === 0 &&
  1. Waiting for the worker to start…
  2. } + {[...steps].reverse().map((step, index) => ( +
  3. + + + {new Date(step.at).toLocaleTimeString(undefined, { hour12: false })} + + {stepLine(step)} + {step.model} +
  4. + ))} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/WatchAllBanner.tsx b/ui/litellm-dashboard/src/components/lens/investigations/WatchAllBanner.tsx new file mode 100644 index 00000000000..4c8c8a00e75 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/WatchAllBanner.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { Lens } from "../model/types"; + +export function WatchAllBanner({ + lenses, + busy, + onWatchAll, + skipped = [], +}: { + lenses: readonly Lens[]; + busy: boolean; + onWatchAll: () => void; + skipped?: readonly { id: string; name: string; reason: string }[]; +}) { + const paused = lenses.filter((lens) => !lens.settings.enabled).length; + if (paused === 0) return null; + if (skipped.length >= paused) + return ( + `${s.name}: ${s.reason}`).join("\n")} + > + {paused} paused · {skipped.length === 1 ? `${skipped[0].name} needs a fix` : `${skipped.length} need a fix`} + + ); + return ( + + {paused} paused ·{" "} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx index 8a5d2156e26..f13aeb14193 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx @@ -19,6 +19,7 @@ export function InvestigationActions({ setEditing, setMonitoring, update, + onRunNow, }: { lens: Lens; ready: boolean; @@ -26,7 +27,8 @@ export function InvestigationActions({ active: Job | undefined; setEditing: (mode: "new" | "edit" | "duplicate") => void; setMonitoring: (open: boolean) => void; - update: (write: LensWrite) => Promise; + update: (write: LensWrite) => Promise; + onRunNow: () => void; }) { return (
@@ -59,7 +61,7 @@ export function InvestigationActions({ )} - diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx index 508c214c78d..5b44424f423 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx @@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { RunsTab } from "./RunsTab"; import { InvestigationProgress } from "../InvestigationProgress"; +import { StepFeed } from "../StepFeed"; +import { RunNowDialog } from "./RunNowDialog"; +import { useState } from "react"; import { InvestigationSummary } from "./InvestigationSummary"; import { InvestigationFailure } from "./InvestigationFailure"; import { scopeLabel, sourceLabels } from "../../model/format"; @@ -27,6 +30,7 @@ export function InvestigationDetail({ update, connected, results, + agents = [], }: { lens: Lens; readOnly: boolean; @@ -34,10 +38,12 @@ export function InvestigationDetail({ busy: boolean; setEditing: (mode: "new" | "edit" | "duplicate") => void; setMonitoring: (open: boolean) => void; - update: (write: LensWrite) => Promise; + update: (write: LensWrite) => Promise; connected: boolean; results: ReturnType; + agents?: readonly string[]; }) { + const [runNow, setRunNow] = useState(false); const { active, job, @@ -85,6 +91,7 @@ export function InvestigationDetail({ setEditing={setEditing} setMonitoring={setMonitoring} update={update} + onRunNow={() => setRunNow(true)} /> )}
@@ -102,6 +109,19 @@ export function InvestigationDetail({ } /> )} + {active && } + {runNow && ( + setRunNow(false)} + onRun={async (request) => { + await update((api) => api.startRun(lens.id, request)); + setRunNow(false); + }} + /> + )} {job?.error && }
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/RunNowDialog.test.ts b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunNowDialog.test.ts new file mode 100644 index 00000000000..617685d80e2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunNowDialog.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { runRequest } from "./RunNowDialog"; + +const base = { agent: "support", saved: "support", start: "", end: "" }; + +describe("runRequest", () => { + it("sends nothing extra for the default since-last-run choice", () => { + expect(runRequest({ ...base, preset: null })).toEqual({}); + }); + + it("only overrides the agent when it differs from the saved one", () => { + expect(runRequest({ ...base, preset: null, agent: "billing" })).toEqual({ agent_name: "billing" }); + expect(runRequest({ ...base, preset: null, agent: " support " })).toEqual({}); + }); + + it("turns a preset into a lookback window", () => { + expect(runRequest({ ...base, preset: 168 })).toEqual({ lookback_hours: 168 }); + }); + + it("sends an exact window as ISO timestamps for a custom range", () => { + const choice = { ...base, preset: -1 as const, start: "2026-10-02T10:00", end: "2026-10-02T12:00" }; + const request = runRequest(choice); + expect(request).toEqual({ + start: new Date("2026-10-02T10:00").toISOString(), + end: new Date("2026-10-02T12:00").toISOString(), + }); + }); + + it("rejects a missing or backwards custom range with a message instead of a request", () => { + const missing = { ...base, preset: -1 as const, start: "", end: "2026-10-02T12:00" }; + const backwards = { ...base, preset: -1 as const, start: "2026-10-02T12:00", end: "2026-10-02T10:00" }; + expect(runRequest(missing)).toBe("Choose a start and end time"); + expect(runRequest(backwards)).toBe("Start time must be before end time"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/RunNowDialog.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunNowDialog.tsx new file mode 100644 index 00000000000..45a8ae06040 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunNowDialog.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; + +import type { Lens } from "../../model/types"; + +export interface RunWindow { + agent_name?: string; + start?: string; + end?: string; + lookback_hours?: number; +} + +const PRESETS = [ + { label: "Since last run", hours: null }, + { label: "Last hour", hours: 1 }, + { label: "Last 24h", hours: 24 }, + { label: "Last 7d", hours: 168 }, + { label: "Custom", hours: -1 }, +] as const; + +const localInput = (date: Date) => + new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); + +export interface RunChoice { + preset: (typeof PRESETS)[number]["hours"]; + agent: string; + saved: string; + start: string; + end: string; +} + +export function runRequest({ preset, agent, saved, start, end }: RunChoice): RunWindow | string { + const agentPart = agent.trim() && agent.trim() !== saved ? { agent_name: agent.trim() } : {}; + if (preset === null) return agentPart; + if (preset > 0) return { ...agentPart, lookback_hours: preset }; + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) return "Choose a start and end time"; + if (startMs >= endMs) return "Start time must be before end time"; + return { ...agentPart, start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString() }; +} + +export function RunNowDialog({ + lens, + agents, + busy, + onClose, + onRun, +}: { + lens: Lens; + agents: readonly string[]; + busy: boolean; + onClose: () => void; + onRun: (request: RunWindow) => Promise; +}) { + const now = new Date(); + const [preset, setPreset] = useState<(typeof PRESETS)[number]["hours"]>(null); + const [agent, setAgent] = useState(lens.settings.agent_name ?? ""); + const [start, setStart] = useState(localInput(new Date(now.getTime() - 3_600_000))); + const [end, setEnd] = useState(localInput(now)); + const [error, setError] = useState(""); + const submit = async () => { + const choice: RunChoice = { preset, agent, saved: lens.settings.agent_name ?? "", start, end }; + const request = runRequest(choice); + if (typeof request === "string") { + setError(request); + return; + } + setError(""); + await onRun(request); + }; + return ( + !open && !busy && onClose()}> + + + Run now + + Runs once with these choices. The saved schedule and settings stay the same. + + +
+ +
+ Traces to review +
+ {PRESETS.map((p) => ( + + ))} +
+ {preset === -1 && ( +
+ + +
+ )} +
+ {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/model/inbox.test.ts b/ui/litellm-dashboard/src/components/lens/model/inbox.test.ts new file mode 100644 index 00000000000..2114047ab3a --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/model/inbox.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + ALL_AGENTS, + UNKNOWN_AGENT, + filterInbox, + findingAgents, + inboxAgents, + inboxRows, + modelsUsed, + scheduleLabel, + stepLine, + type Step, +} from "./inbox"; +import type { Finding, Lens } from "./types"; + +const finding = (overrides: Partial): Finding => + ({ + id: "f", + check_id: "c", + kind: "issue", + status: "open", + title: "Tool errors swallowed", + description: "", + suggestion: "Retry on 5xx", + priority: "medium", + occurrences: ["run-1"], + evidence: [], + first_seen: "2026-10-01T00:00:00Z", + last_seen: "2026-10-01T00:00:00Z", + revision: 1, + ...overrides, + }) as Finding; + +const lens = ( + id: string, + agent: string, + findings: Finding[], + { settings = {}, runs = [] }: { settings?: Partial; runs?: { id: string; service: string }[] } = {}, +): Lens => + ({ + id, + findings, + jobs: [{ sample: { executions: runs } }], + next_run_at: "2026-10-03T12:10:00Z", + settings: { name: id, agent_name: agent, service: "", enabled: true, interval_minutes: 15, ...settings }, + }) as unknown as Lens; + +describe("findingAgents", () => { + it("names the agents the finding's runs were actually recorded under", () => { + const seen = lens("a", "", [], { + runs: [ + { id: "run-1", service: "support-bot" }, + { id: "run-2", service: "billing-bot" }, + ], + }); + expect(findingAgents(seen, finding({ occurrences: ["run-2", "run-1"] }))).toEqual(["billing-bot", "support-bot"]); + }); + + it("falls back to the configured agent, then to an explicit unknown, when no run says", () => { + expect(findingAgents(lens("a", "support", []), finding({}))).toEqual(["support"]); + expect(findingAgents(lens("a", "", []), finding({}))).toEqual([UNKNOWN_AGENT]); + }); +}); + +const ranked = (id: string, title: string, priority: Finding["priority"], lastSeen: string): Partial => ({ + id, + title, + priority, + last_seen: lastSeen, +}); + +describe("inboxRows", () => { + it("merges the same problem for the same agent across investigations into one row", () => { + const rows = inboxRows([ + lens("a", "support", [finding({ id: "1", occurrences: ["run-1", "run-2"] })]), + lens("b", "support", [finding({ id: "2", title: "tool errors swallowed ", occurrences: ["run-2", "run-3"] })]), + ]); + expect(rows).toHaveLength(1); + expect(rows[0].sources).toHaveLength(2); + expect(rows[0].runs).toBe(3); + }); + + it("keeps the same title for different agents as separate problems", () => { + expect(inboxRows([lens("a", "support", [finding({})]), lens("b", "billing", [finding({})])])).toHaveLength(2); + }); + + it("takes the most urgent priority and latest sighting across merged findings", () => { + const [row] = inboxRows([ + lens("a", "support", [finding({ priority: "low", last_seen: "2026-10-03T00:00:00Z" })]), + lens("b", "support", [finding({ priority: "high", last_seen: "2026-10-01T00:00:00Z" })]), + ]); + expect(row.priority).toBe("high"); + expect(row.lastSeen).toBe("2026-10-03T00:00:00Z"); + }); + + it("leaves out resolved, dismissed and pattern findings", () => { + const rows = inboxRows([ + lens("a", "support", [ + finding({ id: "1", status: "resolved", title: "a" }), + finding({ id: "2", status: "dismissed", title: "b" }), + finding({ id: "3", kind: "pattern", title: "c" }), + ]), + ]); + expect(rows).toEqual([]); + }); + + it("orders by priority, then by most recent", () => { + const rows = inboxRows([ + lens("a", "x", [ + finding(ranked("1", "old high", "high", "2026-10-01T00:00:00Z")), + finding(ranked("2", "new low", "low", "2026-10-03T00:00:00Z")), + finding(ranked("3", "new high", "high", "2026-10-02T00:00:00Z")), + ]), + ]); + expect(rows.map((r) => r.title)).toEqual(["new high", "old high", "new low"]); + }); +}); + +const step = (overrides: Partial): Step => + ({ + at: "2026-10-03T12:00:00Z", + kind: "model", + label: "Reviewed a run", + model: "gpt-5.6", + purpose: "extract", + prompt_tokens: 0, + completion_tokens: 0, + cost: 0, + ...overrides, + }) as Step; + +describe("filterInbox", () => { + const rows = inboxRows([ + lens("a", "support", [finding({ id: "1", title: "one", priority: "high" })]), + lens("b", "billing", [finding({ id: "2", title: "two", priority: "low" })]), + ]); + + it("narrows to one agent and one priority, and lists every agent once", () => { + expect(filterInbox(rows, { agent: "billing", priority: "all" }).map((r) => r.title)).toEqual(["two"]); + expect(filterInbox(rows, { agent: ALL_AGENTS, priority: "high" }).map((r) => r.title)).toEqual(["one"]); + expect(filterInbox(rows, { agent: "billing", priority: "high" })).toEqual([]); + expect(inboxAgents(rows)).toEqual(["billing", "support"]); + }); +}); + +describe("step feed helpers", () => { + it("summarizes a model call with its token count and cost", () => { + expect(stepLine(step({ prompt_tokens: 1000, completion_tokens: 200, cost: 0.0031 }))).toBe( + "Reviewed a run · 1.2k tok · $0.0031", + ); + expect(stepLine(step({ kind: "stage", label: "Grouping observations" }))).toBe("Grouping observations"); + }); + + it("lists models used, most used first, ignoring stage steps", () => { + expect( + modelsUsed([ + step({ model: "claude-sonnet-5-5" }), + step({ model: "gpt-5.6" }), + step({ model: "gpt-5.6" }), + step({ kind: "stage", model: "" }), + ]), + ).toEqual(["gpt-5.6", "claude-sonnet-5-5"]); + }); +}); + +describe("scheduleLabel", () => { + const now = Date.parse("2026-10-03T12:00:00Z"); + + it("says how often it runs and when the next run is", () => { + expect(scheduleLabel(lens("a", "x", []), now)).toBe("every 15m · next in 10m"); + expect(scheduleLabel(lens("a", "x", [], { settings: { interval_minutes: 120 } }), now)).toBe( + "every 2h · next in 10m", + ); + }); + + it("calls out paused and overdue investigations", () => { + expect(scheduleLabel(lens("a", "x", [], { settings: { enabled: false } }), now)).toBe("paused"); + expect(scheduleLabel(lens("a", "x", []), Date.parse("2026-10-03T12:30:00Z"))).toBe("every 15m · due now"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/model/inbox.ts b/ui/litellm-dashboard/src/components/lens/model/inbox.ts new file mode 100644 index 00000000000..1e2fd91175f --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/model/inbox.ts @@ -0,0 +1,123 @@ +import type { Finding, Job, Lens } from "./types"; + +export type Step = Job["steps"][number]; +export type Priority = NonNullable; + +export interface InboxRow { + key: string; + title: string; + agents: readonly string[]; + priority: Priority; + suggestion: string; + runs: number; + lastSeen: string; + sources: readonly { lens: Lens; finding: Finding }[]; +} + +export interface InboxFilter { + agent: string; + priority: Priority | "all"; +} + +export const ALL_AGENTS = "all"; +export const UNKNOWN_AGENT = "unknown agent"; + +const priorityRank = { high: 0, medium: 1, low: 2 } as const; + +export function sampledExecutions(lens: Lens) { + return lens.jobs.flatMap((job) => job.sample?.executions ?? []); +} + +export function findingAgents(lens: Lens, finding: Finding): readonly string[] { + const services = new Map(sampledExecutions(lens).map((run) => [run.id, run.service] as const)); + const seen = new Set(finding.occurrences.map((id) => services.get(id)).filter((s): s is string => !!s)); + if (seen.size) return [...seen].sort(); + const configured = lens.settings.agent_name || lens.settings.service; + return [configured || UNKNOWN_AGENT]; +} + +function groupBy(items: readonly T[], key: (item: T) => string): Map { + return items.reduce((groups, item) => { + const k = key(item); + return groups.set(k, [...(groups.get(k) ?? []), item]); + }, new Map()); +} + +export function inboxRows(lenses: readonly Lens[]): InboxRow[] { + const open = lenses.flatMap((lens) => + lens.findings + .filter((f) => f.status === "open" && f.kind === "issue") + .map((finding) => ({ lens, finding, agents: findingAgents(lens, finding) })), + ); + const grouped = groupBy(open, ({ agents, finding }) => `${agents.join(",")}::${finding.title.trim().toLowerCase()}`); + return [...grouped.entries()] + .map(([key, sources]): InboxRow => { + const best = sources.reduce((a, b) => + priorityRank[a.finding.priority ?? "medium"] <= priorityRank[b.finding.priority ?? "medium"] ? a : b, + ); + return { + key, + title: best.finding.title, + agents: best.agents, + priority: best.finding.priority ?? "medium", + suggestion: best.finding.suggestion, + runs: new Set(sources.flatMap((s) => s.finding.occurrences)).size, + lastSeen: sources.map((s) => s.finding.last_seen).reduce((a, b) => (Date.parse(a) > Date.parse(b) ? a : b)), + sources: sources.map(({ lens, finding }) => ({ lens, finding })), + }; + }) + .sort( + (a, b) => priorityRank[a.priority] - priorityRank[b.priority] || Date.parse(b.lastSeen) - Date.parse(a.lastSeen), + ); +} + +export function filterInbox(rows: readonly InboxRow[], { agent, priority }: InboxFilter): InboxRow[] { + const agentMatches = (row: InboxRow) => agent === ALL_AGENTS || row.agents.includes(agent); + const priorityMatches = (row: InboxRow) => priority === "all" || row.priority === priority; + return rows.filter((row) => agentMatches(row) && priorityMatches(row)); +} + +export function inboxAgents(rows: readonly InboxRow[]): string[] { + return [...new Set(rows.flatMap((row) => row.agents))].sort(); +} + +export function stepLine(step: Step): string { + if (step.kind !== "model") return step.label; + const tokens = step.prompt_tokens + step.completion_tokens; + const size = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k tok` : `${tokens} tok`; + return `${step.label} · ${size} · $${step.cost.toFixed(4)}`; +} + +export function modelsUsed(steps: readonly Step[]): string[] { + const counts = groupBy( + steps.filter((s) => s.kind === "model" && s.model), + (s) => s.model, + ); + return [...counts.entries()].sort((a, b) => b[1].length - a[1].length).map(([model]) => model); +} + +function nextRunLabel(msUntil: number): string { + if (msUntil <= 0) return "due now"; + if (msUntil < 60_000) return "next in <1m"; + return `next in ${Math.ceil(msUntil / 60_000)}m`; +} + +export function scheduleLabel(lens: Lens, now: number): string { + if (!lens.settings.enabled) return "paused"; + const every = lens.settings.interval_minutes; + const cadence = every % 60 === 0 ? `every ${every / 60}h` : `every ${every}m`; + const next = Date.parse(lens.next_run_at) - now; + return `${cadence} · ${nextRunLabel(next)}`; +} + +const WINDOW_FORMAT: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}; + +export function windowLabel(job: Job): string { + const fmt = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT); + return `${fmt(job.start)} → ${fmt(job.end)}`; +} diff --git a/ui/litellm-dashboard/src/components/lens/model/progress.test.ts b/ui/litellm-dashboard/src/components/lens/model/progress.test.ts index 06d396d3458..26ef40431b7 100644 --- a/ui/litellm-dashboard/src/components/lens/model/progress.test.ts +++ b/ui/litellm-dashboard/src/components/lens/model/progress.test.ts @@ -24,6 +24,8 @@ const coverage: Job["coverage"] = { const job: Job = { assessments: [], + steps: [], + trigger: "schedule", coverage, attempts: 0, error: "", diff --git a/ui/litellm-dashboard/src/components/lens/model/status.test.ts b/ui/litellm-dashboard/src/components/lens/model/status.test.ts index a43061a16f4..b77490e981e 100644 --- a/ui/litellm-dashboard/src/components/lens/model/status.test.ts +++ b/ui/litellm-dashboard/src/components/lens/model/status.test.ts @@ -18,6 +18,8 @@ const coverage: Job["coverage"] = { const job: Job = { assessments: [], + steps: [], + trigger: "schedule", coverage, attempts: 0, error: "", diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetupDialog.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetupDialog.integration.test.tsx index 20406f0be34..3ea43016929 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetupDialog.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetupDialog.integration.test.tsx @@ -133,18 +133,18 @@ describe("Lens setup", () => { await user.click(screen.getByRole("button", { name: /Add your own/ })); await user.click(screen.getByRole("button", { name: "Remove check 3" })); await user.click(screen.getByRole("button", { name: "Continue" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); expect(screen.getByText("1 matching run")).toBeInTheDocument(); expect(screen.getByText("Research report")).toBeInTheDocument(); expect(screen.getByText(/2026-09-30/)).toBeInTheDocument(); expect(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" })).not.toBeVisible(); - await user.click(screen.getByRole("button", { name: "Run investigation" })); + await user.click(screen.getByRole("button", { name: "Run and monitor" })); const expected = { name: "Research follow-up", service: "", agent_name: "", filters: [{ key: "swarm", value: "research" }], - enabled: false, + enabled: true, sample_size: null, model: "analysis", monthly_budget: 100, @@ -323,11 +323,11 @@ it.each(["new", "duplicate"] as const)("blocks a %s investigation until its mode await user.click(screen.getByRole("button", { name: "Continue" })); await user.click(screen.getByRole("button", { name: "Continue" })); expect(await screen.findByText("1 matching run")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled(); + expect(screen.getByRole("button", { name: mode === "new" ? "Run and monitor" : "Run investigation" })).toBeDisabled(); expect(save).not.toHaveBeenCalled(); }); -it("shows optional budget and repeat controls only under advanced options and saves their values", async () => { +it("keeps a duplicated investigation's schedule off and saves the interval once switched on", async () => { const user = userEvent.setup(); const save = vi.fn().mockResolvedValue(undefined); renderWithProviders( @@ -343,17 +343,39 @@ it("shows optional budget and repeat controls only under advanced options and sa await user.click(screen.getByRole("button", { name: "Continue" })); await user.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled()); + expect(screen.getByRole("checkbox", { name: "Keep watching for new traces" })).not.toBeChecked(); await user.click(screen.getByText("Advanced options")); - expect(screen.getByRole("checkbox", { name: "Repeat this investigation" })).not.toBeChecked(); fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" }), { target: { value: "8" } }); - await user.click(screen.getByRole("checkbox", { name: "Repeat this investigation" })); - fireEvent.change(screen.getByRole("spinbutton", { name: "Repeat every" }), { target: { value: "120" } }); + await user.click(screen.getByRole("checkbox", { name: "Keep watching for new traces" })); + fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "120" } }); await user.click(screen.getByRole("button", { name: "Run and monitor" })); expect(save).toHaveBeenCalledWith( expect.objectContaining({ enabled: true, interval_minutes: 120, monthly_budget: 8 }), ); }); +it("watches new investigations every 15 minutes by default, outside advanced options", async () => { + const user = userEvent.setup(); + const save = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + , + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + const watching = await screen.findByRole("checkbox", { name: "Keep watching for new traces" }); + expect(watching).toBeChecked(); + expect(watching).toBeVisible(); + await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Run and monitor" })); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ enabled: true, interval_minutes: 15 })); +}); + it("does not silently analyze everything after individual selection is enabled", async () => { const user = userEvent.setup(); vi.mocked(apiClient.post).mockResolvedValue({ @@ -510,8 +532,8 @@ describe("Watch for", () => { expect(tile("unhappy")).toHaveAttribute("aria-pressed", "false"); expect(tile("looping")).toHaveAttribute("aria-pressed", "true"); await user.click(screen.getByRole("button", { name: "Continue" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled()); - await user.click(screen.getByRole("button", { name: "Run investigation" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Run and monitor" })); const saved = (save.mock.calls[0][0] as Settings).checks.map((check) => check.id); expect(saved).toEqual(["watch_unsolved", "watch_blocked", "watch_looping"]); }); @@ -558,7 +580,7 @@ describe("Watch for", () => { ); await user.click(screen.getByRole("button", { name: "Continue" })); await user.click(screen.getByRole("button", { name: "Continue" })); - expect(await screen.findByRole("button", { name: "Run investigation" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Run and monitor" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Back" })); for (const name of ["unsolved", "blocked", "unhappy"]) await user.click(tile(name)); await user.click(screen.getByRole("button", { name: "Continue" })); diff --git a/ui/litellm-dashboard/src/components/lens/setup/investigationSchema.ts b/ui/litellm-dashboard/src/components/lens/setup/investigationSchema.ts index 2d3efc9abd2..78db823bab8 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/investigationSchema.ts +++ b/ui/litellm-dashboard/src/components/lens/setup/investigationSchema.ts @@ -193,8 +193,8 @@ export function investigationDefaults( questions: (initial?.checks ?? []).filter((check) => !isWatch(check)), selectedModel: initial?.model ?? null, budget: initial?.monthly_budget ?? 100, - repeat: mode === "edit" && !!initial?.enabled, - interval: initial?.interval_minutes ?? 30, + repeat: mode === "new" || (mode === "edit" && !!initial?.enabled), + interval: initial?.interval_minutes ?? 15, manualSelection: !!initial?.execution_ids?.length, }; } diff --git a/ui/litellm-dashboard/src/components/lens/setup/steps/RunStep.tsx b/ui/litellm-dashboard/src/components/lens/setup/steps/RunStep.tsx index 35bdd4b3822..7f177b8262d 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/steps/RunStep.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/steps/RunStep.tsx @@ -34,6 +34,26 @@ export function RunStep({ const repeat = useWatch({ control, name: "repeat" }); return ( <> +
+ + {repeat && ( + ( + + )} + /> + )} + {errors.interval?.message && ( +

+ {errors.interval.message} +

+ )} +
Advanced options
@@ -87,30 +107,6 @@ export function RunStep({

)} -
- - {repeat && ( - ( - - )} - /> - )} - {errors.interval?.message && ( -

- {errors.interval.message} -

- )} -
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3ed4103a227..54e43f4aac3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8968,6 +8968,23 @@ export interface paths { patch?: never; trace?: never; }; + "/lens/watch-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Watch All */ + post: operations["watch_all_lens_watch_all_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/lens/worker/claim": { parameters: { query?: never; @@ -33344,6 +33361,17 @@ export interface components { * @enum {string} */ status: "queued" | "running" | "completed" | "failed" | "cancelled"; + /** + * Steps + * @default [] + */ + steps: components["schemas"]["Step"][]; + /** + * Trigger + * @default schedule + * @enum {string} + */ + trigger: "schedule" | "manual"; /** Worker Id */ worker_id?: string | null; }; @@ -44240,9 +44268,15 @@ export interface components { }; /** RunRequest */ RunRequest: { + /** Agent Name */ + agent_name?: string | null; + /** End */ + end?: string | null; /** Lookback Hours */ lookback_hours?: number | null; settings?: components["schemas"]["LensSettings"] | null; + /** Start */ + start?: string | null; }; /** SCIMEnterpriseUser */ SCIMEnterpriseUser: { @@ -45573,6 +45607,46 @@ export interface components { */ user_ids: string[]; }; + /** Step */ + Step: { + /** + * At + * Format: date-time + */ + at: string; + /** + * Completion Tokens + * @default 0 + */ + completion_tokens: number; + /** + * Cost + * @default 0 + */ + cost: number; + /** + * Kind + * @enum {string} + */ + kind: "stage" | "model" | "error"; + /** Label */ + label: string; + /** + * Model + * @default + */ + model: string; + /** + * Prompt Tokens + * @default 0 + */ + prompt_tokens: number; + /** + * Purpose + * @default + */ + purpose: string; + }; /** * SuccessfulKeyUpdate * @description Successfully updated key with its updated information @@ -49334,6 +49408,25 @@ export interface components { } & { [key: string]: unknown; }; + /** WatchAllResult */ + WatchAllResult: { + /** + * Skipped + * @default [] + */ + skipped: components["schemas"]["WatchSkipped"][]; + /** Watching */ + watching: string[]; + }; + /** WatchSkipped */ + WatchSkipped: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Reason */ + reason: string; + }; /** * WebSearchInterceptionSettings * @description Configuration for server-side web search interception @@ -62274,6 +62367,26 @@ export interface operations { }; }; }; + watch_all_lens_watch_all_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WatchAllResult"]; + }; + }; + }; + }; claim_lens_worker_claim_post: { parameters: { query?: {