diff --git a/Dockerfile.heroku b/Dockerfile.heroku index a91a3642..917a4852 100644 --- a/Dockerfile.heroku +++ b/Dockerfile.heroku @@ -1,3 +1,3 @@ # Community-maintained template, not tested in CI. # Thinker: https://github.com/patrickbrandao/thinker -FROM docker.io/tmsoftbrasil/thinker:0.32 +FROM docker.io/tmsoftbrasil/thinker:0.33 diff --git a/deploy/aws/TUTORIAL.md b/deploy/aws/TUTORIAL.md index d9402123..c37e86d8 100644 --- a/deploy/aws/TUTORIAL.md +++ b/deploy/aws/TUTORIAL.md @@ -39,7 +39,7 @@ The deploy script exposes the common settings as environment variables: STACK_NAME=thinker \ SERVICE_NAME=thinker \ AWS_REGION=us-east-1 \ -IMAGE_URL=docker.io/tmsoftbrasil/thinker:0.32 \ +IMAGE_URL=docker.io/tmsoftbrasil/thinker:0.33 \ DATABASE_INSTANCE_CLASS=db.t4g.micro \ DESIRED_COUNT=1 \ ./deploy/aws/deploy.sh diff --git a/deploy/aws/deploy.sh b/deploy/aws/deploy.sh index b14e134e..41b887ed 100755 --- a/deploy/aws/deploy.sh +++ b/deploy/aws/deploy.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STACK_NAME="${STACK_NAME:-thinker}" SERVICE_NAME="${SERVICE_NAME:-thinker}" -IMAGE_URL="${IMAGE_URL:-docker.io/tmsoftbrasil/thinker:0.32}" +IMAGE_URL="${IMAGE_URL:-docker.io/tmsoftbrasil/thinker:0.33}" DESIRED_COUNT="${DESIRED_COUNT:-1}" DATABASE_INSTANCE_CLASS="${DATABASE_INSTANCE_CLASS:-db.t4g.micro}" DATABASE_DELETION_PROTECTION="${DATABASE_DELETION_PROTECTION:-false}" diff --git a/deploy/aws/thinker.yaml b/deploy/aws/thinker.yaml index 64012ac4..73663471 100644 --- a/deploy/aws/thinker.yaml +++ b/deploy/aws/thinker.yaml @@ -14,7 +14,7 @@ Parameters: ConstraintDescription: Use up to 40 lowercase letters, numbers, and hyphens, starting with a letter. ImageUrl: Type: String - Default: docker.io/tmsoftbrasil/thinker:0.32 + Default: docker.io/tmsoftbrasil/thinker:0.33 Description: Thinker container image to deploy. ContainerPort: Type: Number diff --git a/deploy/coolify/TUTORIAL.md b/deploy/coolify/TUTORIAL.md index cdfcfb87..9fd22654 100644 --- a/deploy/coolify/TUTORIAL.md +++ b/deploy/coolify/TUTORIAL.md @@ -38,7 +38,7 @@ curl -sSf https:///api/v1/health ## What Gets Provisioned -- Thinker Docker image `tmsoftbrasil/thinker:0.32`. +- Thinker Docker image `tmsoftbrasil/thinker:0.33`. - PostgreSQL 18 container. - Persistent Docker volume for PostgreSQL data. - Persistent Docker volume for compressed request recordings at `/data/request-recordings`. diff --git a/deploy/coolify/docker-compose.yml b/deploy/coolify/docker-compose.yml index 4007642f..348e7bfa 100644 --- a/deploy/coolify/docker-compose.yml +++ b/deploy/coolify/docker-compose.yml @@ -2,7 +2,7 @@ # Thinker: https://github.com/patrickbrandao/thinker services: thinker: - image: tmsoftbrasil/thinker:0.32 + image: tmsoftbrasil/thinker:0.33 restart: unless-stopped stop_grace_period: 30s environment: diff --git a/deploy/easypanel/TUTORIAL.md b/deploy/easypanel/TUTORIAL.md index ef6054c5..950bdc6b 100644 --- a/deploy/easypanel/TUTORIAL.md +++ b/deploy/easypanel/TUTORIAL.md @@ -21,7 +21,7 @@ If Thinker is available in your Easypanel template catalog: 1. Open your Easypanel project. 2. Choose **Templates**. 3. Search for **Thinker**. -4. Keep the default image `tmsoftbrasil/thinker:0.32` unless you need to pin another version. +4. Keep the default image `tmsoftbrasil/thinker:0.33` unless you need to pin another version. 5. Deploy the template. The template provisions Thinker, PostgreSQL, a persistent request-recording volume, generated secrets, and a domain proxy on port `2099`. @@ -43,7 +43,7 @@ Create the Thinker app service: 1. Click **Create Service**. 2. Choose **App**. 3. Set source type to **Docker image**. -4. Use image `tmsoftbrasil/thinker:0.32`. +4. Use image `tmsoftbrasil/thinker:0.33`. 5. Set the proxy port to `2099`. 6. Add your domain and mark it as the primary domain. @@ -97,7 +97,7 @@ Before publishing a template, smoke test the full first-run flow: ## What Gets Provisioned -- Thinker Docker image `tmsoftbrasil/thinker:0.32`. +- Thinker Docker image `tmsoftbrasil/thinker:0.33`. - PostgreSQL service. - Generated or manually configured session and encryption secrets. - HTTPS domain proxy to Thinker port `2099`. diff --git a/deploy/easypanel/template/README.md b/deploy/easypanel/template/README.md index b5e95231..232fcf24 100644 --- a/deploy/easypanel/template/README.md +++ b/deploy/easypanel/template/README.md @@ -15,7 +15,7 @@ templates/thinker/meta.yaml The template provisions: -- Thinker app service from `tmsoftbrasil/thinker:0.32`. +- Thinker app service from `tmsoftbrasil/thinker:0.33`. - PostgreSQL 18 service. - Persistent app volume for compressed request recordings. - Generated database password, Better Auth secret, and Thinker encryption key. diff --git a/deploy/easypanel/template/meta.yaml b/deploy/easypanel/template/meta.yaml index 137a4fa6..afa2755b 100644 --- a/deploy/easypanel/template/meta.yaml +++ b/deploy/easypanel/template/meta.yaml @@ -37,7 +37,7 @@ schema: appServiceImage: type: string title: App Service Image - default: tmsoftbrasil/thinker:0.32 + default: tmsoftbrasil/thinker:0.33 databaseServiceName: type: string title: Database Service Name diff --git a/deploy/fly/TUTORIAL.md b/deploy/fly/TUTORIAL.md index e029d97d..60b65e17 100644 --- a/deploy/fly/TUTORIAL.md +++ b/deploy/fly/TUTORIAL.md @@ -34,7 +34,7 @@ Environment variables: - `FLY_ORG`: Fly organization. Defaults to `personal`. - `FLY_RECORDING_BUCKET_NAME`: private Tigris bucket name. Defaults to `-recordings`. Keep the same value on reruns; changing an attached bucket requires a manual recording migration. -The script uses [`fly.toml`](fly.toml) as a template and deploys `docker.io/tmsoftbrasil/thinker:0.32`. It creates a private Tigris bucket and lets Fly stage the bucket's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets on the app; Thinker uses those credentials with the bucket name, Tigris endpoint, and `auto` region from `fly.toml`. Re-running the script keeps that bucket and the existing `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` values; rotate those secrets manually only if you intend to invalidate sessions and encrypted provider credentials. +The script uses [`fly.toml`](fly.toml) as a template and deploys `docker.io/tmsoftbrasil/thinker:0.33`. It creates a private Tigris bucket and lets Fly stage the bucket's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets on the app; Thinker uses those credentials with the bucket name, Tigris endpoint, and `auto` region from `fly.toml`. Re-running the script keeps that bucket and the existing `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` values; rotate those secrets manually only if you intend to invalidate sessions and encrypted provider credentials. ## Manual Deploy @@ -112,7 +112,7 @@ fly logs --app ## What Gets Provisioned -- Fly app running `docker.io/tmsoftbrasil/thinker:0.32`. +- Fly app running `docker.io/tmsoftbrasil/thinker:0.33`. - Fly Postgres app. - Private Tigris S3-compatible bucket for compressed request recordings. - `DATABASE_URL` secret from `fly postgres attach`. diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml index 7c3a909f..066a651e 100644 --- a/deploy/fly/fly.toml +++ b/deploy/fly/fly.toml @@ -12,7 +12,7 @@ kill_signal = "SIGINT" kill_timeout = "30s" [build] - image = "docker.io/tmsoftbrasil/thinker:0.32" + image = "docker.io/tmsoftbrasil/thinker:0.33" [env] PORT = "2099" diff --git a/deploy/gcp/deploystack.json b/deploy/gcp/deploystack.json index 0b446637..b6c7d5ee 100644 --- a/deploy/gcp/deploystack.json +++ b/deploy/gcp/deploystack.json @@ -26,7 +26,7 @@ "name": "image_url", "prompt": "Thinker image", "type": "string", - "default": "docker.io/tmsoftbrasil/thinker:0.32" + "default": "docker.io/tmsoftbrasil/thinker:0.33" }, { "name": "database_tier", diff --git a/deploy/gcp/variables.tf b/deploy/gcp/variables.tf index 2c4cb87e..97c2ddf1 100644 --- a/deploy/gcp/variables.tf +++ b/deploy/gcp/variables.tf @@ -23,7 +23,7 @@ variable "service_name" { variable "image_url" { description = "Thinker container image to deploy." type = string - default = "docker.io/tmsoftbrasil/thinker:0.32" + default = "docker.io/tmsoftbrasil/thinker:0.33" } variable "database_tier" { diff --git a/deploy/koyeb/TUTORIAL.md b/deploy/koyeb/TUTORIAL.md index 1f62b9ad..08b49730 100644 --- a/deploy/koyeb/TUTORIAL.md +++ b/deploy/koyeb/TUTORIAL.md @@ -33,7 +33,7 @@ openssl rand -hex 32 Open the Koyeb deploy link: ```text -https://app.koyeb.com/deploy?type=docker&image=docker.io%2Ftmsoftbrasil%2Fthinker%3A0.32&name=thinker&service_type=web&ports=2099%3Bhttp%3B%2F&env%5BPORT%5D=2099&env%5BDATABASE_URL%5D=postgres%3A%2F%2FUSER%3APASSWORD%40HOST%2FDB%3Fsslmode%3Drequire&env%5BBETTER_AUTH_SECRET%5D=replace-with-openssl-rand-hex-32&env%5BTHINKER_ENCRYPTION_KEY%5D=replace-with-different-openssl-rand-hex-32&env%5BBETTER_AUTH_URL%5D=https%3A%2F%2F%7B%7B+KOYEB_PUBLIC_DOMAIN+%7D%7D&env%5BBIND_ADDRESS%5D=0.0.0.0&env%5BDB_POOL_MAX%5D=8&env%5BAUTH_DB_POOL_MAX%5D=4&env%5BTHINKER_RECORDING_STORAGE%5D=s3&env%5BTHINKER_RECORDING_S3_BUCKET%5D=replace-with-private-bucket&env%5BTHINKER_RECORDING_S3_ENDPOINT%5D=https%3A%2F%2Fs3.example.com&env%5BTHINKER_RECORDING_S3_REGION%5D=us-east-1&env%5BTHINKER_RECORDING_S3_ACCESS_KEY_ID%5D=replace-with-limited-access-key&env%5BTHINKER_RECORDING_S3_SECRET_ACCESS_KEY%5D=replace-with-secret-key&env%5BTHINKER_RECORDING_S3_FORCE_PATH_STYLE%5D=false +https://app.koyeb.com/deploy?type=docker&image=docker.io%2Ftmsoftbrasil%2Fthinker%3A0.33&name=thinker&service_type=web&ports=2099%3Bhttp%3B%2F&env%5BPORT%5D=2099&env%5BDATABASE_URL%5D=postgres%3A%2F%2FUSER%3APASSWORD%40HOST%2FDB%3Fsslmode%3Drequire&env%5BBETTER_AUTH_SECRET%5D=replace-with-openssl-rand-hex-32&env%5BTHINKER_ENCRYPTION_KEY%5D=replace-with-different-openssl-rand-hex-32&env%5BBETTER_AUTH_URL%5D=https%3A%2F%2F%7B%7B+KOYEB_PUBLIC_DOMAIN+%7D%7D&env%5BBIND_ADDRESS%5D=0.0.0.0&env%5BDB_POOL_MAX%5D=8&env%5BAUTH_DB_POOL_MAX%5D=4&env%5BTHINKER_RECORDING_STORAGE%5D=s3&env%5BTHINKER_RECORDING_S3_BUCKET%5D=replace-with-private-bucket&env%5BTHINKER_RECORDING_S3_ENDPOINT%5D=https%3A%2F%2Fs3.example.com&env%5BTHINKER_RECORDING_S3_REGION%5D=us-east-1&env%5BTHINKER_RECORDING_S3_ACCESS_KEY_ID%5D=replace-with-limited-access-key&env%5BTHINKER_RECORDING_S3_SECRET_ACCESS_KEY%5D=replace-with-secret-key&env%5BTHINKER_RECORDING_S3_FORCE_PATH_STYLE%5D=false ``` In the deploy form: @@ -45,7 +45,7 @@ In the deploy form: - Leave `BETTER_AUTH_URL` as `https://{{ KOYEB_PUBLIC_DOMAIN }}`. - Leave `PORT`, `BIND_ADDRESS`, `DB_POOL_MAX`, `AUTH_DB_POOL_MAX`, and `THINKER_RECORDING_STORAGE=s3` unchanged for a single-instance deploy. Set `THINKER_RECORDING_S3_FORCE_PATH_STYLE=true` only if your object-storage provider requires path-style URLs. -The button deploys `docker.io/tmsoftbrasil/thinker:0.32` and exposes port `2099` over HTTP. +The button deploys `docker.io/tmsoftbrasil/thinker:0.33` and exposes port `2099` over HTTP. ## Open Thinker diff --git a/design-system/registry/components.json b/design-system/registry/components.json index 1da4f8f9..55dc2534 100644 --- a/design-system/registry/components.json +++ b/design-system/registry/components.json @@ -1099,7 +1099,8 @@ ], "files": [ "base.css", - "canvas.css" + "canvas.css", + "shell.css" ] }, "is-selecting": { @@ -2165,7 +2166,7 @@ "prop": "display", "value": "inline-flex", "file": "shell.css", - "line": 1073 + "line": 1080 }, { "selector": ".app.is-collapsed .user-trigger > svg", @@ -2174,7 +2175,7 @@ "prop": "display", "value": "inline-flex", "file": "shell.css", - "line": 1073 + "line": 1080 }, { "selector": ".app.is-collapsed .user-trigger", @@ -2183,21 +2184,21 @@ "prop": "padding", "value": "6px 8px", "file": "shell.css", - "line": 1082 + "line": 1089 }, { "selector": ".liquid-metal::after", "prop": "inset", "value": "-5px", "file": "shell.css", - "line": 1248 + "line": 1255 }, { "selector": ".liquid-metal::after", "prop": "padding", "value": "5px", "file": "shell.css", - "line": 1249 + "line": 1256 } ], "ghostRules": [ @@ -2208,8 +2209,8 @@ "prop": "display", "value": "revert", "file": "shell.css", - "line": 1066, - "losesTo": "shell.css:1073" + "line": 1073, + "losesTo": "shell.css:1080" }, { "selector": ".app.is-collapsed .user-trigger > svg", @@ -2218,8 +2219,8 @@ "prop": "display", "value": "revert", "file": "shell.css", - "line": 1066, - "losesTo": "shell.css:1073" + "line": 1073, + "losesTo": "shell.css:1080" }, { "selector": ".app.is-collapsed .user-trigger", @@ -2228,24 +2229,24 @@ "prop": "padding", "value": "0 10px", "file": "shell.css", - "line": 1078, - "losesTo": "shell.css:1082" + "line": 1085, + "losesTo": "shell.css:1089" }, { "selector": ".liquid-metal::after", "prop": "inset", "value": "-2px", "file": "shell.css", - "line": 1204, - "losesTo": "shell.css:1248" + "line": 1211, + "losesTo": "shell.css:1255" }, { "selector": ".liquid-metal::after", "prop": "padding", "value": "2px", "file": "shell.css", - "line": 1206, - "losesTo": "shell.css:1249" + "line": 1213, + "losesTo": "shell.css:1256" } ], "componentSheets": { diff --git a/design-system/registry/llms.txt b/design-system/registry/llms.txt index 506a17c3..8e581d4a 100644 --- a/design-system/registry/llms.txt +++ b/design-system/registry/llms.txt @@ -177,7 +177,7 @@ is-ok (base.css): is-ok is-outline (base.css): is-outline is-pulse (base.css): is-pulse is-round (canvas.css): is-round -is-selected (base.css, canvas.css): is-selected +is-selected (base.css, canvas.css, shell.css): is-selected is-selecting (canvas.css): is-selecting is-sink (canvas.css): is-sink is-success (shell.css): is-success @@ -324,18 +324,18 @@ Tabs: src/components/ui.tsx (empty until primitives exist) ## Canonical rules — same selector+property declared twice with different values; the winner renders (5) -.app.is-collapsed .nav-action { display: inline-flex } ← shell.css:1073 [@media (max-width: 900px)] -.app.is-collapsed .user-trigger > svg { display: inline-flex } ← shell.css:1073 [@media (max-width: 900px)] -.app.is-collapsed .user-trigger { padding: 6px 8px } ← shell.css:1082 [@media (max-width: 900px)] -.liquid-metal::after { inset: -5px } ← shell.css:1248 -.liquid-metal::after { padding: 5px } ← shell.css:1249 +.app.is-collapsed .nav-action { display: inline-flex } ← shell.css:1080 [@media (max-width: 900px)] +.app.is-collapsed .user-trigger > svg { display: inline-flex } ← shell.css:1080 [@media (max-width: 900px)] +.app.is-collapsed .user-trigger { padding: 6px 8px } ← shell.css:1089 [@media (max-width: 900px)] +.liquid-metal::after { inset: -5px } ← shell.css:1255 +.liquid-metal::after { padding: 5px } ← shell.css:1256 ## Ghost rules — declared but always losing; edit the canonical rule instead, delete these in the refactor project (5) -.app.is-collapsed .nav-action { display: revert } at shell.css:1066 loses to shell.css:1073 -.app.is-collapsed .user-trigger > svg { display: revert } at shell.css:1066 loses to shell.css:1073 -.app.is-collapsed .user-trigger { padding: 0 10px } at shell.css:1078 loses to shell.css:1082 -.liquid-metal::after { inset: -2px } at shell.css:1204 loses to shell.css:1248 -.liquid-metal::after { padding: 2px } at shell.css:1206 loses to shell.css:1249 +.app.is-collapsed .nav-action { display: revert } at shell.css:1073 loses to shell.css:1080 +.app.is-collapsed .user-trigger > svg { display: revert } at shell.css:1073 loses to shell.css:1080 +.app.is-collapsed .user-trigger { padding: 0 10px } at shell.css:1085 loses to shell.css:1089 +.liquid-metal::after { inset: -2px } at shell.css:1211 loses to shell.css:1255 +.liquid-metal::after { padding: 2px } at shell.css:1213 loses to shell.css:1256 ## Component-loaded sheets — imported by components, not index.css; they load after the global styles (2) canvas.css ← pages/Topology.tsx diff --git a/docs/releases/0.33.0.md b/docs/releases/0.33.0.md new file mode 100644 index 00000000..9081ae50 --- /dev/null +++ b/docs/releases/0.33.0.md @@ -0,0 +1,9 @@ +# Thinker 0.33.0 + +```bash +docker pull tmsoftbrasil/thinker:0.33.0 +``` + +## Mudanças + +- feat(dashboard): overview KPI cards drive the chart (cost and tokens as cards, View details, legends, k/m and plain-dollar axes), Observability nav section, request drawer stack and connection-scoped reliability diff --git a/package-lock.json b/package-lock.json index a5355c68..ed39c940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14768,7 +14768,7 @@ } }, "packages/thinker": { - "version": "0.32.0" + "version": "0.33.0" } } } diff --git a/packages/backend/src/analytics/controllers/reliability-analytics.controller.ts b/packages/backend/src/analytics/controllers/reliability-analytics.controller.ts index 8562734f..63391740 100644 --- a/packages/backend/src/analytics/controllers/reliability-analytics.controller.ts +++ b/packages/backend/src/analytics/controllers/reliability-analytics.controller.ts @@ -10,7 +10,14 @@ import { TenantCtx, TenantContext } from '../../common/decorators/tenant-context import { UserCacheInterceptor } from '../../common/interceptors/user-cache.interceptor'; import { DASHBOARD_CACHE_TTL_MS } from '../../common/constants/cache.constants'; -class ReliabilityTimeseriesQueryDto extends RangeQueryDto { +class ReliabilityScopedQueryDto extends RangeQueryDto { + /** Scopes to ONE provider connection (`tenant_providers.id`), terminal attribution. */ + @IsOptional() + @IsString() + connection_id?: string; +} + +class ReliabilityTimeseriesQueryDto extends ReliabilityScopedQueryDto { @IsOptional() @IsString() @IsIn([...RELIABILITY_TS_DIMENSIONS]) @@ -32,11 +39,12 @@ export class ReliabilityAnalyticsController { constructor(private readonly reliabilityStats: ReliabilityStatsService) {} @Get('stats') - async getStats(@Query() query: RangeQueryDto, @TenantCtx() ctx: TenantContext) { + async getStats(@Query() query: ReliabilityScopedQueryDto, @TenantCtx() ctx: TenantContext) { return this.reliabilityStats.getStats({ tenantId: ctx.tenantId, range: query.range, agentName: query.agent_name, + connectionId: query.connection_id, }); } @@ -51,6 +59,7 @@ export class ReliabilityAnalyticsController { by: query.by, agentName: query.agent_name, failedOnly: query.failed_only === 'true', + connectionId: query.connection_id, }); } diff --git a/packages/backend/src/analytics/services/reliability-stats.service.ts b/packages/backend/src/analytics/services/reliability-stats.service.ts index d82c5fdc..56ea333c 100644 --- a/packages/backend/src/analytics/services/reliability-stats.service.ts +++ b/packages/backend/src/analytics/services/reliability-stats.service.ts @@ -15,7 +15,11 @@ import { sqlIsFailedStatus, sqlIsSuccessStatus, } from './query-helpers'; -import { RequestVolumeService, type DispositionTotals } from './request-volume.service'; +import { + RequestVolumeService, + type ConnectionScope, + type DispositionTotals, +} from './request-volume.service'; export interface ReliabilityStatsResponse { success_rate: { value: number; previous: number }; @@ -41,6 +45,9 @@ export interface ReliabilityTimeseriesResponse { buckets: Array<{ bucket: string; counts: number[] }>; } +const connectionScope = (id?: string): ConnectionScope | undefined => + id ? { tenantProviderId: id } : undefined; + interface WindowCounts { total: number; successes: number; @@ -65,6 +72,8 @@ export class ReliabilityStatsService { tenantId: string | null; range?: string; agentName?: string; + /** Only requests concluded on this connection (`tenant_providers.id`). */ + connectionId?: string; }): Promise { const range = params.range ?? '7d'; const cutoff = computeCutoff(rangeToInterval(range)); @@ -76,6 +85,7 @@ export class ReliabilityStatsService { splitAt: cutoff, to: computeCutoff('0 hours'), agentName: params.agentName, + connection: connectionScope(params.connectionId), }); const current = this.windowCounts(windows.current); const previous = this.windowCounts(windows.previous); @@ -220,6 +230,8 @@ export class ReliabilityStatsService { by?: string; agentName?: string; failedOnly?: boolean; + /** Scopes the disposition view to one connection (`tenant_providers.id`). */ + connectionId?: string; }): Promise { const range = params.range ?? '7d'; const by = (RELIABILITY_TS_DIMENSIONS as readonly string[]).includes(params.by ?? '') @@ -239,6 +251,7 @@ export class ReliabilityStatsService { hourly, agentName: params.agentName, failedOnly: params.failedOnly, + connection: connectionScope(params.connectionId), }); return this.pivotTimeseries(range, by, rows); } diff --git a/packages/backend/src/analytics/services/request-volume.service.spec.ts b/packages/backend/src/analytics/services/request-volume.service.spec.ts index 6b8716a0..862cd92f 100644 --- a/packages/backend/src/analytics/services/request-volume.service.spec.ts +++ b/packages/backend/src/analytics/services/request-volume.service.spec.ts @@ -173,6 +173,19 @@ describe('RequestVolumeService (#2511 request-level volume)', () => { expect(lastParams()).toEqual(['t1', '2026-01-01', '2026-01-15', 'demo', '2026-01-08']); }); + it('scopes the window totals to one connection by its terminal attempt', async () => { + messageRepo.query.mockResolvedValue([]); + await service.getDispositionTotalsForWindows({ + tenantId: 't1', + from: '2026-01-01', + splitAt: '2026-01-08', + to: '2026-01-15', + connection: { tenantProviderId: 'tp-1' }, + }); + expect(lastSql()).toContain('WHERE t.tenant_provider_id = $5'); + expect(lastParams()).toEqual(['t1', '2026-01-01', '2026-01-15', '2026-01-08', 'tp-1']); + }); + it('returns empty current and previous windows without a tenant, never querying', async () => { await expect( service.getDispositionTotalsForWindows({ diff --git a/packages/backend/src/analytics/services/request-volume.service.ts b/packages/backend/src/analytics/services/request-volume.service.ts index 8402813c..010f254f 100644 --- a/packages/backend/src/analytics/services/request-volume.service.ts +++ b/packages/backend/src/analytics/services/request-volume.service.ts @@ -224,6 +224,8 @@ export class RequestVolumeService { splitAt: string; to?: string; agentName?: string; + /** Only requests concluded on this connection (terminal attribution). */ + connection?: ConnectionScope; }): Promise<{ current: DispositionTotals; previous: DispositionTotals }> { const empty = (): DispositionTotals => ({ total: 0, @@ -242,11 +244,16 @@ export class RequestVolumeService { if (params.agentName) sqlParams.push(params.agentName); sqlParams.push(params.splitAt); const splitIdx = sqlParams.length; + const connectionWhere = this.connectionPredicate(params.connection, sqlParams).replace( + /^AND /, + 'WHERE ', + ); const sql = `${this.terminalCte(params.agentName, true)} SELECT ${DISPOSITION_EXPR} AS dim, COUNT(*) FILTER (WHERE t.ts >= $${splitIdx})::int AS current_count, COUNT(*) FILTER (WHERE t.ts < $${splitIdx})::int AS previous_count FROM terminal t + ${connectionWhere} GROUP BY 1`; const rows = (await this.messageRepo.query(sql, sqlParams)) as Array<{ dim: string; diff --git a/packages/frontend/src/components/MultiAgentTokenChart.tsx b/packages/frontend/src/components/MultiAgentTokenChart.tsx index b6ea92b0..22996b27 100644 --- a/packages/frontend/src/components/MultiAgentTokenChart.tsx +++ b/packages/frontend/src/components/MultiAgentTokenChart.tsx @@ -16,6 +16,8 @@ import { createTimeScaleRange, createFormatLegendTimestamp, formatLegendTokens, + formatAxisKTokens, + formatAxisDollars, isMultiDayRange, fillDailyGaps, } from '../services/chart-utils.js'; @@ -109,6 +111,8 @@ export default function MultiAgentTokenChart(props: MultiAgentTokenChartProps) { const bars = uPlot.paths.bars!({ size: [1, 20], gap: 2 }); const multiDay = isMultiDayRange(range); const cost = latest.current.label === 'Cost'; + // No label = token usage: the axis reads in thousands ("250k", "1,2m"). + const tokens = !latest.current.label; const series: uPlot.Series[] = [ { value: createFormatLegendTimestamp(range) }, @@ -129,9 +133,8 @@ export default function MultiAgentTokenChart(props: MultiAgentTokenChartProps) { ...axes[1]!, values: (u: uPlot, vals: number[]) => vals.map((v) => - // formatCost surfaces sub-cent values as "< $0.01" instead of - // rounding them to "$0.00". - cost ? (formatCost(v) ?? formatCost(0)!) : formatLegendTokens(u, v), + // Cost reads in plain dollars: the chart title carries "(US $)". + cost ? formatAxisDollars(v) : tokens ? formatAxisKTokens(v) : formatLegendTokens(u, v), ), }; @@ -208,7 +211,7 @@ export default function MultiAgentTokenChart(props: MultiAgentTokenChartProps) { el, data: props.timeseries, buildData, - structureKey: `${props.range}::${props.agents.join(',')}`, + structureKey: `${props.label ?? 'tokens'}::${props.range}::${props.agents.join(',')}`, buildChart, }); @@ -225,6 +228,15 @@ export default function MultiAgentTokenChart(props: MultiAgentTokenChartProps) { fmtVal={fmtVal} hostWidth={() => el.current?.clientWidth ?? 0} /> + {/* Same legend row as the requests chart, so every view keeps one height. */} +
+ {props.agents.map((agent) => ( + + + {agent} + + ))} +
); } diff --git a/packages/frontend/src/components/ReliabilityKpiCards.tsx b/packages/frontend/src/components/ReliabilityKpiCards.tsx index 7618e530..2914afef 100644 --- a/packages/frontend/src/components/ReliabilityKpiCards.tsx +++ b/packages/frontend/src/components/ReliabilityKpiCards.tsx @@ -1,12 +1,84 @@ -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'; -import { useNavigate } from 'react-router'; -import InfoTooltip from './InfoTooltip.js'; +import type { ReactNode } from 'react'; import { cx } from './ui.js'; -import { formatNumber } from '../services/formatters.js'; -import { type ReliabilityStats } from '../services/api/analytics.js'; +import { formatCost, formatNumber } from '../services/formatters.js'; +import { type ReliabilityStats, type StatusTimeseries } from '../services/api/analytics.js'; import { formatRate } from '../services/overview-format.js'; import { intlLocale, t } from '../i18n/index.js'; +/** The card that drives the chart below the cards. */ +export type KpiView = 'requests' | 'cost' | 'tokens' | 'recovered' | 'failed'; + +/** Request statuses each requests-world card keeps; absent = every status. */ +const FILTER_KEYS: Partial> = { + recovered: ['fallback'], + failed: ['error'], +}; + +/** The chart tab a card opens: the request cards all open the requests chart. */ +export function kpiChartTab(view: KpiView): 'requests' | 'cost' | 'tokens' { + return view === 'cost' || view === 'tokens' ? view : 'requests'; +} + +/** The chart title for a card: the card's own label (Cost names its unit). */ +export function kpiChartTitle(view: KpiView): string { + switch (view) { + case 'requests': + return t('overview.kpi.successfulRequests'); + case 'cost': + return t('overview.chart.costUsd'); + case 'tokens': + return t('overview.chart.tokenUsage'); + case 'recovered': + return t('overview.kpi.recoveredRequests'); + case 'failed': + return t('overview.kpi.failedRequests'); + } +} + +/** Where a card's data lives: the Requests log, scoped like the page. */ +export interface KpiDetailsScope { + agentName?: string; + connectionId?: string; + range?: string; +} + +/** + * The Requests-log report behind the chart a card opens (the old "View more" + * targets): recovered → fallback-rescued, failed → failed, the rest → every + * request of the scope and window. + */ +export function kpiDetailsHref(view: KpiView, scope: KpiDetailsScope = {}): string { + const extra = + view === 'recovered' ? 'status=ok&trigger=fallback' : view === 'failed' ? 'status=failed' : ''; + const params = [ + scope.agentName ? `agent=${encodeURIComponent(scope.agentName)}` : '', + scope.connectionId ? `connections=${encodeURIComponent(scope.connectionId)}` : '', + scope.range ? `range=${encodeURIComponent(scope.range)}` : '', + extra, + ] + .filter(Boolean) + .join('&'); + return params ? `/messages?${params}` : '/messages'; +} + +/** The request-status series narrowed to the statuses a card stands for. */ +export function applyKpiFilter( + ts: StatusTimeseries | undefined, + view: KpiView, +): StatusTimeseries | undefined { + const keep = FILTER_KEYS[view]; + if (!ts || !keep) return ts; + const picked = ts.keys.map((k, i) => ({ k, i })).filter(({ k }) => keep.includes(k)); + return { + ...ts, + keys: picked.map(({ k }) => k), + buckets: ts.buckets.map((b) => ({ + bucket: b.bucket, + counts: picked.map(({ i }) => b.counts[i] ?? 0), + })), + }; +} + function fmtPct(v: number): string { if (v === 1 || v === 0) return new Intl.NumberFormat(intlLocale(), { style: 'percent' }).format(v); @@ -15,7 +87,7 @@ function fmtPct(v: number): string { function TrendBadge({ current, previous }: { current: number; previous: number }) { if (previous === 0 && current === 0) return null; - if (previous === 0) return {t('overview.kpi.new')}; + if (previous === 0) return null; const pct = ((current - previous) / previous) * 100; if (Math.abs(pct) < 0.5) return null; const clamped = Math.max(-999, Math.min(999, Math.round(pct))); @@ -27,165 +99,154 @@ function TrendBadge({ current, previous }: { current: number; previous: number } ); } +/** Trend pill from a precomputed percentage (cost and token totals). */ +function PctBadge({ pct }: { pct: number }) { + const clamped = Math.max(-999, Math.min(999, Math.round(pct))); + if (clamped === 0) return null; + return ( + + {clamped > 0 ? '+' : ''} + {clamped}% + + ); +} + +/** A usage total shown as a card (value + trend in percent). */ +export interface KpiUsage { + value: number; + trendPct: number; +} + export interface ReliabilityKpiCardsProps { stats: ReliabilityStats | undefined; - /** Scopes the deep links to one harness; omitted, they cover every harness. */ - agentName?: string; - /** The page's current window, carried on the deep links. */ - range?: string; - /** Hide the deep links entirely (no Requests log to point at). */ - noLinks?: boolean; + /** Window cost; omitted, the Cost card is hidden (no cost to show). */ + cost?: KpiUsage; + /** Window token usage; omitted, the Token usage card is hidden. */ + tokens?: KpiUsage; + /** The card currently driving the chart. */ + selected?: KpiView; + /** Clicking a card switches the chart to its view. */ + onSelect?: (view: KpiView) => void; } function Tile({ + id, label, - info, value, + secondary, trend, - link, - title, - showMore, tone, - onNavigate, + selected, + onSelect, }: { + id: KpiView; label: string; - info?: string; value: string; + /** Muted text right after the value, e.g. the share of all requests. */ + secondary?: string; trend: ReactNode; - link: string | null; - title?: string; - showMore?: boolean; /** Alert color; set only when the value needs attention (above zero). */ tone?: 'warn' | 'danger'; - onNavigate: (to: string) => void; + selected: boolean; + onSelect?: (view: KpiView) => void; }) { - const linkProps = link - ? { - title, - role: 'link' as const, - tabIndex: 0, - onClick: (event: MouseEvent) => { - const interactive = - event.target instanceof Element && - event.target.closest('a, button, input, select, textarea, [role="button"]'); - if (!interactive) onNavigate(link); - }, - onKeyDown: (event: KeyboardEvent) => { - if (event.target === event.currentTarget && event.key === 'Enter') onNavigate(link); - }, - } - : {}; - return ( -
- - {label} - {info ? : null} - + const valueTone = tone === 'warn' ? 'text-warn' : tone === 'danger' ? 'text-danger' : 'text-ink'; + const body = ( + <> + {label}
- + {value} - {trend} - {showMore ? ( - {t('overview.kpi.viewMore')} + {secondary ? ( + {secondary} ) : null} + {trend}
-
+ + ); + if (!onSelect) return
{body}
; + return ( + ); } /** - * The reliability story, request-first: Success rate · Recovered requests % - * · Recovered by fallback · Failed requests. Recovered = fallback_saves over - * the window total. + * The overview cards, request-first: Successful requests · Cost · Token usage + * · Recovered requests · Failed requests. Recovered = fallback_saves over the + * window total. Each card picks what the chart below shows. */ export default function ReliabilityKpiCards({ stats, - agentName, - range, - noLinks, + cost, + tokens, + selected, + onSelect, }: ReliabilityKpiCardsProps) { - const navigate = useNavigate(); - if (!stats) return null; - - const requestsLink = (extra: string) => { - if (noLinks) return null; - const params = [ - agentName ? `agent=${encodeURIComponent(agentName)}` : '', - range ? `range=${range}` : '', - extra.replace(/^&/, ''), - ] - .filter(Boolean) - .join('&'); - return params ? `/messages?${params}` : '/messages'; - }; - const scopeTitle = (subject: string) => - agentName - ? t('overview.kpi.scopeHarness', { subject }) - : t('overview.kpi.scopeAll', { subject }); - - const recovered = stats.fallback_saves?.value ?? 0; - const recoveredPrev = stats.fallback_saves?.previous ?? 0; - const total = stats.total_requests; + if (!stats && !cost && !tokens) return null; + + // Cost and tokens come with the critical overview; the request cards show a + // dash until the reliability stats for this scope land. + const pending = '—'; + const recovered = stats?.fallback_saves?.value ?? 0; + const recoveredPrev = stats?.fallback_saves?.previous ?? 0; + const total = stats?.total_requests; + const failed = stats?.errors_remaining ?? { value: 0, previous: 0 }; const recoveredPct = total && total.value !== 0 ? recovered / total.value : 0; const recoveredPctPrev = total && total.previous !== 0 ? recoveredPrev / total.previous : 0; - const showMore = !!requestsLink(''); - const fallbackLink = requestsLink('&status=ok&trigger=fallback'); + const successful = Math.max(0, (total?.value ?? 0) - failed.value); + const successfulPrev = Math.max(0, (total?.previous ?? 0) - failed.previous); + const tile = (id: KpiView) => ({ id, onSelect, selected: selected === id }); return (
- } - link={null} - onNavigate={navigate} + {...tile('requests')} + label={t('overview.kpi.successfulRequests')} + value={stats ? formatNumber(successful) : pending} + secondary={stats ? fmtPct(stats.success_rate.value) : undefined} + trend={} /> + {cost ? ( + } + /> + ) : null} + {tokens ? ( + } + /> + ) : null} } - link={fallbackLink} - title={scopeTitle(t('overview.kpi.subjectRecovered'))} - showMore={showMore} - onNavigate={navigate} - /> - } - link={fallbackLink} - title={scopeTitle(t('overview.kpi.subjectRecoveredByFallback'))} - showMore={showMore} tone={recovered > 0 ? 'warn' : undefined} - onNavigate={navigate} /> - } - link={requestsLink('&status=failed')} - title={scopeTitle(t('overview.kpi.subjectFailed'))} - showMore={showMore} - tone={stats.errors_remaining.value > 0 ? 'danger' : undefined} - onNavigate={navigate} + value={stats ? formatNumber(failed.value) : pending} + trend={} + tone={failed.value > 0 ? 'danger' : undefined} />
); diff --git a/packages/frontend/src/components/RequestDrawer.tsx b/packages/frontend/src/components/RequestDrawer.tsx index a0999f00..915b73ad 100644 --- a/packages/frontend/src/components/RequestDrawer.tsx +++ b/packages/frontend/src/components/RequestDrawer.tsx @@ -14,6 +14,8 @@ import { Minus, PanelRight, PictureInPicture2, + Pin, + PinOff, X, } from 'lucide-react'; import { getMessageDetails, type AttemptRecording } from '../services/api/messages.js'; @@ -37,6 +39,16 @@ export interface RequestDrawerProps { onClose: () => void; /** `page` fills a dedicated browser tab: no side panel, float or close controls. */ variant?: 'panel' | 'page'; + /** Shows the pin control; a pinned window keeps its request (see RequestDrawerStack). */ + pinned?: boolean; + onTogglePin?: () => void; + /** Open as a floating window from the start. */ + startFloating?: boolean; + /** Cascade step for windows opened next to others. */ + cascade?: number; + /** Stacking order among open windows; raised by `onActivate`. */ + zIndex?: number; + onActivate?: () => void; } type AttemptTab = 'details' | 'request' | 'response' | 'timeline' | 'tools' | 'raw'; @@ -51,12 +63,13 @@ interface FloatRect { const PENDING_POLL_MS = 1500; -function initialFloatRect(): FloatRect { +function initialFloatRect(cascade = 0): FloatRect { const width = Math.min(960, window.innerWidth - 48); const height = Math.min(720, window.innerHeight - 48); + const step = (cascade % 6) * 28; return { - left: Math.max(16, window.innerWidth - width - 32), - top: Math.max(16, Math.round((window.innerHeight - height) / 2)), + left: Math.max(16, window.innerWidth - width - 32 - step), + top: Math.max(16, Math.round((window.innerHeight - height) / 2) + step - 70), width, height, }; @@ -488,12 +501,20 @@ export default function RequestDrawer({ messageId, onClose, variant = 'panel', + pinned, + onTogglePin, + startFloating, + cascade = 0, + zIndex, + onActivate, }: RequestDrawerProps) { const [selectedAttempt, setSelectedAttempt] = useState(0); const [tab, setTab] = useState('details'); - const [layout, setLayout] = useState('docked'); + const [layout, setLayout] = useState(startFloating ? 'floating' : 'docked'); const [minimized, setMinimized] = useState(false); - const [rect, setRect] = useState(null); + const [rect, setRect] = useState(() => + startFloating ? initialFloatRect(cascade) : null, + ); const boxRef = useRef(null); const open = messageId !== null; const isPage = variant === 'page'; @@ -564,7 +585,7 @@ export default function RequestDrawer({ if (!open) return null; const popOut = () => { - setRect((prev) => prev ?? initialFloatRect()); + setRect((prev) => prev ?? initialFloatRect(cascade)); setMinimized(false); setLayout('floating'); }; @@ -624,8 +645,24 @@ export default function RequestDrawer({ : { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : undefined; + // Pinned windows float: docked panels would cover each other. + const togglePin = () => { + if (!pinned && !floating) popOut(); + onTogglePin?.(); + }; + const controls = isPage ? null : (
+ {onTogglePin ? ( + + {pinned ? : } + + ) : null} {floating ? ( <> {minimized ? : } - - - + {pinned ? null : ( + + + + )} ) : ( @@ -660,7 +699,8 @@ export default function RequestDrawer({ isPage ? 'drawer--page' : floating ? 'drawer--floating' : 'w-[min(880px,100vw)]', floating && minimized && 'drawer--minimized', )} - style={floatStyle} + style={floatStyle && zIndex !== undefined ? { ...floatStyle, zIndex } : floatStyle} + onMouseDownCapture={onActivate} aria-label={m ? t('messages.drawer.title', { id: m.id?.slice(0, 12) ?? '' }) : undefined} > {m ? ( diff --git a/packages/frontend/src/components/RequestDrawerStack.tsx b/packages/frontend/src/components/RequestDrawerStack.tsx new file mode 100644 index 00000000..a374f313 --- /dev/null +++ b/packages/frontend/src/components/RequestDrawerStack.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; +import RequestDrawer from './RequestDrawer.js'; + +interface DrawerWindow { + key: number; + messageId: string; + pinned: boolean; + /** Opened while other windows were open: starts floating, cascaded. */ + floating: boolean; + cascade: number; +} + +export interface RequestDrawerStackProps { + /** The request picked in the page; drives the one unpinned window. */ + messageId: string | null; + /** Called when the unpinned window closes, so the page clears its selection. */ + onClose: () => void; +} + +const BASE_Z = 65; + +/** + * Request windows with pinning. The unpinned window follows the page's + * selection; a pinned window keeps its request, and the next selection opens + * a new (unpinned) window next to it. Each window closes on its own. + */ +export default function RequestDrawerStack({ messageId, onClose }: RequestDrawerStackProps) { + const [windows, setWindows] = useState([]); + const [order, setOrder] = useState([]); + const nextKey = useRef(1); + + // Route each new selection to the unpinned window, or open one. + useEffect(() => { + if (!messageId) { + setWindows((prev) => prev.filter((w) => w.pinned)); + return; + } + setWindows((prev) => { + const free = prev.find((w) => !w.pinned); + if (free) { + return free.messageId === messageId + ? prev + : prev.map((w) => (w === free ? { ...w, messageId } : w)); + } + const key = nextKey.current++; + setOrder((o) => [...o.filter((k) => k !== key), key]); + return [ + ...prev, + { + key, + messageId, + pinned: false, + floating: prev.length > 0, + cascade: prev.length, + }, + ]; + }); + }, [messageId]); + + const raise = (key: number) => + setOrder((o) => (o[o.length - 1] === key ? o : [...o.filter((k) => k !== key), key])); + + const close = (win: DrawerWindow) => { + setWindows((prev) => prev.filter((w) => w.key !== win.key)); + setOrder((o) => o.filter((k) => k !== win.key)); + if (!win.pinned) onClose(); + }; + + const togglePin = (win: DrawerWindow) => { + if (win.pinned) { + // Unpinning: this window takes over the selection; drop any other free one. + setWindows((prev) => + prev + .filter((w) => w.pinned || w.key === win.key) + .map((w) => (w.key === win.key ? { ...w, pinned: false } : w)), + ); + } else { + setWindows((prev) => prev.map((w) => (w.key === win.key ? { ...w, pinned: true } : w))); + // The page's selection now belongs to no window: the next click opens a new one. + onClose(); + } + }; + + return ( + <> + {windows.map((win) => ( + togglePin(win)} + startFloating={win.floating} + cascade={win.cascade} + zIndex={BASE_Z + Math.max(0, order.indexOf(win.key))} + onActivate={() => raise(win.key)} + onClose={() => close(win)} + /> + ))} + + ); +} diff --git a/packages/frontend/src/components/UnifiedChartCard.tsx b/packages/frontend/src/components/UnifiedChartCard.tsx index c1906b16..5d2efe8e 100644 --- a/packages/frontend/src/components/UnifiedChartCard.tsx +++ b/packages/frontend/src/components/UnifiedChartCard.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy, type ReactNode } from 'react'; +import { useNavigate } from 'react-router'; import InfoTooltip from './InfoTooltip.js'; import { cx } from './ui.js'; -import { formatNumber, formatCost } from '../services/formatters.js'; import { type StatusTimeseries } from '../services/api/analytics.js'; import { t } from '../i18n/index.js'; @@ -24,8 +24,8 @@ export interface UnifiedChartCardProps { requestsLabel?: string; /** Optional ⓘ next to the requests/attempts tab label. */ requestsInfoTooltip?: string; - requestsValue: number; - requestsTrendPct: number; + requestsValue?: number; + requestsTrendPct?: number; agentRequestTimeseries?: AgentTimeseries; /** When set, the Requests tab shows this (disposition) instead of agentRequestTimeseries */ requestStatusTimeseries?: StatusTimeseries; @@ -46,6 +46,10 @@ export interface UnifiedChartCardProps { tokensTrendPct: number; agentTimeseries?: AgentTimeseries; // Shared + /** Chart title override (the selected KPI card's label). */ + title?: string; + /** "View details" target beside the title: the report behind the chart. */ + detailsHref?: string; range: string; colorMap?: Record; /** Filter controls rendered in the subtitle bar */ @@ -113,15 +117,17 @@ export const chartEmpty = (msg: string) => ( * one chart body. Each tab picks its series; filters sit in the subtitle bar. */ export default function UnifiedChartCard(props: UnifiedChartCardProps) { - const showCost = props.costValue != null; + const navigate = useNavigate(); const requestsLabel = props.requestsLabel ?? t('overview.chart.requests'); - const tabTitle = { - requests: requestsLabel, - recovered: t('overview.chart.recoveredRequests'), - cost: t('overview.chart.cost'), - tokens: t('overview.chart.tokenUsage'), - }[props.activeTab]; + const tabTitle = + props.title ?? + { + requests: requestsLabel, + recovered: t('overview.chart.recoveredRequests'), + cost: t('overview.chart.cost'), + tokens: t('overview.chart.tokenUsage'), + }[props.activeTab]; const requestsBody = () => { const status = props.requestStatusTimeseries; @@ -194,52 +200,22 @@ export default function UnifiedChartCard(props: UnifiedChartCardProps) { return (
-
- } - onClick={() => props.onTabChange('requests')} - /> - {props.recoveredTimeseries ? ( - } - onClick={() => props.onTabChange('recovered')} - /> - ) : null} - {showCost ? ( - } - onClick={() => props.onTabChange('cost')} - /> - ) : null} - } - onClick={() => props.onTabChange('tokens')} - /> -
- + {/* No stat tabs: the KPI cards above the chart pick its view. */} {/* Subtitle bar: title + contextual filters. The grouping filters do not apply to the recovered tab, which is already a fixed fallback subset. */}
- {tabTitle} + + {tabTitle} + {props.detailsHref ? ( + + ) : null} + {props.seriesFilters && props.activeTab !== 'recovered' ? (
{props.seriesFilters}
) : null} diff --git a/packages/frontend/src/components/flow/FlowCanvas.tsx b/packages/frontend/src/components/flow/FlowCanvas.tsx index bffd3f4c..9c273b23 100644 --- a/packages/frontend/src/components/flow/FlowCanvas.tsx +++ b/packages/frontend/src/components/flow/FlowCanvas.tsx @@ -10,6 +10,7 @@ import { ReactFlowProvider, SelectionMode, useEdgesState, + useNodesInitialized, useNodesState, useReactFlow, } from '@xyflow/react'; @@ -66,6 +67,9 @@ function Canvas({ const [edges, setEdges, onEdgesChange] = useEdgesState(graph.edges); const flow = useReactFlow(); const shape = useRef(''); + // Set when the node set changed; the fit waits until React Flow measured them. + const pendingFit = useRef(false); + const measured = useNodesInitialized(); // Dragging empty space pans the stage (hand) or draws a selection box (select). const [selecting, setSelecting] = useState(false); @@ -96,10 +100,17 @@ function Canvas({ const next = graph.nodes.map((n) => n.id).join('|'); if (next !== shape.current) { shape.current = next; - requestAnimationFrame(() => void flow.fitView({ padding: 0.15, duration: 200 })); + pendingFit.current = true; } }, [graph, setNodes, setEdges, flow]); + useEffect(() => { + if (!pendingFit.current || !measured || nodes.length === 0) return; + pendingFit.current = false; + // Next frame: the store holds the committed nodes and their sizes. + requestAnimationFrame(() => void flow.fitView({ padding: 0.15, duration: 200 })); + }, [measured, nodes, flow]); + // `f` frames the whole graph, unless typing in a field. useEffect(() => { const onKey = (event: KeyboardEvent) => { diff --git a/packages/frontend/src/components/message-table-types.ts b/packages/frontend/src/components/message-table-types.ts index 5a5069fa..9a0e5cd1 100644 --- a/packages/frontend/src/components/message-table-types.ts +++ b/packages/frontend/src/components/message-table-types.ts @@ -66,8 +66,6 @@ export const COMPACT_COLUMNS: MessageColumnKey[] = [ 'status', 'date', 'model', - 'attempts', - 'message', 'cost', 'totalTokens', 'cache', diff --git a/packages/frontend/src/components/shell/Topbar.tsx b/packages/frontend/src/components/shell/Topbar.tsx index de33c5e0..b34dc20e 100644 --- a/packages/frontend/src/components/shell/Topbar.tsx +++ b/packages/frontend/src/components/shell/Topbar.tsx @@ -26,7 +26,7 @@ import { useSignal } from '../../lib/signal.js'; import { brandingSignal } from '../../services/branding.js'; import { themedIcon } from '../setup-ui.js'; import { t } from '../../i18n/index.js'; -import { IconButton, Menu, MenuItem } from '../ui.jsx'; +import { Badge, IconButton, Menu, MenuItem } from '../ui.jsx'; import { providerIcon } from '../ProviderIcon.jsx'; import DuplicateAgentModal from '../DuplicateAgentModal.jsx'; import AlertsMenu from './AlertsMenu.jsx'; @@ -117,6 +117,12 @@ export default function Topbar({ {connection.providerId ? providerIcon(connection.providerId, 14) : null} {connection.name} {connection.label ? {connection.label} : null} + {connection.custom ? {t('providers.custom')} : null} + {connection.active === null ? null : connection.active ? ( + {t('providers.active')} + ) : ( + {t('providers.inactive')} + )} ) : section && pageCrumb.name && pageCrumb.path === pathname ? ( <> diff --git a/packages/frontend/src/config/navigation.tsx b/packages/frontend/src/config/navigation.tsx index bd6d164f..1362f388 100644 --- a/packages/frontend/src/config/navigation.tsx +++ b/packages/frontend/src/config/navigation.tsx @@ -46,6 +46,7 @@ export interface NavGroup { export const NAVIGATION: NavGroup[] = [ { key: 'main', + label: 'nav.sectionObservability', items: [ { key: 'overview', to: '/overview', label: 'nav.overview', icon: LayoutDashboard }, { key: 'sessions', to: '/sessions', label: 'nav.sessions', icon: MessagesSquare }, diff --git a/packages/frontend/src/i18n/locales/en/alerts.ts b/packages/frontend/src/i18n/locales/en/alerts.ts index ca2eeb07..7c36cd6f 100644 --- a/packages/frontend/src/i18n/locales/en/alerts.ts +++ b/packages/frontend/src/i18n/locales/en/alerts.ts @@ -23,6 +23,7 @@ export default { catalog: 'Catalog', }, col: { + level: 'Severity', event: 'Event', category: 'Category', actor: 'Who', diff --git a/packages/frontend/src/i18n/locales/en/messages.ts b/packages/frontend/src/i18n/locales/en/messages.ts index fc282c2d..8f968045 100644 --- a/packages/frontend/src/i18n/locales/en/messages.ts +++ b/packages/frontend/src/i18n/locales/en/messages.ts @@ -137,6 +137,8 @@ export default { }, drawer: { title: 'Request {{ id }}', + pin: 'Pin window (the next click opens another)', + unpin: 'Unpin window', close: 'Close', float: 'Pop out to a floating window', dock: 'Dock to the side', diff --git a/packages/frontend/src/i18n/locales/en/nav.ts b/packages/frontend/src/i18n/locales/en/nav.ts index c648d92f..d11e3011 100644 --- a/packages/frontend/src/i18n/locales/en/nav.ts +++ b/packages/frontend/src/i18n/locales/en/nav.ts @@ -10,6 +10,7 @@ export default { requests: 'Requests', harnesses: 'Harnesses', createHarness: 'Create new harness', + sectionObservability: 'OBSERVABILITY', sectionClients: 'CLIENTS', sectionProviders: 'PROVIDERS', local: 'Local', diff --git a/packages/frontend/src/i18n/locales/en/overview.ts b/packages/frontend/src/i18n/locales/en/overview.ts index 7a972a5d..bda48a1c 100644 --- a/packages/frontend/src/i18n/locales/en/overview.ts +++ b/packages/frontend/src/i18n/locales/en/overview.ts @@ -95,7 +95,9 @@ export default { requestSuccessRate: 'Successful requests over all requests. Recovered requests count as successful.', }, + periodFilter: 'Period filter', kpi: { + successfulRequests: 'Successful requests', successRate: 'Success rate', recoveredRequests: 'Recovered requests', recoveredByFallback: 'Recovered by Fallback', @@ -105,6 +107,7 @@ export default { subjectRecovered: 'recovered requests', subjectRecoveredByFallback: 'requests recovered by fallback', subjectFailed: 'failed requests', + scopeConnection: 'View the {{ subject }} of this account', scopeHarness: "View this harness's {{ subject }}", scopeAll: 'View {{ subject }} across all harnesses', }, @@ -113,6 +116,8 @@ export default { recoveredRequests: 'Recovered requests', cost: 'Cost', tokenUsage: 'Token usage', + costUsd: 'Cost (US $)', + viewDetails: 'View details', loading: 'Loading chart…', noRequestData: 'No request data for this time range', noRecovered: 'No recovered requests in this time range', diff --git a/packages/frontend/src/i18n/locales/en/shell.ts b/packages/frontend/src/i18n/locales/en/shell.ts index 93499467..62f61f25 100644 --- a/packages/frontend/src/i18n/locales/en/shell.ts +++ b/packages/frontend/src/i18n/locales/en/shell.ts @@ -48,6 +48,10 @@ export default { }, }, flow: { + harness: 'Harness', + allHarnesses: 'All harnesses', + providerType: 'Provider type', + allProviders: 'All providers', subtitle: 'How requests flow from clients through harnesses and providers to the internet.', empty: 'No harness yet. Create one and its providers will appear here.', emptyActive: 'No traffic in this period.', diff --git a/packages/frontend/src/i18n/locales/pt-BR/alerts.ts b/packages/frontend/src/i18n/locales/pt-BR/alerts.ts index 85b5d289..44d3e376 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/alerts.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/alerts.ts @@ -23,6 +23,7 @@ export default { catalog: 'Catálogo', }, col: { + level: 'Severidade', event: 'Evento', category: 'Categoria', actor: 'Quem', diff --git a/packages/frontend/src/i18n/locales/pt-BR/messages.ts b/packages/frontend/src/i18n/locales/pt-BR/messages.ts index fdaf36b2..dc49e756 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/messages.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/messages.ts @@ -143,6 +143,8 @@ const ptBR: Catalog = { }, drawer: { title: 'Requisição {{ id }}', + pin: 'Fixar janela (o próximo clique abre outra)', + unpin: 'Desafixar janela', close: 'Fechar', float: 'Destacar em janela flutuante', dock: 'Fixar na lateral', diff --git a/packages/frontend/src/i18n/locales/pt-BR/nav.ts b/packages/frontend/src/i18n/locales/pt-BR/nav.ts index fe2bc907..91878202 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/nav.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/nav.ts @@ -12,6 +12,7 @@ const ptBR: Catalog = { requests: 'Requisições', harnesses: 'Harnesses', createHarness: 'Criar harness', + sectionObservability: 'OBSERVABILIDADE', sectionClients: 'CLIENTES', sectionProviders: 'PROVEDORES', local: 'Locais', diff --git a/packages/frontend/src/i18n/locales/pt-BR/overview.ts b/packages/frontend/src/i18n/locales/pt-BR/overview.ts index cda940f7..5b6c3a3a 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/overview.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/overview.ts @@ -99,7 +99,9 @@ const ptBR: Catalog = { requestSuccessRate: 'Requisições bem-sucedidas sobre todas as requisições. As recuperadas contam como bem-sucedidas.', }, + periodFilter: 'Filtro por período', kpi: { + successfulRequests: 'Requisições funcionais', successRate: 'Taxa de sucesso', recoveredRequests: 'Requisições recuperadas', recoveredByFallback: 'Recuperadas por fallback', @@ -109,6 +111,7 @@ const ptBR: Catalog = { subjectRecovered: 'requisições recuperadas', subjectRecoveredByFallback: 'requisições recuperadas por fallback', subjectFailed: 'requisições com falha', + scopeConnection: 'Ver as {{ subject }} desta conta', scopeHarness: 'Ver as {{ subject }} deste harness', scopeAll: 'Ver as {{ subject }} de todos os harnesses', }, @@ -117,6 +120,8 @@ const ptBR: Catalog = { recoveredRequests: 'Requisições recuperadas', cost: 'Custo', tokenUsage: 'Uso de tokens', + costUsd: 'Custo (US $)', + viewDetails: 'Ver detalhes', loading: 'Carregando gráfico…', noRequestData: 'Nenhuma requisição neste período', noRecovered: 'Nenhuma requisição recuperada neste período', diff --git a/packages/frontend/src/i18n/locales/pt-BR/shell.ts b/packages/frontend/src/i18n/locales/pt-BR/shell.ts index 517cc725..bc4aa1d7 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/shell.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/shell.ts @@ -51,6 +51,10 @@ const shell: Catalog = { }, }, flow: { + harness: 'Harness', + allHarnesses: 'Todos os harnesses', + providerType: 'Tipo de provedor', + allProviders: 'Todos os provedores', subtitle: 'Como as requisições saem dos clientes, passam pelos harnesses e provedores e chegam à internet.', empty: 'Nenhum harness ainda. Crie um e os provedores dele vão aparecer aqui.', diff --git a/packages/frontend/src/pages/Alerts.tsx b/packages/frontend/src/pages/Alerts.tsx index 6d5c3753..0b94aaf1 100644 --- a/packages/frontend/src/pages/Alerts.tsx +++ b/packages/frontend/src/pages/Alerts.tsx @@ -156,6 +156,7 @@ export default function Alerts() { + @@ -166,11 +167,11 @@ export default function Alerts() { {data.events.map((e) => ( + diff --git a/packages/frontend/src/pages/GlobalOverview.tsx b/packages/frontend/src/pages/GlobalOverview.tsx index 2b2c17e9..7f32a145 100644 --- a/packages/frontend/src/pages/GlobalOverview.tsx +++ b/packages/frontend/src/pages/GlobalOverview.tsx @@ -38,7 +38,13 @@ import { PROVIDERS } from '../services/providers.js'; import { AGENT_COLORS } from '../components/MultiAgentTokenChart.js'; import InfoTooltip from '../components/InfoTooltip.js'; import UnifiedChartCard, { type ChartTab } from '../components/UnifiedChartCard.js'; -import ReliabilityKpiCards from '../components/ReliabilityKpiCards.js'; +import ReliabilityKpiCards, { + applyKpiFilter, + kpiChartTab, + kpiChartTitle, + kpiDetailsHref, + type KpiView, +} from '../components/ReliabilityKpiCards.js'; import Sparkline from '../components/Sparkline.js'; import FilterSelect from '../components/FilterSelect.js'; import Select from '../components/Select.js'; @@ -271,7 +277,9 @@ export default function GlobalOverview() { setGroupByRaw(v); persist('local', GROUP_STORAGE_KEY, v); }; - const [chartView, setChartView] = useState('requests'); + // The selected KPI card drives the chart (tab + status filter). + const [kpiView, setKpiView] = useState('requests'); + const chartView: ChartTab = kpiChartTab(kpiView); // ── Data ───────────────────────────────────────────────────────────── const overviewRes = useResource( @@ -355,20 +363,10 @@ export default function GlobalOverview() { [chartRange, aPing], ); const requestStatusTs = requestStatusRes.data; - const recoveredTs = useMemo(() => { - if (!requestStatusTs) return undefined; - const picked = requestStatusTs.keys - .map((k, i) => ({ k, i })) - .filter(({ k }) => k === 'fallback'); - return { - ...requestStatusTs, - keys: picked.map(({ k }) => k), - buckets: requestStatusTs.buckets.map((b) => ({ - bucket: b.bucket, - counts: picked.map(({ i }) => b.counts[i] ?? 0), - })), - }; - }, [requestStatusTs]); + const filteredStatusTs = useMemo( + () => applyKpiFilter(requestStatusTs, kpiView), + [requestStatusTs, kpiView], + ); // Harness table usage follows the page range selector (the workspace agents // endpoint is a fixed window). @@ -547,21 +545,24 @@ export default function GlobalOverview() { agents !== undefined && providers !== undefined; - const requestsGroup = groupBy === 'agent' ? 'agent' : 'status'; + // A KPI card filters by request status, so it forces the status grouping. + const statusOnly = kpiView === 'recovered' || kpiView === 'failed'; + const requestsGroup = groupBy === 'agent' && !statusOnly ? 'agent' : 'status'; const usageGroup = groupBy === 'provider' ? 'provider' : 'agent'; - const recoveredTrendPct = (() => { - const s = reliabilityStats; - if (!s) return 0; - const cur = s.fallback_saves?.value ?? 0; - const prev = s.fallback_saves?.previous ?? 0; - if (prev === 0) return 0; - return Math.max(-999, Math.min(999, Math.round(((cur - prev) / prev) * 100))); - })(); const segmented = (options: Array<{ key: string; label: string; active: boolean }>) => (
{options.map((o) => ( - ))} @@ -572,26 +573,30 @@ export default function GlobalOverview() { const o = overview!; return ( <> - + {!hasNoAgents || !hasNoProviders ? ( -
-
-
-
) : null} diff --git a/packages/frontend/src/pages/MessageLog.tsx b/packages/frontend/src/pages/MessageLog.tsx index f68096c5..a6c0c81d 100644 --- a/packages/frontend/src/pages/MessageLog.tsx +++ b/packages/frontend/src/pages/MessageLog.tsx @@ -11,7 +11,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router'; import { Ban, Sprout } from 'lucide-react'; import ErrorState from '../components/ErrorState.js'; import MessageTable from '../components/MessageTable.js'; -import RequestDrawer from '../components/RequestDrawer.js'; +import RequestDrawerStack from '../components/RequestDrawerStack.js'; import Pagination from '../components/Pagination.js'; import Select, { type SelectOption } from '../components/Select.js'; import MultiSelect, { type MultiSelectOption } from '../components/MultiSelect.js'; @@ -934,7 +934,7 @@ export default function MessageLog() { onClose={() => setSetupOpen(false)} /> ) : null} - +
); } diff --git a/packages/frontend/src/pages/Overview.tsx b/packages/frontend/src/pages/Overview.tsx index 277946c2..7b9415d4 100644 --- a/packages/frontend/src/pages/Overview.tsx +++ b/packages/frontend/src/pages/Overview.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; import UnifiedChartCard, { type ChartTab } from '../components/UnifiedChartCard.js'; -import ReliabilityKpiCards from '../components/ReliabilityKpiCards.js'; +import ReliabilityKpiCards, { + applyKpiFilter, + kpiChartTab, + kpiChartTitle, + kpiDetailsHref, + type KpiView, +} from '../components/ReliabilityKpiCards.js'; import FilterSelect from '../components/FilterSelect.js'; import { AGENT_COLORS } from '../components/MultiAgentTokenChart.js'; import CostByModelTable from '../components/CostByModelTable.js'; @@ -163,14 +169,16 @@ export default function Overview() { markUserSelected: () => setUserSelectedRange(true), }); - const [activeView, setActiveViewRaw] = useState('requests'); + // The selected KPI card drives the chart (tab + status filter). + const [kpiView, setKpiViewRaw] = useState('requests'); const [tokenChartRequested, setTokenChartRequested] = useState(false); const [costChartRequested, setCostChartRequested] = useState(false); - const setActiveView = (view: ChartTab) => { + const setKpiView = (view: KpiView) => { if (view === 'tokens') setTokenChartRequested(true); if (view === 'cost') setCostChartRequested(true); - setActiveViewRaw(view); + setKpiViewRaw(view); }; + const activeView: ChartTab = kpiChartTab(kpiView); // One token per range/agent visit: its identity changes on every transition // (A → B → A included) but stays stable across same-scope SSE refreshes, so @@ -266,19 +274,10 @@ export default function Overview() { [range, agentName, ping], ); - const recoveredTs = useMemo(() => { - const ts = statusTs.data; - if (!ts) return undefined; - const picked = ts.keys.map((k, i) => ({ k, i })).filter(({ k }) => k === 'fallback'); - return { - ...ts, - keys: picked.map(({ k }) => k), - buckets: ts.buckets.map((b) => ({ - bucket: b.bucket, - counts: picked.map(({ i }) => b.counts[i] ?? 0), - })), - }; - }, [statusTs.data]); + const filteredStatusTs = useMemo( + () => applyKpiFilter(statusTs.data, kpiView), + [statusTs.data, kpiView], + ); const allProviders = useMemo(() => { const set = new Set([ @@ -331,15 +330,6 @@ export default function Overview() { [providerCostTs.data, effectiveSelected], ); - const recoveredTrendPct = (() => { - const s = reliabilityStats; - if (!s) return 0; - const cur = s.fallback_saves?.value ?? 0; - const prev = s.fallback_saves?.previous ?? 0; - if (prev === 0) return 0; - return Math.max(-999, Math.min(999, Math.round(((cur - prev) / prev) * 100))); - })(); - const encodedAgent = encodeURIComponent(agentName); const name = agentDisplayName() ?? agentName; @@ -371,23 +361,30 @@ export default function Overview() {
) : null} - + {showDashboard ? ( -
+
+ {t('overview.periodFilter')}
-
) : null} diff --git a/packages/frontend/src/pages/SessionDetail.tsx b/packages/frontend/src/pages/SessionDetail.tsx index 6d6fbeba..642b7e53 100644 --- a/packages/frontend/src/pages/SessionDetail.tsx +++ b/packages/frontend/src/pages/SessionDetail.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router'; import ErrorState from '../components/ErrorState.js'; import MessageTable from '../components/MessageTable.js'; -import RequestDrawer from '../components/RequestDrawer.js'; +import RequestDrawerStack from '../components/RequestDrawerStack.js'; import { DETAILED_COLUMNS, type MessageRow } from '../components/message-table-types.js'; import { Badge, Button, Panel, Skel } from '../components/ui.js'; import { t } from '../i18n/index.js'; @@ -283,7 +283,7 @@ export default function SessionDetail() {
)} - setSelected(null)} /> + setSelected(null)} /> ); } diff --git a/packages/frontend/src/pages/Topology.tsx b/packages/frontend/src/pages/Topology.tsx index 94e20c5a..431c05ab 100644 --- a/packages/frontend/src/pages/Topology.tsx +++ b/packages/frontend/src/pages/Topology.tsx @@ -12,11 +12,18 @@ import { analyticsPing } from '../services/sse.js'; import { useResource } from '../lib/use-resource.js'; import { useSignal } from '../lib/signal.js'; import { t } from '../i18n/index.js'; -import { buildTopologyGraph, topologyLegend } from './topology-adapter.js'; +import { + buildTopologyGraph, + filterTopology, + topologyLegend, + topologyProviderTypes, +} from './topology-adapter.js'; import '../styles/canvas.css'; const RANGE_KEY = 'thinker.topologyRange'; const MODE_KEY = 'thinker.topologyMode'; +const HARNESS_KEY = 'thinker.topologyHarness'; +const PROVIDER_KEY = 'thinker.topologyProvider'; /** * Recent: every node over the window. Traffic: only what carried it. Live: * requests in flight. Interval: requests that started or ended between two instants. @@ -54,6 +61,23 @@ function savedMode(): Mode { } } +function saved(key: string): string { + try { + return localStorage.getItem(key) ?? ''; + } catch { + return ''; + } +} + +function remember(key: string, value: string) { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + /* per-viewer convenience only */ + } +} + function savedRange(): { count: number; unit: Unit } { try { return parseRange(localStorage.getItem(RANGE_KEY)) ?? { count: 24, unit: 'h' }; @@ -88,6 +112,9 @@ export default function Topology() { return () => clearTimeout(timer); }, [span]); const [mode, setMode] = useState(savedMode); + // Narrow the canvas to one harness and/or one provider type ('' = all). + const [harnessFilter, setHarnessFilter] = useState(() => saved(HARNESS_KEY)); + const [providerFilter, setProviderFilter] = useState(() => saved(PROVIDER_KEY)); const live = mode === 'live'; // Interval bounds as `datetime-local` values; the fetch waits for a valid pair. const [bounds, setBounds] = useState({ from: '', to: '' }); @@ -133,13 +160,30 @@ export default function Topology() { () => topology.data ? buildTopologyGraph( - { ...topology.data, positions: { ...topology.data.positions, ...moved } }, + { + ...filterTopology(topology.data, { + harness: harnessFilter, + provider: providerFilter, + }), + positions: { ...topology.data.positions, ...moved }, + }, { activeOnly: mode === 'traffic', live }, ) : { nodes: [], edges: [] }, - [topology.data, mode, live, moved], + [topology.data, mode, live, moved, harnessFilter, providerFilter], ); const legend = useMemo(() => topologyLegend(), []); + const harnessOptions = useMemo( + () => + (topology.data?.harnesses ?? []) + .map((h) => ({ value: h.id, label: h.displayName || h.name })) + .sort((a, b) => a.label.localeCompare(b.label)), + [topology.data], + ); + const providerOptions = useMemo( + () => (topology.data ? topologyProviderTypes(topology.data) : []), + [topology.data], + ); const hint = topology.loading && !topology.data @@ -191,6 +235,46 @@ export default function Topology() { ))} + + {mode === 'interval' ? (
> }; const CD_RANGES = ['24h', '7d', '30d', '90d', '365d'] as const; @@ -162,7 +167,7 @@ const backLabelFor = (authType: string | undefined) => { return (kind && tMaybe(`providers.kind.${kind}`)) ?? t('providers.kind.providers'); }; -const formatRate = (rate: number | null) => (rate == null ? '—' : `${(rate * 100).toFixed(1)}%`); +const formatRate = (rate: number | null) => (rate == null ? '—' : formatPercent(rate)); /** Thin share bar + percentage used by the models and harnesses tables. */ function ShareBar({ pct }: { pct: number }) { @@ -176,42 +181,6 @@ function ShareBar({ pct }: { pct: number }) { ); } -function StatTile({ - label, - value, - extra, - title, - onClick, -}: { - label: ReactNode; - value: ReactNode; - extra?: ReactNode; - title?: string; - onClick?: () => void; -}) { - const body = ( - <> - {label} -
- {value} - {extra} -
- - ); - return onClick ? ( - - ) : ( -
{body}
- ); -} - const ConnectionDetail = () => { const { connectionId = '' } = useParams<{ connectionId: string }>(); const navigate = useNavigate(); @@ -273,9 +242,18 @@ const ConnectionDetail = () => { // A local server added as a custom provider shows its runtime's icon. localType ?? conn.provider, conn.label, + { active: conn.is_active, custom: isCustomProvider }, ); } - }, [conn?.provider, conn?.label, conn?.auth_type, providerDisplayName, localType]); + }, [ + conn?.provider, + conn?.label, + conn?.auth_type, + conn?.is_active, + isCustomProvider, + providerDisplayName, + localType, + ]); useEffect(() => () => setConnectionBreadcrumb(null), []); // Chart state (persisted in sessionStorage) @@ -289,25 +267,16 @@ const ConnectionDetail = () => { setChartRangeRaw(v); writeStorage(`chart-range:${connectionId}`, v); }; - const [chartView, setChartViewRaw] = useState(() => { + // The selected KPI card drives the chart (tab + status filter). + const [kpiView, setKpiViewRaw] = useState(() => { const v = readStorage(`chart-view:${connectionId}`); - return v === 'tokens' || v === 'cost' || v === 'requests' ? v : 'tokens'; + return v === 'tokens' || v === 'cost' || v === 'recovered' || v === 'failed' ? v : 'requests'; }); - const setChartView = (v: ChartTab) => { - setChartViewRaw(v); + const setKpiView = (v: KpiView) => { + setKpiViewRaw(v); writeStorage(`chart-view:${connectionId}`, v); }; - - // Attempts tab view: By HTTP status (default), attempt status or harness. - // Persisted per connection like the range and the active tab. - const [groupBy, setGroupByRaw] = useState(() => { - const v = readStorage(`chart-group:${connectionId}`); - return v === 'harness' || v === 'status' ? v : 'http'; - }); - const setGroupBy = (v: GroupBy) => { - setGroupByRaw(v); - writeStorage(`chart-group:${connectionId}`, v); - }; + const chartView: ChartTab = kpiChartTab(kpiView); // Every chart/summary query is scoped to this exact connection // (provider+auth_type+label, plus the tenant_providers id), so two @@ -340,28 +309,21 @@ const ConnectionDetail = () => { scope, enabled, ); - // Attempt world: every provider call on this connection, by its own - // outcome. The harness view shares the same universe so both stack to the - // same totals. - const attemptStatusTs = useResource( - () => getConnectionAttemptStatusTimeseries(a, p, chartRange, label, connectionId), - scope, - enabled, - ); - const attemptsByAgentTs = useResource( - () => getConnectionAttemptsByAgentTimeseries(a, p, chartRange, label, connectionId), + // Request world, as on the Overview: a request belongs to the connection of + // its concluding attempt (a rescue by another connection counts there). + const reliability = useResource( + () => getReliabilityStats(chartRange, undefined, connectionId), scope, enabled, ); - const httpStatusTs = useResource( - () => getConnectionAttemptHttpStatusTimeseries(a, p, chartRange, label, connectionId), + const statusTs = useResource( + () => getRequestStatusTimeseries(chartRange, undefined, connectionId), scope, enabled, ); - const breakdown = useResource( - () => getConnectionAttemptBreakdown(a, p, chartRange, label, connectionId), - scope, - enabled, + const filteredStatusTs = useMemo( + () => applyKpiFilter(statusTs.data, kpiView), + [statusTs.data, kpiView], ); const isByok = authType === 'api_key'; @@ -371,21 +333,6 @@ const ConnectionDetail = () => { { enabled: !!conn && isByok }, ); - const attemptTotals = (() => { - const ts = attemptStatusTs.data; - if (!ts) return { attempts: 0, succeeded: 0 }; - const successIdx = ts.keys.indexOf('success'); - let attempts = 0; - let succeeded = 0; - for (const b of ts.buckets) { - for (let i = 0; i < b.counts.length; i++) { - attempts += b.counts[i] ?? 0; - if (i === successIdx) succeeded += b.counts[i] ?? 0; - } - } - return { attempts, succeeded }; - })(); - // Harness tag selection for chart filtering (persisted in sessionStorage). // `null` means "no persisted preference" (→ default to all selected); a Set // — even an empty one — means an explicit user choice, so a genuine @@ -411,7 +358,6 @@ const ConnectionDetail = () => { ...(agentTimeseries.data?.agents ?? []), ...(agentMessageTimeseries.data?.agents ?? []), ...(agentCostTimeseries.data?.agents ?? []), - ...(attemptsByAgentTs.data?.agents ?? []), ]), ].sort(); @@ -656,12 +602,6 @@ const ConnectionDetail = () => { } }; - // Deep links into the Requests log, scoped to THIS connection and the - // card's current window, so the list matches what the card counted. - const requestsLink = (extra: string) => - `/messages?connections=${encodeURIComponent(connectionId)}&range=${chartRange}${extra}`; - const viewMore = {t('providers.detail.viewMore')}; - // A rejected fetch surfaces a retryable error state; a resolved-but-null // connection (unknown id / deleted) renders not-found. Neither may leave the // loading skeleton spinning forever. @@ -710,7 +650,6 @@ const ConnectionDetail = () => { const c = conn; const data = detail.data; - const b = breakdown.data; const totalCost = (() => { const ts = agentCostTimeseries.data; if (!ts) return undefined; @@ -724,12 +663,6 @@ const ConnectionDetail = () => { const emptyRow = (text: string) => (

{text}

); - const metaItem = (k: string, v: ReactNode) => ( - - {k} {v} - - ); - // ── Manage window ───────────────────────────────────────────── const windowIcon = provDef ? ( {providerIcon(provDef.id, 20)} @@ -1065,36 +998,8 @@ const ConnectionDetail = () => { {t('providers.detail.pageTitle', { provider: providerDisplayName, label: c.label })} -
-
-
-
- {isCustomProvider ? {t('providers.custom')} : null} - {c.is_active ? ( - {t('providers.active')} - ) : ( - {t('providers.inactive')} - )} -
-
-
- {metaItem(t('providers.detail.connectionName'), c.label)} - {metaItem(t('providers.detail.models'), c.cached_model_count)} - {metaItem( - t('providers.detail.firstConnection'), - c.connected_at ? formatTimeAgo(c.connected_at) : '—', - )} - {metaItem( - t('providers.detail.lastUsed'), - c.last_used_at ? formatTimeAgo(c.last_used_at) : '—', - )} -
-
+
- {tab === 'activity' && allAgents.length > 1 ? harnessFilter : null} - {tab === 'activity' ? ( - +
+ {allAgents.length > 1 ? harnessFilter : null}
+ {/* The Overview's reliability cards, on this connection's requests. */} + + {analytics.data ? (
- {/* Status/harness grouping only applies to the Attempts tab; - Tokens and Cost stay per-harness. */} - {chartView === 'requests' ? ( -
- {( - [ - ['http', t('providers.detail.byHttpStatus')], - ['status', t('providers.detail.byAttemptStatus')], - ['harness', t('providers.detail.byHarness')], - ] as const - ).map(([key, text]) => ( - - ))} -
- ) : null} - {(chartView !== 'requests' || groupBy === 'harness') && allAgents.length > 1 - ? harnessFilter - : null} - + chartView !== 'requests' && allAgents.length > 1 ? harnessFilter : undefined } />
diff --git a/packages/frontend/src/pages/topology-adapter.ts b/packages/frontend/src/pages/topology-adapter.ts index 9f8f9854..86c85af3 100644 --- a/packages/frontend/src/pages/topology-adapter.ts +++ b/packages/frontend/src/pages/topology-adapter.ts @@ -131,6 +131,90 @@ const harnessHref = (name: string) => `/harnesses/${encodeURIComponent(name)}`; const providerName = (provider: string, fallback: string) => PROVIDERS.find((p) => p.id === provider.toLowerCase())?.name ?? fallback; +/** Provider types present in the topology, as select options sorted by label. */ +export function topologyProviderTypes(data: Topology): Array<{ value: string; label: string }> { + const seen = new Map(); + for (const p of data.providers) { + const key = p.provider.toLowerCase(); + if (!seen.has(key)) seen.set(key, providerName(p.provider, p.name)); + } + return [...seen] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * Narrows the topology to one harness and/or one provider type (empty: all). + * Keeps the chosen harnesses, the clients calling them, their connections of + * the chosen type and everything downstream of those connections. + */ +export function filterTopology( + data: Topology, + filter: { harness?: string; provider?: string }, +): Topology { + if (!filter.harness && !filter.provider) return data; + const harnessIds = new Set(data.harnesses.map((h) => h.id)); + const providerIds = new Set( + data.providers + .filter((p) => !filter.provider || p.provider.toLowerCase() === filter.provider) + .map((p) => p.id), + ); + const allProviders = new Set(data.providers.map((p) => p.id)); + let harnesses = data.harnesses.filter((h) => !filter.harness || h.id === filter.harness); + if (filter.provider) { + const linked = new Set( + data.links + .filter((l) => providerIds.has(l.target) && harnessIds.has(l.source)) + .map((l) => l.source), + ); + harnesses = harnesses.filter((h) => linked.has(h.id)); + } + const keep = new Set(harnesses.map((h) => h.id)); + // Clients of the kept harnesses. + for (const l of data.links) + if (keep.has(l.target) && !harnessIds.has(l.source)) keep.add(l.source); + // Connections of the kept harnesses (of the chosen type), then downstream. + const queue: string[] = []; + for (const l of data.links) { + if ( + harnessIds.has(l.source) && + keep.has(l.source) && + providerIds.has(l.target) && + !keep.has(l.target) + ) { + keep.add(l.target); + queue.push(l.target); + } + } + while (queue.length) { + const id = queue.shift()!; + for (const l of data.links) { + if ( + l.source !== id || + keep.has(l.target) || + harnessIds.has(l.target) || + allProviders.has(l.target) + ) + continue; + keep.add(l.target); + queue.push(l.target); + } + } + const only = (list: T[] | undefined) => + list?.filter((n) => keep.has(n.id)); + return { + ...data, + clients: only(data.clients)!, + harnesses: only(data.harnesses)!, + providers: only(data.providers)!, + proxies: only(data.proxies)!, + servers: only(data.servers)!, + egress: only(data.egress)!, + destinations: only(data.destinations), + links: data.links.filter((l) => keep.has(l.source) && keep.has(l.target)), + }; +} + function linkTooltip(l: TopologyLink): string | undefined { const lines: string[] = []; for (const m of [...(l.models ?? [])].sort((a, b) => b.requests - a.requests)) { diff --git a/packages/frontend/src/services/api/analytics.ts b/packages/frontend/src/services/api/analytics.ts index 066a552b..d16b08ca 100644 --- a/packages/frontend/src/services/api/analytics.ts +++ b/packages/frontend/src/services/api/analytics.ts @@ -323,10 +323,16 @@ export function getAttemptTimeseries(range = '7d', agentName?: string): Promise< }) as Promise; } -export function getReliabilityStats(range = '7d', agentName?: string): Promise { +export function getReliabilityStats( + range = '7d', + agentName?: string, + /** Only requests concluded on this connection (`tenant_providers.id`). */ + connectionId?: string, +): Promise { return fetchJson(RELIABILITY_PATHS.stats, { range, ...(agentName ? { agent_name: agentName } : {}), + ...(connectionId ? { connection_id: connectionId } : {}), }) as Promise; } @@ -337,11 +343,14 @@ export function getReliabilityStats(range = '7d', agentName?: string): Promise { return fetchJson(RELIABILITY_PATHS.requestStatusTimeseries, { range, by: 'disposition', ...(agentName ? { agent_name: agentName } : {}), + ...(connectionId ? { connection_id: connectionId } : {}), }) as Promise; } diff --git a/packages/frontend/src/services/chart-utils.ts b/packages/frontend/src/services/chart-utils.ts index 3938b9ee..113905e7 100644 --- a/packages/frontend/src/services/chart-utils.ts +++ b/packages/frontend/src/services/chart-utils.ts @@ -12,6 +12,7 @@ import { useEffect, useRef, type RefObject } from 'react'; import uPlot from 'uplot'; import { formatNumber } from './formatters.js'; import { dayMonth } from './overview-format.js'; +import { intlLocale } from '../i18n/index.js'; export interface UseChartLifecycleOptions { /** Ref to the element uPlot renders into (its width drives resizes). */ @@ -127,6 +128,23 @@ export function createFormatLegendTimestamp( }; } +/** Token axis in thousands: "0", "250k", "1,5m" (above 999k). */ +export function formatAxisKTokens(val: number): string { + if (val == null || isNaN(val)) return ''; + if (val === 0) return '0'; + const fmt = (n: number) => + new Intl.NumberFormat(intlLocale(), { maximumFractionDigits: 1 }).format(n); + return val >= 999_500 ? `${fmt(val / 1_000_000)}m` : `${fmt(val / 1_000)}k`; +} + +/** Cost axis in dollars, numbers only (the chart title names the unit). */ +export function formatAxisDollars(val: number): string { + if (val == null || isNaN(val)) return ''; + return new Intl.NumberFormat(intlLocale(), { + maximumFractionDigits: Math.abs(val) >= 1 || val === 0 ? 2 : 3, + }).format(val); +} + export function formatLegendTokens(_u: uPlot, val: number): string { if (val == null || isNaN(val)) return ''; return formatNumber(Math.round(val)); diff --git a/packages/frontend/src/services/connection-breadcrumb-store.ts b/packages/frontend/src/services/connection-breadcrumb-store.ts index 202425fe..b624ddc4 100644 --- a/packages/frontend/src/services/connection-breadcrumb-store.ts +++ b/packages/frontend/src/services/connection-breadcrumb-store.ts @@ -10,6 +10,10 @@ interface ConnectionBreadcrumb { label: string | null; backLink: string; backLabel: Label; + /** Account status, shown as a badge next to the title (null: unknown). */ + active: boolean | null; + /** A custom provider, flagged next to the title. */ + custom: boolean; } export const connectionBreadcrumbSignal = signal({ @@ -18,6 +22,8 @@ export const connectionBreadcrumbSignal = signal({ label: null, backLink: '/providers/subscriptions', backLabel: () => t('providers.kind.subscriptions'), + active: null, + custom: false, }); export function connectionBreadcrumbName(): string | null { @@ -47,6 +53,7 @@ export function setConnectionBreadcrumb( backLabel?: Label, providerId?: string, label?: string, + status?: { active: boolean; custom: boolean }, ): void { connectionBreadcrumbSignal.set((prev) => ({ name, @@ -54,5 +61,7 @@ export function setConnectionBreadcrumb( label: label ?? null, backLink: backLink ?? prev.backLink, backLabel: backLabel ?? prev.backLabel, + active: status?.active ?? null, + custom: status?.custom ?? false, })); } diff --git a/packages/frontend/src/styles/shell.css b/packages/frontend/src/styles/shell.css index d7cb26ac..5dadebbd 100644 --- a/packages/frontend/src/styles/shell.css +++ b/packages/frontend/src/styles/shell.css @@ -17,6 +17,9 @@ min-height: 0; } .app-content { + /* Containing block for absolute descendants (e.g. sr-only live regions), + so they can't stretch the document past the viewport. */ + position: relative; flex: 1; min-height: 0; overflow: auto; @@ -443,7 +446,7 @@ /* Stat tiles and grids */ .stat-tiles { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } .stat-tile { @@ -452,6 +455,10 @@ border: 1px solid var(--border); border-radius: var(--radius-surface); } + .stat-tile.is-selected { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); + } .stat-tile .stat-label { color: var(--text-muted); font-size: 12px; diff --git a/packages/frontend/tests/components/AnalyticsChartSurfaces.test.tsx b/packages/frontend/tests/components/AnalyticsChartSurfaces.test.tsx index 0483df7e..6a58e0be 100644 --- a/packages/frontend/tests/components/AnalyticsChartSurfaces.test.tsx +++ b/packages/frontend/tests/components/AnalyticsChartSurfaces.test.tsx @@ -41,7 +41,18 @@ vi.mock('../../src/services/theme.js', () => ({ getHslA: (name: string, alpha: number) => `hsla(var(${name}), ${alpha})`, })); -vi.mock('../../src/services/chart-utils.js', () => ({ +vi.mock('../../src/services/chart-utils.js', async () => ({ + // The real axis formatters: they are pure and the assertions read them. + formatAxisKTokens: ( + await vi.importActual( + '../../src/services/chart-utils.js' + ) + ).formatAxisKTokens, + formatAxisDollars: ( + await vi.importActual( + '../../src/services/chart-utils.js' + ) + ).formatAxisDollars, useChartLifecycle: (opts: any) => { capturedLifecycleOpts = opts; }, @@ -93,7 +104,11 @@ import GlobalOverviewSkeleton from '../../src/components/GlobalOverviewSkeleton' import MultiAgentTokenChart, { AGENT_COLORS } from '../../src/components/MultiAgentTokenChart'; import ProviderChartCard from '../../src/components/ProviderChartCard'; import UnifiedChartCard from '../../src/components/UnifiedChartCard'; -import ReliabilityKpiCards from '../../src/components/ReliabilityKpiCards'; +import ReliabilityKpiCards, { + applyKpiFilter, + kpiChartTab, + kpiDetailsHref, +} from '../../src/components/ReliabilityKpiCards'; import ErrorClassCard from '../../src/components/ErrorClassCard'; import ReliabilityChart from '../../src/components/ReliabilityChart'; @@ -120,7 +135,7 @@ const buildLazyChart = async () => { }; describe('analytics chart surface components', () => { - it('renders the recovered requests tab from the request-status timeseries', async () => { + it('renders the recovered series without stat tabs', async () => { const onTabChange = vi.fn(); const recoveredTimeseries = { range: '7d', @@ -132,27 +147,17 @@ describe('analytics chart surface components', () => { , ); - expect( - screen - .getAllByText('Recovered requests')[0]! - .closest('button') - ?.classList.contains('is-active'), - ).toBe(true); - expect(screen.getByText('15')).toBeDefined(); - expect(screen.getByText('+20%')).toBeDefined(); - fireEvent.click(screen.getAllByText('Requests')[0]!); - expect(onTabChange).toHaveBeenCalledWith('requests'); + // The KPI cards pick the chart view: the card has no stat tabs. + expect(screen.queryAllByRole('tab')).toHaveLength(0); + expect(onTabChange).not.toHaveBeenCalled(); await buildLazyChart(); // Stacked series are reversed (top-of-stack first). const labels = capturedChartOpts.series.slice(1).map((s: { label: string }) => s.label); @@ -335,19 +340,26 @@ describe('analytics chart surface components', () => { }} />, ); - expect(screen.getByText('Success rate')).toBeDefined(); + expect(screen.getByText('Successful requests')).toBeDefined(); expect(screen.getByText('80.0%')).toBeDefined(); // Recovered share = 3 / 100. expect(screen.getByText('Recovered requests')).toBeDefined(); expect(screen.getByText('3.0%')).toBeDefined(); expect(screen.getByText('Failed requests')).toBeDefined(); - expect(screen.getByText('Recovered by Fallback')).toBeDefined(); - expect(screen.getByText('3')).toBeDefined(); + // One recovered tile (no duplicate "Recovered by Fallback"), in the + // warning color since it is above zero. + expect(screen.queryByText('Recovered by Fallback')).toBeNull(); + expect(screen.getByText('3').className).toContain('text-warn'); + // Successful requests = total - failed, with the rate beside it. + expect(screen.getByText('100')).toBeDefined(); + // Zero failures keep the neutral color. + expect(screen.getByText('0').className).toContain('text-ink'); expect(document.body.textContent).not.toContain('Autofix'); // Four cards use the base responsive grid; an inline grid-template-columns // would override the phone breakpoints. - const grid = screen.getByText('Success rate').closest('.stat-tiles') as HTMLElement; - expect(grid.querySelectorAll('.stat-tile')).toHaveLength(4); + const grid = screen.getByText('Successful requests').closest('.stat-tiles') as HTMLElement; + // Without cost/tokens the usage cards are hidden. + expect(grid.querySelectorAll('.stat-tile')).toHaveLength(3); expect(grid.style.gridTemplateColumns).toBe(''); unmount(); @@ -361,7 +373,7 @@ describe('analytics chart surface components', () => { }} />, ); - expect(screen.getAllByText('-50%').length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText('-50%')).toHaveLength(1); }); it('colors the fallback and failed KPI values only when they are above zero', () => { @@ -374,79 +386,110 @@ describe('analytics chart surface components', () => { errors_remaining: { value: errors, previous: 0 }, }); const { unmount } = render(); - expect(valueOf('Recovered by Fallback').className).toContain('text-ink'); + expect(valueOf('Recovered requests').className).toContain('text-ink'); expect(valueOf('Failed requests').className).toContain('text-ink'); unmount(); render(); - expect(valueOf('Recovered by Fallback').className).toContain('text-warn'); + expect(valueOf('Recovered requests').className).toContain('text-warn'); expect(valueOf('Failed requests').className).toContain('text-danger'); }); - it('deep-links the recovered KPI cards when an agent scope is given', () => { - const base = { + it('renders cost and token cards after Successful requests and selects views', () => { + const onSelect = vi.fn(); + const stats = { success_rate: { value: 0.8, previous: 0.7 }, - errors_remaining: { value: 0, previous: 0 }, + errors_remaining: { value: 2, previous: 1 }, + fallback_saves: { value: 3, previous: 0 }, + total_requests: { value: 100, previous: 0 }, }; - render( - , - ); - fireEvent.click(screen.getByText('Failed requests').closest('.stat-tile')!); - expect(routerNavigate).toHaveBeenCalledWith( - '/messages?agent=demo-agent&range=7d&status=failed', - ); - fireEvent.click(screen.getByText('Recovered by Fallback').closest('.stat-tile')!); - expect(routerNavigate).toHaveBeenCalledWith( - '/messages?agent=demo-agent&range=7d&status=ok&trigger=fallback', - ); - routerNavigate.mockClear(); - fireEvent.click(screen.getByText('Recovered requests').closest('.stat-tile')!); - expect(routerNavigate).toHaveBeenCalledWith( - '/messages?agent=demo-agent&range=7d&status=ok&trigger=fallback', - ); - - const failedCard = screen.getByText('Failed requests').closest('.stat-tile')!; - expect(failedCard.getAttribute('role')).toBe('link'); - expect((failedCard as HTMLElement).tabIndex).toBe(0); - fireEvent.keyDown(failedCard, { key: 'Enter' }); - expect(routerNavigate).toHaveBeenLastCalledWith( - '/messages?agent=demo-agent&range=7d&status=failed', + const props = { + stats, + cost: { value: 2.71, trendPct: -5 }, + tokens: { value: 5000, trendPct: 12 }, + onSelect, + }; + const { rerender } = render(); + const labels = [...document.querySelectorAll('.stat-tile .stat-label')].map( + (el) => el.textContent, ); + expect(labels).toEqual([ + 'Successful requests', + 'Cost', + 'Token usage', + 'Recovered requests', + 'Failed requests', + ]); + expect(screen.getByText('$2.71')).toBeDefined(); + expect(screen.getByText('-5%')).toBeDefined(); + expect(screen.getByText('+12%')).toBeDefined(); + // No "new" badge, info icon or "View more" link. + expect(document.body.textContent).not.toMatch(/\bnew\b|View more/i); + expect(document.querySelector('[data-testid="info-tooltip"]')).toBeNull(); + + const tile = (label: string) => screen.getByText(label).closest('.stat-tile')!; + expect(tile('Successful requests').getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(tile('Cost')); + expect(onSelect).toHaveBeenLastCalledWith('cost'); + fireEvent.click(tile('Failed requests')); + expect(onSelect).toHaveBeenLastCalledWith('failed'); + expect(routerNavigate).not.toHaveBeenCalled(); + + rerender(); + expect(tile('Failed requests').classList.contains('is-selected')).toBe(true); + expect(tile('Successful requests').getAttribute('aria-pressed')).toBe('false'); + }); - const recoveredCard = screen.getByText('Recovered requests').closest('.stat-tile')!; - const tooltip = recoveredCard.querySelector('[data-testid="info-tooltip"]')!; - const navigationCount = routerNavigate.mock.calls.length; - fireEvent.keyDown(tooltip, { key: 'Enter' }); - fireEvent.click(tooltip); - expect(routerNavigate).toHaveBeenCalledTimes(navigationCount); + it('shows the usage cards with dashes while reliability stats load', () => { + render(); + expect(screen.getByText('$1.00')).toBeDefined(); + expect(screen.getAllByText('—')).toHaveLength(3); }); - it('describes global KPI links as covering all harnesses', () => { - render( - , + it('links each chart to the report behind it, beside the title', () => { + expect(kpiDetailsHref('recovered', { agentName: 'demo agent', range: '7d' })).toBe( + '/messages?agent=demo%20agent&range=7d&status=ok&trigger=fallback', ); + expect(kpiDetailsHref('failed', { connectionId: 'conn-1', range: '30d' })).toBe( + '/messages?connections=conn-1&range=30d&status=failed', + ); + expect(kpiDetailsHref('cost', { range: '24h' })).toBe('/messages?range=24h'); + expect(kpiDetailsHref('requests')).toBe('/messages'); - expect( - screen.getByText('Recovered requests').closest('.stat-tile')?.getAttribute('title'), - ).toBe('View recovered requests across all harnesses'); - expect(screen.getByText('Failed requests').closest('.stat-tile')?.getAttribute('title')).toBe( - 'View failed requests across all harnesses', + const { unmount } = render( + , ); + expect(screen.getByText('Failed requests')).toBeDefined(); + fireEvent.click(screen.getByText('View details')); + expect(routerNavigate).toHaveBeenCalledWith('/messages?status=failed'); + unmount(); + }); + + it('narrows the request-status series to the selected KPI', () => { + const ts = { + range: '7d', + by: 'disposition', + keys: ['success', 'fallback', 'error'], + buckets: [{ bucket: 'b', counts: [5, 2, 1] }], + }; + // Successful requests opens the whole Requests chart. + expect(applyKpiFilter(ts, 'requests')).toBe(ts); + expect(applyKpiFilter(ts, 'cost')).toBe(ts); + expect(applyKpiFilter(ts, 'recovered')).toMatchObject({ + keys: ['fallback'], + buckets: [{ bucket: 'b', counts: [2] }], + }); + expect(applyKpiFilter(ts, 'failed')?.keys).toEqual(['error']); + expect(kpiChartTab('failed')).toBe('requests'); + expect(kpiChartTab('tokens')).toBe('tokens'); }); it('renders and sorts error classes, then renders the empty state', async () => { @@ -697,7 +740,12 @@ describe('analytics chart surface components', () => { expect(hover).toHaveBeenCalledWith({ openai: 10, anthropic: 5 }); // Tooltip rows render after a hover with non-zero values. - expect(screen.getByText('openai')).toBeDefined(); + expect(screen.getAllByText('openai').length).toBeGreaterThanOrEqual(2); + // The legend lists every series, so each view keeps the same height. + const legend = [...document.querySelectorAll('.rel-chart__legend-item')].map( + (e) => e.textContent, + ); + expect(legend).toEqual(['openai', 'anthropic']); expect(screen.getByText('Total')).toBeDefined(); }); @@ -713,7 +761,14 @@ describe('analytics chart surface components', () => { buildCapturedChart(); // Token axis uses formatLegendTokens (mocked to String()). - expect(capturedChartOpts.axes[1].values({}, [1234])).toEqual(['1234']); + // Token axis in thousands; "m" above 999k. + expect(capturedChartOpts.axes[1].values({}, [0, 1234, 250_000, 999_000])).toEqual([ + '0', + '1.2k', + '250k', + '999k', + ]); + expect(capturedChartOpts.axes[1].values({}, [1_000_000, 1_500_000])).toEqual(['1m', '1.5m']); expect( capturedChartOpts.cursor.move( { posToIdx: () => 0, data: [[1700000000]], valToPos: () => 5 }, @@ -739,11 +794,10 @@ describe('analytics chart surface components', () => { buildCapturedChart(); expect(capturedChartData[1][0]).toBe(0); - // Cost y-axis uses formatCost: whole/cent values render normally, but a - // sub-cent value surfaces as "< $0.01" instead of rounding to "$0.00". - expect(capturedChartOpts.axes[1].values({}, [1.23])).toEqual(['$1.23']); - expect(capturedChartOpts.axes[1].values({}, [0.004])).toEqual(['< $0.01']); - expect(capturedChartOpts.axes[1].values({}, [0])).toEqual(['$0.00']); + // Cost y-axis: plain dollars, no currency prefix (the title says "US $"). + expect(capturedChartOpts.axes[1].values({}, [1.23])).toEqual(['1.23']); + expect(capturedChartOpts.axes[1].values({}, [0.004])).toEqual(['0.004']); + expect(capturedChartOpts.axes[1].values({}, [0, 1500])).toEqual(['0', '1,500']); expect( capturedChartOpts.cursor.move({ posToIdx: () => null, data: [[]], valToPos: () => 0 }, 42, 9), ).toEqual([42, 9]); diff --git a/packages/frontend/tests/components/MessageTable.test.tsx b/packages/frontend/tests/components/MessageTable.test.tsx index b5c69c25..245ee3a9 100644 --- a/packages/frontend/tests/components/MessageTable.test.tsx +++ b/packages/frontend/tests/components/MessageTable.test.tsx @@ -112,17 +112,18 @@ describe('MessageTable', () => { , ); const headers = container.querySelectorAll('th'); - expect(headers.length).toBe(9); - // Identity first (status, when, what), then mechanics. + // The overviews' compact set: no Attempts or Request columns. + expect(headers.length).toBe(7); expect(headers[0]!.textContent).toContain('Status'); expect(headers[1]!.textContent).toContain('Date'); expect(headers[2]!.textContent).toContain('Model'); - expect(headers[3]!.textContent).toBe('Attempts'); - expect(headers[4]!.textContent).toContain('Request'); - expect(headers[5]!.textContent).toContain('Cost'); - expect(headers[6]!.textContent).toContain('Tokens'); - expect(headers[7]!.textContent).toContain('Cache'); - expect(headers[8]!.textContent).toContain('Latency'); + expect(headers[3]!.textContent).toContain('Cost'); + expect(headers[4]!.textContent).toContain('Tokens'); + expect(headers[5]!.textContent).toContain('Cache'); + expect(headers[6]!.textContent).toContain('Latency'); + const text = [...headers].map((h) => h.textContent); + expect(text).not.toContain('Attempts'); + expect(text).not.toContain('Request'); }); it('renders detailed column headers with tooltips', () => { @@ -154,7 +155,7 @@ describe('MessageTable', () => { const { container } = render( , ); - const tokensHeader = container.querySelectorAll('th')[6]!; // 'totalTokens' is at index 6 in COMPACT_COLUMNS + const tokensHeader = container.querySelectorAll('th')[4]!; // 'totalTokens' is at index 4 in COMPACT_COLUMNS expect(tokensHeader.textContent).toBe('Tokens'); expect(tokensHeader.querySelector('[data-testid="info-tooltip"]')).toBeNull(); }); diff --git a/packages/frontend/tests/components/RequestDrawerStack.test.tsx b/packages/frontend/tests/components/RequestDrawerStack.test.tsx new file mode 100644 index 00000000..5674d057 --- /dev/null +++ b/packages/frontend/tests/components/RequestDrawerStack.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('../../src/components/RequestDrawer.js', () => ({ + default: (props: Record) => ( +
+ + +
+ ), +})); + +import RequestDrawerStack from '../../src/components/RequestDrawerStack'; + +const wins = () => + screen.queryAllByTestId('win').map((w) => `${w.dataset.id}:${w.dataset.pinned}`); + +describe('RequestDrawerStack', () => { + it('reuses the unpinned window, and opens a new one after pinning', () => { + const onClose = vi.fn(); + const { rerender } = render(); + rerender(); + expect(wins()).toEqual(['b:false']); + + fireEvent.click(screen.getByText('pin-b')); + expect(onClose).toHaveBeenCalledTimes(1); + rerender(); + expect(wins()).toEqual(['b:true']); + + rerender(); + rerender(); + expect(wins()).toEqual(['b:true', 'd:false']); + }); + + it('closes a pinned window without clearing the selection', () => { + const onClose = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByText('pin-a')); + rerender(); + onClose.mockClear(); + fireEvent.click(screen.getByText('close-a')); + expect(onClose).not.toHaveBeenCalled(); + expect(wins()).toEqual(['b:false']); + fireEvent.click(screen.getByText('close-b')); + expect(onClose).toHaveBeenCalled(); + expect(wins()).toEqual([]); + }); + + it('unpinning makes that window follow the selection and drops the other free one', () => { + const onClose = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByText('pin-a')); + rerender(); + fireEvent.click(screen.getByText('pin-a')); + expect(wins()).toEqual(['a:false']); + rerender(); + expect(wins()).toEqual(['c:false']); + }); +}); diff --git a/packages/frontend/tests/i18n/overview.pt-BR.test.tsx b/packages/frontend/tests/i18n/overview.pt-BR.test.tsx index 5b6d9f56..c7d735ad 100644 --- a/packages/frontend/tests/i18n/overview.pt-BR.test.tsx +++ b/packages/frontend/tests/i18n/overview.pt-BR.test.tsx @@ -162,8 +162,9 @@ describe('Overview in pt-BR', () => { expect(screen.getByText('Nome da conexão')).toBeDefined(); expect(screen.getByText('Inativa')).toBeDefined(); expect(screen.getByText('Padrão')).toBeDefined(); - expect(screen.getByText('Taxa de sucesso', { selector: '.stat-label' })).toBeDefined(); - expect(screen.getByText('Recuperadas por fallback')).toBeDefined(); + expect(screen.getByText('Requisições funcionais', { selector: '.stat-label' })).toBeDefined(); + expect(screen.getByText('Requisições recuperadas', { selector: '.stat-label' })).toBeDefined(); + expect(screen.queryByText('Recuperadas por fallback')).toBeNull(); expect(screen.getByText('Últimos 7 dias')).toBeDefined(); // Intl percent in pt-BR: decimal comma. expect(screen.getByText('95,5%')).toBeDefined(); diff --git a/packages/frontend/tests/pages/Overview-limits.test.tsx b/packages/frontend/tests/pages/Overview-limits.test.tsx index 4f076a83..3ba7aab6 100644 --- a/packages/frontend/tests/pages/Overview-limits.test.tsx +++ b/packages/frontend/tests/pages/Overview-limits.test.tsx @@ -266,14 +266,15 @@ describe('Overview - trend badges and status display', () => { it('prefers the details payload rows once they load', async () => { mockGetOverview.mockResolvedValue(overviewData); mockGetOverviewDetails.mockResolvedValue({ - recent_activity: [{ ...overviewData.recent_activity[0], id: 'msg-from-details' }], + recent_activity: [ + { ...overviewData.recent_activity[0], id: 'msg-from-details', model: 'details-model' }, + ], cost_by_model: [], }); const { container } = renderOverview(); await vi.waitFor(() => { - // The message cell shows the first 8 characters of the row id. - expect(container.textContent).toContain('msg-from'); - expect(container.textContent).not.toContain('msg-1234'); + // No Request (id) column on the overview: tell the rows apart by model. + expect(container.textContent).toContain('details-model'); expect(container.querySelector('.status-shape[title="Success"]')).not.toBeNull(); }); }); diff --git a/packages/frontend/tests/pages/Overview.test.tsx b/packages/frontend/tests/pages/Overview.test.tsx index 69f0779b..6035de28 100644 --- a/packages/frontend/tests/pages/Overview.test.tsx +++ b/packages/frontend/tests/pages/Overview.test.tsx @@ -456,7 +456,6 @@ describe('Overview', () => { await vi.waitFor(() => { expect(container.textContent).toContain('$3.50'); expect(container.textContent).toContain('50000'); - expect(container.textContent).toContain('42'); }); }); @@ -466,7 +465,6 @@ describe('Overview', () => { await vi.waitFor(() => { expect(container.textContent).toContain('+12%'); expect(container.textContent).toContain('-5%'); - expect(container.textContent).toContain('+8%'); }); }); @@ -510,8 +508,9 @@ describe('Overview', () => { const { container } = renderOverview(); await vi.waitFor(() => { expect(container.textContent).toContain('Recent Requests'); - expect(container.textContent).toContain('msg-1234'); expect(container.textContent).toContain('gpt-4o'); + // No Request (id) or Attempts columns on the overview. + expect(container.textContent).not.toContain('msg-1234'); }); }); @@ -617,23 +616,40 @@ describe('Overview', () => { await vi.waitFor(() => { expect(mockPerProviderMessages).toHaveBeenCalledTimes(1); - expect(container.querySelectorAll('.chart-card__stat').length).toBe(4); + expect(container.querySelectorAll('.stat-tile').length).toBe(5); }); - const stats = container.querySelectorAll('.chart-card__stat'); - fireEvent.click(stats[2]); // cost (Requests=0, Recovered=1, Cost=2, Token usage=3) + fireEvent.click(screen.getByText('Cost', { selector: '.stat-label' }).closest('.stat-tile')!); await vi.waitFor(() => { expect(mockPerProviderCosts).toHaveBeenCalledWith('test-agent', '30d'); }); }); - it('has clickable stat headers for requests, recovered requests, cost and tokens', async () => { + it('drives the chart from the KPI cards (no stat tabs on the chart)', async () => { mockGetOverview.mockResolvedValue(overviewData); const { container } = renderOverview(); await vi.waitFor(() => { - const clickable = container.querySelectorAll('.chart-card__stat'); - expect(clickable.length).toBe(4); + expect(container.querySelectorAll('.stat-tile').length).toBe(5); + }); + expect(container.querySelector('.chart-card__stat')).toBeNull(); + const labels = [...container.querySelectorAll('.stat-tile .stat-label')].map( + (el) => el.textContent, + ); + expect(labels).toEqual([ + 'Successful requests', + 'Cost', + 'Token usage', + 'Recovered requests', + 'Failed requests', + ]); + // Successful requests is selected by default and shows the Requests chart. + const tile = (label: string) => + screen.getByText(label, { selector: '.stat-label' }).closest('.stat-tile')!; + expect(tile('Successful requests').getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(tile('Failed requests')); + await vi.waitFor(() => { + expect(tile('Failed requests').getAttribute('aria-pressed')).toBe('true'); + expect(tile('Successful requests').getAttribute('aria-pressed')).toBe('false'); }); - expect(screen.getAllByText('Recovered requests').length).toBeGreaterThan(0); }); it('loads and renders the reliability KPIs and recovered tab for every tenant', async () => { @@ -644,10 +660,10 @@ describe('Overview', () => { expect(screen.getAllByText('Success rate').length).toBeGreaterThan(0); }); expect(mockGetReliabilityStats).toHaveBeenCalledWith('30d', 'test-agent'); - // Tab + KPI cards share the label; both surfaces are present. - expect(screen.getAllByText('Recovered requests').length).toBeGreaterThanOrEqual(2); + // Only the KPI card carries the label: the chart has no recovered tab. + expect(screen.getAllByText('Recovered requests')).toHaveLength(1); expect(screen.queryByText('Recovered by Autofix')).toBeNull(); - expect(screen.getByText('Recovered by Fallback')).toBeDefined(); + expect(screen.queryByText('Recovered by Fallback')).toBeNull(); }); it('switches chart view when stat header clicked', async () => { @@ -659,24 +675,25 @@ describe('Overview', () => { const { container } = renderOverview(); // The Requests view is status-only now (no per-provider request chart); // usage views render the multi-provider chart. Order: - // Requests / Recovered requests / Cost / Tokens. + // The KPI cards pick the view: Cost and Token usage are cards now. await vi.waitFor(() => { - expect(container.querySelectorAll('.chart-card__stat').length).toBe(4); + expect(container.querySelectorAll('.stat-tile').length).toBe(5); }); expect(container.querySelector('[data-testid="multi-agent-chart"]')).toBeNull(); - const stats = container.querySelectorAll('.chart-card__stat'); + const tile = (label: string) => + screen.getByText(label, { selector: '.stat-label' }).closest('.stat-tile')!; - fireEvent.click(stats[2]); // cost + fireEvent.click(tile('Cost')); await vi.waitFor(() => { - const active = container.querySelector('.chart-card__stat.is-active'); - expect(active?.textContent).toContain('Cost'); + expect(tile('Cost').getAttribute('aria-pressed')).toBe('true'); + // The chart title follows the card; Cost names its unit. + expect(screen.getByText('Cost (US $)')).toBeDefined(); }); - fireEvent.click(stats[3]); // tokens — renders the token-view chart + fireEvent.click(tile('Token usage')); // renders the token-view chart await vi.waitFor(() => { - const active = container.querySelector('.chart-card__stat.is-active'); - expect(active?.textContent).toContain('Token usage'); + expect(tile('Token usage').getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('[data-testid="multi-agent-chart"]')).not.toBeNull(); }); }); @@ -814,7 +831,7 @@ describe('Overview', () => { mockGetOverview.mockResolvedValue({ ...emptyOverviewData, has_providers: true }); const { container } = renderOverview(); await vi.waitFor(() => { - expect(container.querySelector('.chart-card__stat')).not.toBeNull(); + expect(container.querySelector('.chart-card')).not.toBeNull(); expect(container.textContent).toContain('$0.00'); }); }); @@ -823,7 +840,7 @@ describe('Overview', () => { mockGetOverview.mockResolvedValue({ ...overviewData, has_data: true, has_providers: false }); const { container } = renderOverview(); await vi.waitFor(() => { - expect(container.querySelector('.chart-card__stat')).not.toBeNull(); + expect(container.querySelector('.chart-card')).not.toBeNull(); expect(container.querySelector('.empty')).toBeNull(); }); }); diff --git a/packages/frontend/tests/pages/ProviderOverviewPages.test.tsx b/packages/frontend/tests/pages/ProviderOverviewPages.test.tsx index b84a4eb9..1827e77e 100644 --- a/packages/frontend/tests/pages/ProviderOverviewPages.test.tsx +++ b/packages/frontend/tests/pages/ProviderOverviewPages.test.tsx @@ -27,10 +27,8 @@ const apiMocks = vi.hoisted(() => ({ getProviderAnalytics: vi.fn(), getPerAgentTimeseries: vi.fn(), getPerAgentMessageTimeseries: vi.fn(), - getConnectionAttemptStatusTimeseries: vi.fn(), - getConnectionAttemptHttpStatusTimeseries: vi.fn(), - getConnectionAttemptBreakdown: vi.fn(), - getConnectionAttemptsByAgentTimeseries: vi.fn(), + getReliabilityStats: vi.fn(), + getRequestStatusTimeseries: vi.fn(), getPerAgentCostTimeseries: vi.fn(), })); @@ -99,18 +97,10 @@ vi.mock('../../src/services/api/analytics.js', () => ({ getPerAgentTimeseries: (...args: unknown[]) => apiMocks.getPerAgentTimeseries(...args), getPerAgentMessageTimeseries: (...args: unknown[]) => apiMocks.getPerAgentMessageTimeseries(...args), - getConnectionAttemptStatusTimeseries: (...args: unknown[]) => - apiMocks.getConnectionAttemptStatusTimeseries(...args), - getConnectionAttemptHttpStatusTimeseries: (...args: unknown[]) => - apiMocks.getConnectionAttemptHttpStatusTimeseries(...args), - getConnectionAttemptBreakdown: (...args: unknown[]) => - apiMocks.getConnectionAttemptBreakdown(...args), - getConnectionAttemptsByAgentTimeseries: (...args: unknown[]) => - apiMocks.getConnectionAttemptsByAgentTimeseries(...args), getPerAgentCostTimeseries: (...args: unknown[]) => apiMocks.getPerAgentCostTimeseries(...args), - getReliabilityStats: () => Promise.resolve(null), - getRequestStatusTimeseries: () => - Promise.resolve({ range: '7d', by: 'disposition', keys: [], buckets: [] }), + getReliabilityStats: (...args: unknown[]) => apiMocks.getReliabilityStats(...args), + getRequestStatusTimeseries: (...args: unknown[]) => + apiMocks.getRequestStatusTimeseries(...args), getPerProviderReliability: () => Promise.resolve([]), getPerModelReliability: () => Promise.resolve([]), getPerAgentReliability: () => Promise.resolve([]), @@ -678,6 +668,21 @@ const connectionAnalytics = { message_usage: overviewResponse.message_usage, }; +// 12 requests on this connection: 3 failed, 2 recovered by fallback. +const connectionReliability = { + success_rate: { value: 0.75, previous: 0.5 }, + fallback_saves: { value: 2, previous: 1 }, + total_requests: { value: 12, previous: 10 }, + errors_remaining: { value: 3, previous: 5 }, +}; + +const connectionRequestStatus = { + range: '7d', + by: 'disposition', + keys: ['ok', 'fallback', 'failed'], + buckets: [{ bucket: '2026-06-04', counts: [7, 2, 3] }], +}; + function makeMemoryStorage() { const data = new Map(); return { @@ -782,28 +787,14 @@ beforeEach(() => { apiMocks.getPerAgentTimeseries.mockResolvedValue(agentTimeseries); apiMocks.getPerAgentMessageTimeseries.mockResolvedValue(agentTimeseries); apiMocks.getPerAgentCostTimeseries.mockResolvedValue(agentTimeseries); - apiMocks.getConnectionAttemptStatusTimeseries.mockResolvedValue({ + // Request-world reliability; null/empty by default so the overview pages + // render as before. ConnectionDetail tests opt in with connectionReliability. + apiMocks.getReliabilityStats.mockResolvedValue(null); + apiMocks.getRequestStatusTimeseries.mockResolvedValue({ range: '7d', - by: 'metric', - keys: ['success', 'error'], - buckets: [{ bucket: '2026-06-04', counts: [9, 3] }], - }); - apiMocks.getConnectionAttemptsByAgentTimeseries.mockResolvedValue({ - agents: ['demo-agent'], - timeseries: [{ date: '2026-06-04', 'demo-agent': 12 }], - }); - apiMocks.getConnectionAttemptHttpStatusTimeseries.mockResolvedValue({ - range: '7d', - by: 'metric', - keys: ['200', '429'], - buckets: [{ bucket: '2026-06-04', counts: [9, 3] }], - }); - apiMocks.getConnectionAttemptBreakdown.mockResolvedValue({ - attempts: 12, - succeeded: 9, - failed: 3, - fallback_retries: 10, - fallback_retries_succeeded: 8, + by: 'disposition', + keys: [], + buckets: [], }); }); @@ -999,18 +990,20 @@ describe('ConnectionDetail (analytics)', () => { it('renders connection analytics, models, and harness breakdown', async () => { const { container } = renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); // The chart card paints once the connection-scoped analytics resolve. await screen.findByTestId('provider-chart-card'); expect(screen.getAllByText('Harnesses').length).toBeGreaterThan(0); expect(screen.getAllByText('gpt-5').length).toBeGreaterThan(0); - expect(screen.getByTestId('requests-info-tooltip').textContent).toContain( - 'including fallback retries.', - ); // Recent messages table renders model and token data (description is no longer displayed). expect(screen.getByText('Recent Requests')).toBeDefined(); - // BYOK connection → cost columns present - expect(screen.getByText('Active')).toBeDefined(); + // The range Select sits in the Period filter bar, as on the Overview. + expect(screen.getAllByText('Period filter').length).toBeGreaterThan(0); + // The account status moved to the topbar breadcrumb. + expect(vi.mocked(setConnectionBreadcrumb).mock.lastCall?.[5]).toEqual({ + active: true, + custom: false, + }); // Manage opens the settings window (not the ProviderSelectModal). fireEvent.click(screen.getByText('Manage')); @@ -1032,6 +1025,7 @@ describe('ConnectionDetail (analytics)', () => { expect.any(Function), 'openai', 'Default', + { active: true, custom: false }, ), ); unmount(); @@ -1040,7 +1034,7 @@ describe('ConnectionDetail (analytics)', () => { it('persists chart range and view selection', async () => { renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); // The chart card paints once the connection-scoped analytics resolve. await screen.findByTestId('provider-chart-card'); @@ -1052,133 +1046,116 @@ describe('ConnectionDetail (analytics)', () => { expect(sessionStorage.getItem('chart-view:conn-openai')).toBe('requests'); }); - it('shows the Attempts tab, By HTTP status default, and no Recovered tab', async () => { + it('defaults to the Requests tab and filters it by the selected KPI card', async () => { + apiMocks.getReliabilityStats.mockResolvedValue(connectionReliability); + apiMocks.getRequestStatusTimeseries.mockResolvedValue(connectionRequestStatus); + renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); - // The chart card paints once the connection-scoped analytics resolve. - await screen.findByTestId('provider-chart-card'); - fireEvent.click(screen.getByText('Requests chart')); + const card = await screen.findByTestId('provider-chart-card'); + expect(card.getAttribute('data-active-view')).toBe('requests'); - // Grouping buttons, in order: HTTP status (default), attempt status, - // harness. No provider grouping and no request notion here. - await waitFor(() => expect(screen.getByText('By HTTP status')).toBeDefined()); - expect(screen.getByText('By HTTP status').getAttribute('aria-pressed')).toBe('true'); - const buttons = screen - .getAllByRole('button') - .map((b) => b.textContent?.trim()) - .filter((t) => t?.startsWith('By ')); - expect(buttons).toEqual(['By HTTP status', 'By attempt status', 'By harness']); - - // Default view feeds the HTTP-status series. - await waitFor(() => expect(screen.getByTestId('status-keys').textContent).toBe('200,429')); - - // Switching to attempt status feeds the success/error series. - fireEvent.click(screen.getByText('By attempt status')); + // Requests tab: the connection's request status, as on the Overview. await waitFor(() => - expect(screen.getByTestId('status-keys').textContent).toBe('success,error'), + expect(screen.getByTestId('status-keys').textContent).toBe('ok,fallback,failed'), ); - // No Recovered tab: recovery belongs to requests, not connections. - expect(screen.getByTestId('recovered-keys').textContent).toBe(''); - - // Scoped fetch to this exact connection. - expect(apiMocks.getConnectionAttemptStatusTimeseries).toHaveBeenCalledWith( - 'api_key', - 'openai', - expect.any(String), - 'Default', + // A KPI card narrows the same series to its statuses. + fireEvent.click(screen.getByText('Recovered requests').closest('.stat-tile')!); + await waitFor(() => expect(screen.getByTestId('status-keys').textContent).toBe('fallback')); + // No attempt-world grouping control any more. + expect(screen.queryByText('By HTTP status')).toBeNull(); + + expect(apiMocks.getRequestStatusTimeseries).toHaveBeenCalledWith( + '7d', + undefined, 'conn-openai', ); - - // Switching to By harness hands the chart the attempts-per-agent series. - fireEvent.click(screen.getByText('By harness')); - await waitFor(() => expect(screen.getByTestId('status-keys').textContent).toBe('')); - expect(screen.getByTestId('msg-agents').textContent).toBe('demo-agent'); }); - it('applies the harness selection to the attempts-by-harness series', async () => { - const emptySeries = { agents: [], timeseries: [] }; - apiMocks.getPerAgentTimeseries.mockResolvedValue(emptySeries); - apiMocks.getPerAgentMessageTimeseries.mockResolvedValue(emptySeries); - apiMocks.getPerAgentCostTimeseries.mockResolvedValue(emptySeries); - apiMocks.getConnectionAttemptsByAgentTimeseries.mockResolvedValue({ + it('applies the harness selection to the token series', async () => { + const twoAgents = { agents: ['alpha', 'beta'], - timeseries: [{ date: '2026-06-04', alpha: 7, beta: 5 }], - }); + timeseries: [{ hour: '2026-06-04 10:00:00', alpha: 7, beta: 5 }], + }; + apiMocks.getPerAgentTimeseries.mockResolvedValue(twoAgents); + apiMocks.getPerAgentMessageTimeseries.mockResolvedValue(twoAgents); + apiMocks.getPerAgentCostTimeseries.mockResolvedValue(twoAgents); renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); - // The chart card paints once the connection-scoped analytics resolve. await screen.findByTestId('provider-chart-card'); - fireEvent.click(screen.getByText('Requests chart')); - fireEvent.click(screen.getByText('By harness')); - await waitFor(() => expect(screen.getByTestId('msg-agents').textContent).toBe('alpha,beta')); + fireEvent.click(screen.getByText('Tokens chart')); + await waitFor(() => expect(screen.getByTestId('ts-agents').textContent).toBe('alpha,beta')); + // The harness filter shows on the Tokens tab. + expect(screen.getByTestId('series-filters').textContent).toContain('All harnesses (2)'); fireEvent.click(screen.getAllByText('All harnesses (2)').at(-1)!); fireEvent.click(screen.getByText('beta')); - await waitFor(() => expect(screen.getByTestId('msg-agents').textContent).toBe('alpha')); + await waitFor(() => expect(screen.getByTestId('ts-agents').textContent).toBe('alpha')); + expect(screen.getByTestId('cost-agents').textContent).toBe('alpha'); }); - it('lays out the header, meta row, controls and stat grid with responsive classes', async () => { + it('lays out the header, period filter and stat grid with responsive classes', async () => { + apiMocks.getReliabilityStats.mockResolvedValue(connectionReliability); const { container } = renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); - // The header, meta row and controls use the console's wrapping page-head - // layout so the phone width can reflow them. - expect(container.querySelector('.page-head')).not.toBeNull(); - expect(container.querySelector('.page-head .sub')).not.toBeNull(); - expect(container.querySelector('.page-head .page-actions')).not.toBeNull(); - // The status pill (and the Custom tag on custom connections) share one - // badge class so they size as a matched pair. - expect(container.querySelector('.page-head .badge')?.textContent).toBe('Active'); - const stats = container.querySelector('.stat-tiles'); - // Four cards in the auto-fit tile grid, no fixed inline column count. - expect(stats?.querySelectorAll('.stat-tile')).toHaveLength(4); - expect((stats as HTMLElement).style.gridTemplateColumns).toBe(''); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); + // The header only holds the Manage action; badges and the meta row moved + // to the topbar breadcrumb. + const head = container.querySelector('.page-head'); + expect(head).not.toBeNull(); + expect(head?.querySelector('.page-actions')?.textContent).toBe('Manage'); + expect(head?.querySelector('.badge')).toBeNull(); + expect(head?.querySelector('.sub')).toBeNull(); + // The Period filter bar wraps the range Select at phone width. + const bar = container.querySelector('.overview-filter-bar'); + expect(bar?.className).toContain('flex-wrap'); + expect(bar?.textContent).toContain('Period filter'); + expect(bar?.querySelector('select')).not.toBeNull(); + // Five cards (with Cost on this BYOK connection) in the auto-fit tile grid, + // no fixed inline column count. + const stats = await waitFor(() => { + const found = container.querySelector('.stat-tiles'); + expect(found).not.toBeNull(); + return found as HTMLElement; + }); + await waitFor(() => expect(stats.querySelectorAll('.stat-tile')).toHaveLength(5)); + expect(stats.style.gridTemplateColumns).toBe(''); }); - it('shows the attempt cards row: rate, counts and fallback retries', async () => { + it('shows the reliability cards for this connection', async () => { + apiMocks.getReliabilityStats.mockResolvedValue(connectionReliability); const { container } = renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); - await waitFor(() => { - const cards = [...container.querySelectorAll('.stat-tile')].map((c) => c.textContent?.trim()); - // 9 ok / 12 attempts = 75.0%. - expect(cards.join(' | ')).toContain('75.0%'); - expect(cards.find((c) => c?.includes('Succeeded attempts'))).toContain('9'); - expect(cards.find((c) => c?.includes('Failed attempts'))).toContain('3'); - // A fallback is a retry: 10 sent, 8 of them succeeded. - const fb = cards.find((c) => c?.includes('Fallback retries')); - expect(fb).toContain('10'); - expect(fb).toContain('8 succeeded'); - }); - // Four attempt cards; no Autofix card. - const cards = container.querySelectorAll('.stat-tile'); - expect(cards).toHaveLength(4); + const tileText = (label: string) => + [...container.querySelectorAll('.stat-tile')] + .find((c) => c.querySelector('.stat-label')?.textContent?.startsWith(label)) + ?.querySelector('.stat-value')?.textContent; + await waitFor(() => expect(container.querySelectorAll('.stat-tile')).toHaveLength(5)); + // 12 requests - 3 failed = 9 successful; 2 recovered by fallback. + expect(tileText('Successful requests')).toBe('9'); + expect(tileText('Recovered requests')).toBe('2'); + expect(tileText('Recovered by Fallback')).toBeUndefined(); + expect(tileText('Failed requests')).toBe('3'); expect(container.textContent).not.toMatch(/autofix/i); + // Request world, scoped to this connection. + expect(apiMocks.getReliabilityStats).toHaveBeenCalledWith('7d', undefined, 'conn-openai'); }); - it.each([ - ['Failed attempts', '&attempts=has_failed'], - ['Succeeded attempts', '&attempts=has_succeeded'], - ['Fallback retries', '&trigger=fallback'], - ])('links the %s card to the connection-scoped Requests log', async (label, extra) => { + it('selects a KPI card as the chart filter instead of navigating', async () => { + apiMocks.getReliabilityStats.mockResolvedValue(connectionReliability); const { container } = renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); const card = await waitFor(() => { const found = [...container.querySelectorAll('.stat-tile')].find((c) => - c.textContent?.includes(label), + c.querySelector('.stat-label')?.textContent?.startsWith('Failed requests'), ); expect(found).toBeDefined(); return found!; }); fireEvent.click(card); - // Scoped to THIS connection and the card's window, never the whole provider. - expect(routerState.navigate).toHaveBeenCalledWith( - `/messages?connections=conn-openai&range=7d${extra}`, - ); + expect(card.getAttribute('aria-pressed')).toBe('true'); + expect(routerState.navigate).not.toHaveBeenCalled(); }); it('opens the inline manage modal from the connection detail', async () => { renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); fireEvent.click(screen.getByText('Manage')); // The settings window opens on its General tab, with the connection name. @@ -1219,10 +1196,12 @@ describe('ConnectionDetail (analytics)', () => { renderPage(); - await waitFor(() => expect(screen.getAllByText('Custom').length).toBeGreaterThan(0)); - // "Custom" appears both as the badge and as the connection label. - expect(screen.getAllByText('Custom').length).toBeGreaterThan(0); - expect(screen.getByText('Inactive')).toBeDefined(); + await screen.findByText('No requests yet.'); + // Inactive + Custom now render as topbar breadcrumb badges. + expect(vi.mocked(setConnectionBreadcrumb).mock.lastCall?.[5]).toEqual({ + active: false, + custom: true, + }); expect(screen.getByText('No requests yet.')).toBeDefined(); expect(screen.getByText('No model usage data yet.')).toBeDefined(); expect(screen.getByText('No harnesses have used this provider yet.')).toBeDefined(); @@ -1249,7 +1228,8 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getByText('Inactive')).toBeDefined()); + await screen.findByText('Manage'); + expect(vi.mocked(setConnectionBreadcrumb).mock.lastCall?.[5]).toMatchObject({ active: false }); fireEvent.click(screen.getByText('Manage')); fireEvent.click(screen.getByRole('button', { name: 'Delete' })); @@ -1300,7 +1280,7 @@ describe('ConnectionDetail (analytics)', () => { expect(container.querySelector('.skel')).not.toBeNull(); resolveDetail(connectionDetail); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); }); it('shows a not-found state for a missing/deleted connection (P3)', async () => { @@ -1331,7 +1311,7 @@ describe('ConnectionDetail (analytics)', () => { // Retry re-fetches; on success the connection renders. apiMocks.getConnectionDetail.mockResolvedValueOnce(connectionDetail); fireEvent.click(screen.getByText('Retry')); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); }); it('scopes every chart query to the connection label (P2)', async () => { @@ -1345,15 +1325,24 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getAllByText('Work').length).toBeGreaterThan(0)); + await waitFor(() => expect(apiMocks.getProviderAnalytics).toHaveBeenCalled()); - // analytics summary/timeseries: getProviderAnalytics(authType, range, agent, provider, label) + // analytics summary/timeseries: getProviderAnalytics(authType, range, agent, provider, label, id) const analyticsCall = apiMocks.getProviderAnalytics.mock.calls.at(-1)!; expect(analyticsCall[4]).toBe('Work'); + expect(analyticsCall[5]).toBe('conn-work'); // per-agent token/message/cost fetchers: (...args, range, label) expect(apiMocks.getPerAgentTimeseries.mock.calls.at(-1)![3]).toBe('Work'); expect(apiMocks.getPerAgentMessageTimeseries.mock.calls.at(-1)![3]).toBe('Work'); expect(apiMocks.getPerAgentCostTimeseries.mock.calls.at(-1)![3]).toBe('Work'); + expect(apiMocks.getPerAgentTimeseries.mock.calls.at(-1)![4]).toBe('conn-work'); + // Request-world cards and chart: (range, agent, connectionId). + expect(apiMocks.getReliabilityStats).toHaveBeenLastCalledWith('7d', undefined, 'conn-work'); + expect(apiMocks.getRequestStatusTimeseries).toHaveBeenLastCalledWith( + '7d', + undefined, + 'conn-work', + ); }); it('opens the inline manage modal for a custom provider connection', async () => { @@ -1369,7 +1358,7 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getAllByText('Custom').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); // Custom providers open the CustomProviderForm in edit mode @@ -1392,7 +1381,7 @@ describe('ConnectionDetail (analytics)', () => { it('closes the custom provider edit modal and refetches on onCreated', async () => { setupCustomConnectionDetail(); renderPage(); - await waitFor(() => expect(screen.getAllByText('Custom').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); await waitFor(() => expect(screen.getByTestId('custom-provider-form')).toBeDefined()); @@ -1408,7 +1397,7 @@ describe('ConnectionDetail (analytics)', () => { it('closes the custom provider edit modal on onBack', async () => { setupCustomConnectionDetail(); renderPage(); - await waitFor(() => expect(screen.getAllByText('Custom').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); await waitFor(() => expect(screen.getByTestId('custom-provider-form')).toBeDefined()); @@ -1422,7 +1411,7 @@ describe('ConnectionDetail (analytics)', () => { it('closes the custom provider edit modal and navigates back on onDeleted', async () => { setupCustomConnectionDetail(); renderPage(); - await waitFor(() => expect(screen.getAllByText('Custom').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); await waitFor(() => expect(screen.getByTestId('custom-provider-form')).toBeDefined()); @@ -1451,14 +1440,25 @@ describe('ConnectionDetail (analytics)', () => { apiMocks.getCustomProviders.mockRejectedValue(new Error('custom lookup failed')); renderPage(); - await waitFor(() => expect(screen.getAllByText('Custom key').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); + // Lookup failed → the provider id stands in for the name in the breadcrumb. + await waitFor(() => + expect(setConnectionBreadcrumb).toHaveBeenCalledWith( + 'custom:cp-1', + '/providers/usage-based', + expect.any(Function), + 'custom:cp-1', + 'Custom key', + { active: true, custom: true }, + ), + ); }); it('falls back to no providers when the harness list cannot load', async () => { apiMocks.getAgents.mockRejectedValue(new Error('agents down')); renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); // Manage still opens the inline modal even when harness list fails. fireEvent.click(screen.getByText('Manage')); @@ -1475,7 +1475,7 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); // The chart card paints once the connection-scoped analytics resolve. await screen.findByTestId('provider-chart-card'); @@ -1495,13 +1495,13 @@ describe('ConnectionDetail (analytics)', () => { // Helper: open the manage modal and return the rename input. const openModelsTab = async () => { - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); fireEvent.click(screen.getByRole('tab', { name: /Models/ })); await screen.findByText('GPT-5 test'); }; const openManageModal = async () => { - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); fireEvent.click(screen.getByText('Manage')); return screen.getByDisplayValue('Default') as HTMLInputElement; }; @@ -1746,7 +1746,7 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getAllByText('Claude').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); // Active subscription → "Connected via subscription" (line 963 truthy branch). @@ -1762,7 +1762,7 @@ describe('ConnectionDetail (analytics)', () => { }); const { container } = renderPage(); - await waitFor(() => expect(screen.getAllByText('Default').length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText('Activity').length).toBeGreaterThan(0)); // The model cell falls back to an em dash when msg.model is falsy (line 708). const dashCell = Array.from(container.querySelectorAll('td')).find( (td) => td.textContent?.trim() === '—', @@ -1783,7 +1783,7 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getAllByText('Stale').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); expect(screen.getByText('Connection name')).toBeDefined(); @@ -1818,7 +1818,7 @@ describe('ConnectionDetail (analytics)', () => { }); renderPage(); - await waitFor(() => expect(screen.getAllByText('Old Claude').length).toBeGreaterThan(0)); + await screen.findByText('Manage'); fireEvent.click(screen.getByText('Manage')); fireEvent.click(screen.getByText('Delete')); diff --git a/packages/frontend/tests/topology/topology-adapter.test.ts b/packages/frontend/tests/topology/topology-adapter.test.ts index 0433bc97..fa1844ce 100644 --- a/packages/frontend/tests/topology/topology-adapter.test.ts +++ b/packages/frontend/tests/topology/topology-adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildTopologyGraph, expandIpv6, + filterTopology, ipLines, linkWidths, } from '../../src/pages/topology-adapter'; @@ -256,3 +257,23 @@ describe('ip lines', () => { expect(ipLines('10.0.0.5')).toEqual(['10.0.0.5']); }); }); + +describe('filterTopology', () => { + it('returns the data untouched with no filter', () => { + expect(filterTopology(data, {})).toBe(data); + }); + + it('keeps one harness, its clients and what it reaches', () => { + const f = filterTopology(data, { harness: 'harness:b' }); + expect(f.harnesses.map((h) => h.id)).toEqual(['harness:b']); + expect(f.clients).toEqual([]); + expect(f.links.every((l) => l.source !== 'harness:a')).toBe(true); + }); + + it('keeps only harnesses linked to the provider type and its connections', () => { + const f = filterTopology(data, { provider: 'anthropic' }); + expect(f.harnesses.map((h) => h.id)).toEqual(['harness:a']); + expect(f.providers.map((p) => p.id)).toEqual(['connection:c2']); + expect(f.clients.map((c) => c.id)).toEqual(['client:10.0.0.5']); + }); +}); diff --git a/packages/thinker/package.json b/packages/thinker/package.json index 4d022cdf..e6681553 100644 --- a/packages/thinker/package.json +++ b/packages/thinker/package.json @@ -1,6 +1,6 @@ { "name": "thinker", - "version": "0.32.0", + "version": "0.33.0", "private": true, "description": "Canonical release version for the Thinker Docker image. This package holds no code — the real backend and frontend live in ../backend and ../frontend. See README.md for details." }
{t('alerts.col.level')} {t('alerts.col.event')} {t('alerts.col.category')} {t('alerts.col.actor')}
+ {levelLabel(e.level)} + -
- {levelLabel(e.level)} - {e.title} -
+
{e.title}
{e.message ?
{e.message}
: null}
{e.type}