mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(lens): always-on investigations with findings and investigations tables (#44418)
* feat(lens): record run steps, trigger and exact run windows on investigations * feat(lens): scan only traces since the last run and keep a capped step log * feat(lens): log each analysis model call with its model, tokens and cost * feat(lens): accept agent and time window on run now and add turn-all-on * test(lens): cover new-traces-only windows, manual runs and the step cap * test(lens): cover run now overrides and turning paused investigations on * chore(ui): regenerate api types for lens run steps and run now options * feat(lens): group open findings into one row per problem with agent filters * test(lens): cover the findings table grouping, filters and schedule labels * feat(lens): add a findings table across all investigations * feat(lens): show a live step feed with the model behind each call * feat(lens): offer turning all paused investigations on * feat(lens): let run now pick an agent and time window * test(lens): cover run now request building * feat(lens): show the step feed and run now dialog on an investigation * feat(lens): open run now choices instead of running immediately * feat(lens): open findings first and peek a finding without leaving the table * feat(lens): fold investigation actions into the findings toolbar * feat(lens): show each investigation's schedule and open findings * feat(lens): name the agent on a finding * feat(lens): keep new investigations watching every 15 minutes by default * feat(lens): show the watch schedule outside advanced options * feat(lens): send run now options and turn-all-on from the dashboard * test(lens): give demo runs steps and a trigger * feat(lens): let the findings table fill the screen * test(lens): add steps and trigger to progress fixtures * test(lens): add steps and trigger to status fixtures * test(lens): cover the default watch schedule in setup * test(lens): cover run now choices from an investigation * test(lens): open saved investigations from the manage view * feat(lens): use one tab bar for traces, findings and investigations * feat(lens): place page actions on the lens tab row * feat(lens): drop the nested tabs and edit investigations in place * feat(lens): show investigations as a table with run now and edit * feat(lens): name each findings row for screen readers * test(lens): open saved investigation links on findings * test(lens): reach findings and investigations from the top tabs * fix(lens): mark run now jobs manual and keep them from moving the scheduled scan * fix(lens): keep run now since-last-run windows even with an agent override * test(lens): cover that manual runs never skip scheduled traces * test(lens): cover run now windows with agent and lookback overrides * fix(lens): group findings without Map.groupBy and expose sampled runs * fix(lens): open older findings and review every merged copy from one row * fix(lens): hide edit and run now from read-only viewers * chore(lens): drop restating comments from the findings table * chore(lens): drop restating comments from the step feed * chore(lens): drop restating comments from the paused banner * chore(lens): drop restating comments from header actions * chore(lens): drop restating comments from run now * test(lens): cover merged findings and read-only investigation rows * fix(lens): record a model step even when the response has no usage * test(lens): cover model steps with and without reported usage * fix(lens): keep a merged finding open when one of its updates fails * refactor(lens): accept update results from the investigations view * refactor(lens): accept update results in investigation actions * test(lens): cover retrying a merged finding after a failed update
This commit is contained in:
parent
f445e466b4
commit
dd31692282
33 changed files with 1703 additions and 163 deletions
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(<LensPage />, { 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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(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({
|
|||
<TabsTrigger value="traces" className="px-3">
|
||||
Traces
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="findings" className="px-3">
|
||||
Findings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="investigations" className="px-3">
|
||||
Investigations
|
||||
</TabsTrigger>
|
||||
|
|
@ -102,19 +105,23 @@ function LensContent({
|
|||
onDemo={activeTab === "traces" ? openDemo : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="investigations" keepMounted={!!demo}>
|
||||
{demo || isProxyAdminTierRole(userRole) ? (
|
||||
<InvestigationsView
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === "investigations" ? openDemo : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Investigations require proxy administrator access. You can still view your traces.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
{(["findings", "investigations"] as const).map((view) => (
|
||||
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
|
||||
{demo || isProxyAdminTierRole(userRole) ? (
|
||||
<InvestigationsView
|
||||
view={view}
|
||||
active={activeTab === view}
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === view ? openDemo : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Investigations require proxy administrator access. You can still view your traces.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</main>
|
||||
</LensPreviewTarget.Provider>
|
||||
|
|
|
|||
|
|
@ -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<KeyPage>;
|
||||
keyInfo(keyId: string): Promise<KeyInfo>;
|
||||
saveLens(id: string | undefined, settings: Settings): Promise<Lens>;
|
||||
startRun(lensId: string): Promise<void>;
|
||||
startRun(lensId: string, request?: RunWindow): Promise<void>;
|
||||
watchAll(): Promise<components["schemas"]["WatchAllResult"]>;
|
||||
cancelRun(lensId: string): Promise<void>;
|
||||
reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise<void>;
|
||||
registerWorker(analysisKeyId: string | null): Promise<WorkerCreated>;
|
||||
|
|
@ -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<Lens>(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<components["schemas"]["WatchAllResult"]>("/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 } }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<SheetHeader>
|
||||
<SheetTitle className="pr-8 text-xl leading-snug">{finding.title}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{agents.length > 0 && <span className="font-medium text-foreground">{agents.join(", ")} · </span>}
|
||||
{finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} ·{" "}
|
||||
{finding.occurrences?.length ?? 0} linked {finding.occurrences?.length === 1 ? "run" : "runs"}
|
||||
</SheetDescription>
|
||||
|
|
|
|||
|
|
@ -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<T extends string>({
|
||||
label,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
width,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
items: { value: T; label: string }[];
|
||||
onChange: (value: T) => void;
|
||||
width: string;
|
||||
}) {
|
||||
return (
|
||||
<Select items={items} value={value} onValueChange={(next: T | null) => next !== null && onChange(next)}>
|
||||
<SelectTrigger size="sm" className={cn("h-7 text-[12px]", width)} aria-label={label}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsInbox({ lenses, onOpen }: { lenses: readonly Lens[]; onOpen: (row: InboxRow) => void }) {
|
||||
const [agent, setAgent] = useState(ALL_AGENTS);
|
||||
const [priority, setPriority] = useState<Priority | "all">("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 (
|
||||
<div
|
||||
className="flex min-h-[420px] flex-1 flex-col overflow-hidden border-y border-border bg-card"
|
||||
data-testid="findings-inbox"
|
||||
>
|
||||
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-1.5">
|
||||
<FilterSelect
|
||||
label="Filter by agent"
|
||||
value={agent}
|
||||
items={agentItems}
|
||||
onChange={setAgent}
|
||||
width="min-w-[150px]"
|
||||
/>
|
||||
<FilterSelect
|
||||
label="Filter by priority"
|
||||
value={priority}
|
||||
items={PRIORITY_ITEMS}
|
||||
onChange={setPriority}
|
||||
width="min-w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table aria-label="Findings" className="w-full min-w-[900px] table-fixed border-collapse text-left">
|
||||
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
|
||||
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
|
||||
<th className={`w-[96px] ${TH}`}>Priority</th>
|
||||
<th className={TH}>Finding</th>
|
||||
<th className={`w-[180px] ${TH}`}>Agent</th>
|
||||
<th className={`w-[64px] ${TH_NUM}`}>Runs</th>
|
||||
<th className={`w-[96px] ${TH}`}>Last seen</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<td className="px-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<span className={cn("size-1.5 rounded-full", PRIORITY_DOT[row.priority])} />
|
||||
{row.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3" title={row.suggestion ? `Fix: ${row.suggestion}` : undefined}>
|
||||
<span className="block truncate text-foreground">{row.title}</span>
|
||||
</td>
|
||||
<td className="truncate px-3 text-muted-foreground" title={row.agents.join(", ")}>
|
||||
{row.agents.join(", ")}
|
||||
</td>
|
||||
<td className="px-3 text-right font-mono tabular-nums text-foreground">{row.runs}</td>
|
||||
<td
|
||||
className="px-3 font-mono text-[11px] tabular-nums text-muted-foreground"
|
||||
title={formatActivityTimestamp(row.lastSeen)}
|
||||
>
|
||||
{agoLabel(Date.parse(row.lastSeen), now)}
|
||||
</td>
|
||||
<td>
|
||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<div className="py-16 text-center text-[12px] text-muted-foreground">
|
||||
{all.length === 0
|
||||
? "No open findings yet. New problems show up here as soon as an investigation spots them."
|
||||
: "No findings match these filters."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
|
||||
{rows.length} {rows.length === 1 ? "finding" : "findings"}
|
||||
{rows.length !== all.length && ` of ${all.length}`}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = <div className="flex flex-wrap items-center gap-3">{children}</div>;
|
||||
if (target === null) return null;
|
||||
return target ? createPortal(actions, target) : actions;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section aria-label="Saved investigations" className="space-y-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search investigations"
|
||||
placeholder="Search investigations"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
|
||||
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-1.5">
|
||||
<div className="relative w-full max-w-[380px]">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search investigations"
|
||||
placeholder="Search investigations"
|
||||
className="h-7 pl-8 text-[12px]"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y border-y">
|
||||
{shown.map((lens) => (
|
||||
<ListRow
|
||||
key={lens.id}
|
||||
onClick={() => 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]"
|
||||
>
|
||||
<div className="col-start-1 row-start-1 min-w-0">
|
||||
<p className="text-sm font-medium">{lens.settings.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{scopeLabel(lens.settings)}</p>
|
||||
</div>
|
||||
<div className="col-start-1 row-start-2 text-xs sm:col-start-2 sm:row-start-1 sm:text-right">
|
||||
<p
|
||||
data-state={lens.jobs[0]?.status === "failed" ? "failed" : "other"}
|
||||
className="data-[state=failed]:text-destructive data-[state=other]:text-muted-foreground"
|
||||
>
|
||||
{lensStatus(lens, connected)}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{lens.jobs[0] ? runTime(lens.jobs[0].created_at) : "Not run yet"}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="col-start-2 row-start-1 size-4 text-muted-foreground sm:col-start-3" />
|
||||
</ListRow>
|
||||
))}
|
||||
{!shown.length && <p className="py-8 text-sm text-muted-foreground">No investigations match your search.</p>}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table aria-label="Investigations" className="w-full min-w-[900px] table-fixed border-collapse text-left">
|
||||
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
|
||||
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
|
||||
<th className={TH}>Investigation</th>
|
||||
<th className={`w-[180px] ${TH}`}>Agent</th>
|
||||
<th className={`w-[220px] ${TH}`}>Schedule</th>
|
||||
<th className={`w-[150px] ${TH}`}>Last run</th>
|
||||
<th className={`w-[64px] ${TH_NUM}`}>Open</th>
|
||||
<th className="w-[92px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((lens) => {
|
||||
const latest = lens.jobs[0];
|
||||
const failed = latest?.status === "failed";
|
||||
const open = lens.findings.filter((f) => f.status === "open").length;
|
||||
return (
|
||||
<tr
|
||||
key={lens.id}
|
||||
onClick={readOnly ? undefined : () => 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",
|
||||
)}
|
||||
>
|
||||
<td className="truncate px-3 text-foreground">{lens.settings.name}</td>
|
||||
<td className="truncate px-3 text-muted-foreground">{scopeLabel(lens.settings)}</td>
|
||||
<td className="px-3 font-mono text-[11px]" data-testid="investigation-schedule">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5",
|
||||
lens.settings.enabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
lens.settings.enabled ? "bg-[#3b5bfd]" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
{scheduleLabel(lens, now)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="px-3 font-mono text-[11px]"
|
||||
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
|
||||
>
|
||||
<span className={failed ? "text-destructive" : "text-muted-foreground"}>
|
||||
{lensStatus(lens, connected).toLowerCase()}
|
||||
</span>
|
||||
{latest && (
|
||||
<span className="text-muted-foreground"> · {agoLabel(Date.parse(latest.created_at), now)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 text-right font-mono tabular-nums text-foreground">{open}</td>
|
||||
<td className="pr-2">
|
||||
{!readOnly && (
|
||||
<span className="flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Run ${lens.settings.name} now`}
|
||||
title="Run now"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRunNow(lens.id);
|
||||
}}
|
||||
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-6 items-center justify-center text-muted-foreground/60"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!shown.length && (
|
||||
<div className="py-16 text-center text-[12px] text-muted-foreground">
|
||||
No investigations match your search.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
|
||||
{shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "}
|
||||
{lenses.filter((l) => l.settings.enabled).length} watching
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,21 +25,26 @@ export function InvestigationNavigation({
|
|||
setEditing: (mode: "new") => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<header className={lens ? "flex flex-wrap items-center justify-between gap-2" : "flex items-center gap-2"}>
|
||||
{lens ? (
|
||||
<Button variant="ghost" size="sm" className="-ml-3" onClick={() => selectLens(null)}>
|
||||
<ArrowLeft className="size-4" /> All investigations
|
||||
<ArrowLeft className="size-4" /> Back
|
||||
</Button>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold">Investigations</h2>
|
||||
)}
|
||||
) : null}
|
||||
{showActions && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" disabled={!activityReady} onClick={() => setWorkerSetup(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!activityReady}
|
||||
onClick={() => setWorkerSetup(true)}
|
||||
title={connected ? "Worker connected" : "Connect worker"}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<StatusDot state={connected ? "ok" : "warn"} />
|
||||
{connected ? "Worker connected" : "Connect worker"}
|
||||
{connected ? "Worker" : "Connect worker"}
|
||||
</Button>
|
||||
<Button variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
|
||||
<Button size="sm" variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> New investigation
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<typeof
|
|||
}
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
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(<InvestigationsView accessToken="test" />);
|
||||
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(<InvestigationsView accessToken="test" />);
|
||||
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(<InvestigationsView accessToken="test" view="investigations" readOnly />);
|
||||
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(<InvestigationsView accessToken="test" />);
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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<InboxRow | null>(null);
|
||||
const [runNowId, setRunNowId] = useState<string | null>(null);
|
||||
const [skipped, setSkipped] = useState<readonly { id: string; name: string; reason: string }[]>([]);
|
||||
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<boolean> => {
|
||||
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 (
|
||||
<section aria-label="Investigations" className="w-full min-w-0 space-y-6">
|
||||
{!showEmpty && (
|
||||
<section aria-label="Investigations" className="flex w-full min-w-0 flex-1 flex-col gap-3">
|
||||
{showDetailNav && (
|
||||
<InvestigationNavigation
|
||||
lens={lens}
|
||||
showActions={showActions}
|
||||
|
|
@ -173,12 +223,51 @@ export function InvestigationsView({
|
|||
onDemo={activity.isSuccess && !ready ? onDemo : undefined}
|
||||
/>
|
||||
)}
|
||||
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} />}
|
||||
{!selected && lenses.length > 0 && (
|
||||
<InvestigationList lenses={lenses} connected={connected} onSelect={selectLens} />
|
||||
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} className="py-2 text-xs" />}
|
||||
{showTables && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
{active && (
|
||||
<HeaderActions>
|
||||
{!readOnly && (
|
||||
<WatchAllBanner
|
||||
lenses={lenses}
|
||||
busy={busy}
|
||||
skipped={skipped}
|
||||
onWatchAll={() =>
|
||||
update(async (api) => {
|
||||
const result = await api.watchAll();
|
||||
setSkipped(result.skipped);
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<InvestigationNavigation
|
||||
lens={undefined}
|
||||
showActions={showActions}
|
||||
activityReady={activityReady}
|
||||
connected={connected}
|
||||
ready={ready}
|
||||
selectLens={selectLens}
|
||||
setWorkerSetup={setWorkerSetup}
|
||||
setEditing={setEditing}
|
||||
/>
|
||||
</HeaderActions>
|
||||
)}
|
||||
{view === "findings" ? (
|
||||
<FindingsInbox lenses={lenses} onOpen={openFinding} />
|
||||
) : (
|
||||
<InvestigationList
|
||||
lenses={lenses}
|
||||
connected={connected}
|
||||
readOnly={readOnly}
|
||||
onEdit={editFromTable}
|
||||
onRunNow={(id) => setRunNowId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{missingSelection && !lens && <InvestigationMissing selectLens={selectLens} />}
|
||||
{lens && (
|
||||
{showMissing && <InvestigationMissing selectLens={selectLens} />}
|
||||
{lens && !peek && (
|
||||
<InvestigationDetail
|
||||
lens={lens}
|
||||
readOnly={readOnly}
|
||||
|
|
@ -189,6 +278,7 @@ export function InvestigationsView({
|
|||
update={update}
|
||||
connected={connected}
|
||||
results={results}
|
||||
agents={Array.isArray(agents.data) ? agents.data : []}
|
||||
/>
|
||||
)}
|
||||
{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 && (
|
||||
<RunNowDialog
|
||||
lens={lenses.find((l) => 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 && (
|
||||
<WorkerDialog
|
||||
accessToken={accessToken}
|
||||
|
|
@ -235,11 +343,12 @@ export function InvestigationsView({
|
|||
/>
|
||||
)}
|
||||
<FindingSheet
|
||||
finding={finding}
|
||||
sampledRuns={sampledRuns}
|
||||
finding={sheetFinding}
|
||||
agents={sheetAgents}
|
||||
sampledRuns={sheetRuns}
|
||||
readOnly={readOnly}
|
||||
busy={busy}
|
||||
onClose={() => setFindingId(null)}
|
||||
onClose={closeFinding}
|
||||
changeFinding={changeFinding}
|
||||
onEvidence={(value) => {
|
||||
setRequestOffset(0);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-1 px-3 py-2 text-left hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{job.trigger === "manual" ? "manual run" : "scheduled run"}
|
||||
</span>
|
||||
<span>{windowLabel(job)}</span>
|
||||
<span>
|
||||
{calls} model {calls === 1 ? "call" : "calls"} · ${job.cost.toFixed(4)}
|
||||
</span>
|
||||
{models.length > 0 && (
|
||||
<span data-testid="step-feed-models">
|
||||
using <span className="text-foreground">{models.join(", ")}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && (
|
||||
<ol className="max-h-56 space-y-0.5 overflow-y-auto border-t px-3 py-2" data-testid="step-feed">
|
||||
{steps.length === 0 && <li className="text-muted-foreground">Waiting for the worker to start…</li>}
|
||||
{[...steps].reverse().map((step, index) => (
|
||||
<li
|
||||
key={`${step.at}-${index}`}
|
||||
className={cn(
|
||||
"grid grid-cols-[1rem_4.5rem_minmax(0,1fr)_auto] items-baseline gap-2",
|
||||
index === 0 && "motion-safe:animate-in motion-safe:fade-in",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className={STEP_TONE[step.kind]}>
|
||||
{index === 0 && step.kind === "stage" ? "▸" : STEP_MARK[step.kind]}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{new Date(step.at).toLocaleTimeString(undefined, { hour12: false })}
|
||||
</span>
|
||||
<span className={cn("truncate", STEP_TONE[step.kind])}>{stepLine(step)}</span>
|
||||
<span className="truncate text-muted-foreground">{step.model}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<span
|
||||
role="status"
|
||||
className="text-xs text-muted-foreground"
|
||||
title={skipped.map((s) => `${s.name}: ${s.reason}`).join("\n")}
|
||||
>
|
||||
{paused} paused · {skipped.length === 1 ? `${skipped[0].name} needs a fix` : `${skipped.length} need a fix`}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span role="status" className="text-xs text-muted-foreground">
|
||||
{paused} paused ·{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onWatchAll}
|
||||
className="font-medium text-foreground underline-offset-2 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Turn all on
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
update: (write: LensWrite) => Promise<unknown>;
|
||||
onRunNow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -59,7 +61,7 @@ export function InvestigationActions({
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button disabled={busy || !!active || !ready} onClick={() => update((api) => api.startRun(lens.id))}>
|
||||
<Button disabled={busy || !!active || !ready} onClick={onRunNow}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
update: (write: LensWrite) => Promise<unknown>;
|
||||
connected: boolean;
|
||||
results: ReturnType<typeof useInvestigationResults>;
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -102,6 +109,19 @@ export function InvestigationDetail({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{active && <StepFeed job={active} />}
|
||||
{runNow && (
|
||||
<RunNowDialog
|
||||
lens={lens}
|
||||
agents={agents}
|
||||
busy={busy}
|
||||
onClose={() => setRunNow(false)}
|
||||
onRun={async (request) => {
|
||||
await update((api) => api.startRun(lens.id, request));
|
||||
setRunNow(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{job?.error && <InvestigationFailure job={job} connected={connected} />}
|
||||
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
}) {
|
||||
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 (
|
||||
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run now</DialogTitle>
|
||||
<DialogDescription>
|
||||
Runs once with these choices. The saved schedule and settings stay the same.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
Agent
|
||||
<Input
|
||||
list="run-now-agents"
|
||||
value={agent}
|
||||
placeholder="All agents"
|
||||
onChange={(e) => setAgent(e.target.value)}
|
||||
aria-label="Agent"
|
||||
/>
|
||||
<datalist id="run-now-agents">
|
||||
{agents.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Traces to review</legend>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
aria-pressed={preset === p.hours}
|
||||
onClick={() => setPreset(p.hours)}
|
||||
className="rounded-md bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:ring-[1.5px] aria-pressed:ring-foreground aria-pressed:ring-inset"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preset === -1 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="grid gap-1 text-xs text-muted-foreground">
|
||||
From
|
||||
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-muted-foreground">
|
||||
To
|
||||
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void submit()}>
|
||||
Run now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
181
ui/litellm-dashboard/src/components/lens/model/inbox.test.ts
Normal file
181
ui/litellm-dashboard/src/components/lens/model/inbox.test.ts
Normal file
|
|
@ -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>): 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<Lens["settings"]>; 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<Finding> => ({
|
||||
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>): 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");
|
||||
});
|
||||
});
|
||||
123
ui/litellm-dashboard/src/components/lens/model/inbox.ts
Normal file
123
ui/litellm-dashboard/src/components/lens/model/inbox.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { Finding, Job, Lens } from "./types";
|
||||
|
||||
export type Step = Job["steps"][number];
|
||||
export type Priority = NonNullable<Finding["priority"]>;
|
||||
|
||||
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<T>(items: readonly T[], key: (item: T) => string): Map<string, T[]> {
|
||||
return items.reduce((groups, item) => {
|
||||
const k = key(item);
|
||||
return groups.set(k, [...(groups.get(k) ?? []), item]);
|
||||
}, new Map<string, T[]>());
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ const coverage: Job["coverage"] = {
|
|||
|
||||
const job: Job = {
|
||||
assessments: [],
|
||||
steps: [],
|
||||
trigger: "schedule",
|
||||
coverage,
|
||||
attempts: 0,
|
||||
error: "",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ const coverage: Job["coverage"] = {
|
|||
|
||||
const job: Job = {
|
||||
assessments: [],
|
||||
steps: [],
|
||||
trigger: "schedule",
|
||||
coverage,
|
||||
attempts: 0,
|
||||
error: "",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<InvestigationSetupDialog
|
||||
models={["analysis"]}
|
||||
defaultModel="analysis"
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={save}
|
||||
/>,
|
||||
);
|
||||
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" }));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,26 @@ export function RunStep({
|
|||
const repeat = useWatch({ control, name: "repeat" });
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" className="size-4 rounded border-input accent-foreground" {...register("repeat")} />
|
||||
Keep watching for new traces
|
||||
</label>
|
||||
{repeat && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<DurationInput label="Check every" value={field.value} onChange={field.onChange} base="minutes" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.interval?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.interval.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<details open={!modelValid || undefined}>
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced options</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
|
|
@ -87,30 +107,6 @@ export function RunStep({
|
|||
</p>
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-input accent-foreground"
|
||||
{...register("repeat")}
|
||||
/>
|
||||
Repeat this investigation
|
||||
</label>
|
||||
{repeat && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<DurationInput label="Repeat every" value={field.value} onChange={field.onChange} base="minutes" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.interval?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.interval.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
113
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
113
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue