diff --git a/docs/frontend-ui-audit-2026-08-06/RuntimeUsageCharts.md b/docs/frontend-ui-audit-2026-08-06/RuntimeUsageCharts.md new file mode 100644 index 000000000..99982bcac --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-06/RuntimeUsageCharts.md @@ -0,0 +1,26 @@ +# Runtime usage charts UI audit + +## Scope + +- `src/modules/shared/dataSource/TeamRuntimeToday.tsx` +- `src/modules/shared/dataSource/TeamMemberDetail.tsx` + +The configured `frontend-ui-audit` skill file was unavailable, so this report +uses the repository's documented audit columns and manually checks design-system +reuse, arbitrary Tailwind values, accessibility, and duplicated visual patterns. + +## Findings + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------- | ------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `TeamRuntimeToday.tsx:258` | Rolling usage chart section | keep with reason | Reuses the existing lazy-loaded `UsageTrendChart`, shared section heading classes, semantic color tokens, and existing localized range/title strings. The standard `h-72` fallback adds no arbitrary Tailwind value. | None. | +| `TeamRuntimeToday.tsx:284` | Per-member usage breakdown | keep with reason | Reuses `SectionContainer`, `Avatar`, existing typography/border/fill tokens, and native buttons. `aria-pressed` exposes the selected member filter to assistive technology. | None. | +| `TeamMemberDetail.tsx:315` | Source filter visibility in 24h mode | keep with reason | Reuses the existing `TabPill`; hiding it for the all-source hourly snapshot prevents a control from implying unsupported per-source hourly data. | None. | +| `TeamMemberDetail.tsx:336` | Usage range selector | keep with reason | Reuses the shared `Select` and existing localized `24h` label, keeps the default daily controls intact, and adds a stable test id without introducing a parallel range component. | None. | + +## Summary + +- fix: 0 +- keep with reason: 4 +- abstract: 0 +- systematic sweep candidates: 0 diff --git a/src-tauri/crates/orgtrack-core/src/usage_dashboard.rs b/src-tauri/crates/orgtrack-core/src/usage_dashboard.rs index bca53a020..beb3f2434 100644 --- a/src-tauri/crates/orgtrack-core/src/usage_dashboard.rs +++ b/src-tauri/crates/orgtrack-core/src/usage_dashboard.rs @@ -33,7 +33,7 @@ mod rounds; mod tests; use accumulator::UsageHeadlineAccumulator; -pub use daily_rollup::{usage_daily_rollup, DailyRollup, DailyRollupRow}; +pub use daily_rollup::{usage_daily_rollup, DailyRollup, DailyRollupRow, RecentUsageSnapshot}; pub use overview::{usage_overview, usage_rounds, usage_trends, UsageOverview}; use rounds::visit_rounds; pub use rounds::UsageRoundRow; diff --git a/src-tauri/crates/orgtrack-core/src/usage_dashboard/daily_rollup.rs b/src-tauri/crates/orgtrack-core/src/usage_dashboard/daily_rollup.rs index 2194b8274..106889861 100644 --- a/src-tauri/crates/orgtrack-core/src/usage_dashboard/daily_rollup.rs +++ b/src-tauri/crates/orgtrack-core/src/usage_dashboard/daily_rollup.rs @@ -24,8 +24,11 @@ use std::collections::{BTreeMap, HashSet}; use rusqlite::Connection; use serde::Serialize; +use super::accumulator::UsageHeadlineAccumulator; use super::rounds::visit_rounds_windowed; -use super::{TrendBucket, UsageFilter}; +use super::{TrendBucket, UsageFilter, UsageSummary, UsageTrendPoint}; + +const RECENT_USAGE_WINDOW_MS: i64 = 86_400_000; /// One (UTC-day-floor, bucket) aggregate row. #[derive(Debug, Clone, Default, PartialEq, Serialize)] @@ -60,6 +63,22 @@ pub struct DailyRollup { /// retains the windowed daily rows, so the lifetime figure has to ride /// along from the client. pub total_sessions: i64, + /// Rolling 24-hour headline + hourly series derived during the same + /// bounded round scan. The member-runtime scheduler attaches this to the + /// opaque status stats blob, so team viewers can render an accurate 24h + /// chart without another local scan or a cloud schema migration. + pub recent_usage_24h: RecentUsageSnapshot, +} + +/// A bounded local usage snapshot suitable for the member-runtime status +/// payload. Empty hourly buckets are omitted here and filled by the chart. +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentUsageSnapshot { + pub start_ms: i64, + pub end_ms: i64, + pub summary: UsageSummary, + pub trends: Vec, } #[derive(Default)] @@ -95,6 +114,7 @@ pub fn usage_daily_rollup( start_ms: i64, end_ms: i64, ) -> Result { + let recent_start_ms = start_ms.max(end_ms.saturating_sub(RECENT_USAGE_WINDOW_MS)); let filter = UsageFilter { bucket: None, start_ms: Some(start_ms), @@ -107,12 +127,16 @@ pub fn usage_daily_rollup( // BTreeMap keys give the required (day, bucket) output ordering for free. let mut cells: BTreeMap<(i64, String), RollupCell> = BTreeMap::new(); + let mut recent = UsageHeadlineAccumulator::new(TrendBucket::Hour, true, true); visit_rounds_windowed(conn, &filter, |round| { // Rounds without a usable timestamp cannot be attributed to a UTC // day (mirrors the trend accumulator's `created_at_ms > 0` gate). if round.created_at_ms <= 0 { return Ok(()); } + if round.created_at_ms >= recent_start_ms { + recent.observe(&round); + } let day_start_ms = TrendBucket::Day.floor(round.created_at_ms); let cell = cells .entry((day_start_ms, round.bucket.clone())) @@ -147,8 +171,16 @@ pub fn usage_daily_rollup( }) .collect(); + let (recent_summary, recent_trends) = recent.finish(); + Ok(DailyRollup { days, total_sessions: total_session_count(conn)?, + recent_usage_24h: RecentUsageSnapshot { + start_ms: recent_start_ms, + end_ms, + summary: recent_summary, + trends: recent_trends, + }, }) } diff --git a/src-tauri/crates/orgtrack-core/src/usage_dashboard/tests.rs b/src-tauri/crates/orgtrack-core/src/usage_dashboard/tests.rs index 118888f72..982b9b7fc 100644 --- a/src-tauri/crates/orgtrack-core/src/usage_dashboard/tests.rs +++ b/src-tauri/crates/orgtrack-core/src/usage_dashboard/tests.rs @@ -767,6 +767,60 @@ fn daily_rollup_window_clips_rounds() { assert_eq!(rollup.days[0].input_tokens, 400_000); } +#[test] +fn daily_rollup_derives_a_true_rolling_24h_snapshot_in_the_same_scan() { + let conn = fixture_conn(); + insert_code_session( + &conn, + "rolling-claude", + "claude", + "Rolling window", + "2026-07-18T12:00:00+00:00", + ); + insert_turn( + &conn, + "rolling-claude", + "claude-sonnet-4-5", + (100, 10, 0, 0), + "2026-07-17T11:59:59.999Z", + ); + insert_turn( + &conn, + "rolling-claude", + "claude-sonnet-4-5", + (200, 20, 30, 40), + "2026-07-17T12:00:00Z", + ); + recompute_session_usage(&conn, "rolling-claude") + .unwrap() + .expect("rolling session projected"); + + let end_ms = ms("2026-07-18T12:00:00Z"); + let rollup = usage_daily_rollup(&conn, ms("2026-07-01T00:00:00Z"), end_ms) + .expect("daily + rolling rollup"); + + assert_eq!(rollup.recent_usage_24h.start_ms, ms("2026-07-17T12:00:00Z")); + assert_eq!(rollup.recent_usage_24h.end_ms, end_ms); + assert_eq!(rollup.recent_usage_24h.summary.input_tokens, 200); + assert_eq!(rollup.recent_usage_24h.summary.output_tokens, 20); + assert_eq!(rollup.recent_usage_24h.summary.cache_read_tokens, 30); + assert_eq!(rollup.recent_usage_24h.summary.cache_write_tokens, 40); + assert_eq!(rollup.recent_usage_24h.summary.session_count, 1); + assert_eq!(rollup.recent_usage_24h.summary.request_count, 1); + assert_eq!(rollup.recent_usage_24h.trends.len(), 1); + assert_eq!( + rollup.recent_usage_24h.trends[0].bucket_ms, + ms("2026-07-17T12:00:00Z") + ); + + // The daily sync window still contains both rounds; the rolling snapshot + // alone excludes the row one millisecond before the 24h boundary. + assert_eq!( + rollup.days.iter().map(|row| row.input_tokens).sum::(), + 300 + ); +} + #[test] fn daily_rollup_skips_zero_usage_cells() { let conn = seeded_conn(); @@ -1022,6 +1076,16 @@ fn daily_rollup_serializes_camel_case() { let json = serde_json::to_value(DailyRollup { days: vec![row], total_sessions: 42, + recent_usage_24h: RecentUsageSnapshot { + start_ms: 1_784_332_800_000, + end_ms: 1_784_419_200_000, + summary: UsageSummary::default(), + trends: vec![UsageTrendPoint { + bucket_ms: 1_784_332_800_000, + input_tokens: 5, + ..UsageTrendPoint::default() + }], + }, }) .expect("serialize rollup"); assert_eq!(json["totalSessions"], 42); @@ -1036,4 +1100,7 @@ fn daily_rollup_serializes_camel_case() { assert_eq!(row["costUsd"], 0.5); assert_eq!(row["sessions"], 1); assert_eq!(row["requests"], 2); + assert_eq!(json["recentUsage24h"]["startMs"], 1_784_332_800_000_i64); + assert_eq!(json["recentUsage24h"]["endMs"], 1_784_419_200_000_i64); + assert_eq!(json["recentUsage24h"]["trends"][0]["inputTokens"], 5); } diff --git a/src-tauri/src/orgtrack/usage_dashboard_commands.rs b/src-tauri/src/orgtrack/usage_dashboard_commands.rs index ff5c6fc79..c0a22db98 100644 --- a/src-tauri/src/orgtrack/usage_dashboard_commands.rs +++ b/src-tauri/src/orgtrack/usage_dashboard_commands.rs @@ -207,9 +207,10 @@ pub async fn usage_dashboard_rounds( .map_err(|err| format!("Task join error: {err}"))? } -/// Per-(UTC day, bucket) rollup for the member-runtime cloud push. Unlike the -/// scoped desktop views above, this always spans ALL sources (the `other` -/// bucket included) so the totals a member shares with their org are complete. +/// Per-(UTC day, bucket) rollup plus rolling-24h snapshot for the +/// member-runtime cloud push. Unlike the scoped desktop views above, this +/// always spans ALL sources (the `other` bucket included) so the totals a +/// member shares with their org are complete. #[tauri::command] pub async fn usage_dashboard_daily_rollup( start_ms: i64, diff --git a/src/api/tauri/usageDashboard/index.ts b/src/api/tauri/usageDashboard/index.ts index d9a7401cc..d5d00205b 100644 --- a/src/api/tauri/usageDashboard/index.ts +++ b/src/api/tauri/usageDashboard/index.ts @@ -59,6 +59,14 @@ export interface UsageTrendPoint { costUsd: number; } +/** A bounded headline + trend snapshot captured at one instant. */ +export interface RecentUsageSnapshot { + startMs: number; + endMs: number; + summary: UsageSummary; + trends: UsageTrendPoint[]; +} + export interface UsageSessionRow { sessionId: string; name: string; @@ -302,14 +310,16 @@ export interface DailyRollupResult { days: DailyRollupRow[]; /** Lifetime mirror-deduped session count — independent of the window. */ totalSessions: number; + /** Rolling 24h usage derived during the same local round scan. */ + recentUsage24h: RecentUsageSnapshot; } /** * Per-UTC-day, per-bucket rollup over `[startMs, endMs]` for the * member-runtime push (registered behind the same 1-permit semaphore as the * other dashboard scans, so a plain wrapper is enough — concurrent callers - * queue in the backend). Also carries the lifetime session census the push - * shares as `stats.totalSessions`. + * queue in the backend). Also carries the lifetime session census and a + * rolling-24h snapshot the push shares through its bounded status blob. */ export async function usageDashboardDailyRollup( startMs: number, diff --git a/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.test.ts b/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.test.ts index c751a683c..1d2bc0572 100644 --- a/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.test.ts +++ b/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.test.ts @@ -177,7 +177,45 @@ describe("listMemberRuntime", () => { reportedAt: "2026-07-29T09:00:00Z", machine: STATUS_INPUT.status?.machine, sample: STATUS_INPUT.status?.sample, - stats: { totalSessions: 321 }, + stats: { + totalSessions: 321, + recentUsage24h: { + startMs: 1_753_000_000_000, + endMs: 1_753_086_400_000, + summary: { + sessionCount: 2, + requestCount: 3, + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + realTotalTokens: 100, + totalTokens: 100, + costUsd: 1.25, + estimatedCostUsd: 1.25, + recordedCostUsd: 0, + cacheHitRate: 0.375, + byBucket: [ + { + bucket: "claude", + sessionCount: 2, + realTotalTokens: 100, + costUsd: 1.25, + }, + ], + }, + trends: [ + { + bucketMs: 1_753_000_000_000, + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + costUsd: 1.25, + }, + ], + }, + }, builderTypeCode: "MDFS", profile: { code: "MDFS", axes: [], extraFutureField: 1 }, installedAgents: [{ id: "claude", status: "installed" }], @@ -206,6 +244,10 @@ describe("listMemberRuntime", () => { reportedAt: null, machine: { totally: "malformed" }, sample: "not-an-object", + stats: { + totalSessions: 7, + recentUsage24h: { startMs: "malformed" }, + }, builderTypeCode: null, profile: null, installedAgents: "garbage", @@ -223,7 +265,9 @@ describe("listMemberRuntime", () => { expect(members[0].userId).toBe("user-1"); expect(members[0].machine?.deviceId).toBe("dev-1"); expect(members[0].sample?.cpuPercent).toBe(42.5); - expect(members[0].stats).toEqual({ totalSessions: 321 }); + expect(members[0].stats?.totalSessions).toBe(321); + expect(members[0].stats?.recentUsage24h?.summary.realTotalTokens).toBe(100); + expect(members[0].stats?.recentUsage24h?.trends).toHaveLength(1); expect(members[0].profile?.code).toBe("MDFS"); expect(members[0].installedAgents).toEqual([ { id: "claude", status: "installed" }, @@ -234,7 +278,10 @@ describe("listMemberRuntime", () => { expect(members[1].displayName).toBeNull(); expect(members[1].machine).toBeNull(); expect(members[1].sample).toBeNull(); - expect(members[1].stats).toBeNull(); + expect(members[1].stats).toEqual({ + totalSessions: 7, + recentUsage24h: undefined, + }); expect(members[1].profile).toBeNull(); expect(members[1].installedAgents).toEqual([]); expect(members[1].recentDays).toEqual([]); diff --git a/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.ts b/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.ts index cdeb992f8..1ff5243d7 100644 --- a/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.ts +++ b/src/features/Org2Cloud/memberRuntime/memberRuntimeClient.ts @@ -149,6 +149,51 @@ const MemberUsageDayWireSchema = z.object({ requests: z.number().catch(0), }); +const NonNegativeNumberWireSchema = z.number().nonnegative().catch(0); + +const UsageBucketSummaryWireSchema = z.object({ + bucket: z.string(), + sessionCount: NonNegativeNumberWireSchema, + realTotalTokens: NonNegativeNumberWireSchema, + costUsd: NonNegativeNumberWireSchema, +}); + +const UsageSummaryWireSchema = z.object({ + sessionCount: NonNegativeNumberWireSchema, + requestCount: NonNegativeNumberWireSchema, + inputTokens: NonNegativeNumberWireSchema, + outputTokens: NonNegativeNumberWireSchema, + cacheReadTokens: NonNegativeNumberWireSchema, + cacheWriteTokens: NonNegativeNumberWireSchema, + realTotalTokens: NonNegativeNumberWireSchema, + totalTokens: NonNegativeNumberWireSchema, + costUsd: NonNegativeNumberWireSchema, + estimatedCostUsd: NonNegativeNumberWireSchema, + recordedCostUsd: NonNegativeNumberWireSchema, + cacheHitRate: z.number().min(0).max(1).catch(0), + byBucket: z + .array(UsageBucketSummaryWireSchema) + .max(TEAM_USAGE_BUCKETS.length) + .catch([]) + .default([]), +}); + +const UsageTrendPointWireSchema = z.object({ + bucketMs: z.number().nonnegative(), + inputTokens: NonNegativeNumberWireSchema, + outputTokens: NonNegativeNumberWireSchema, + cacheReadTokens: NonNegativeNumberWireSchema, + cacheWriteTokens: NonNegativeNumberWireSchema, + costUsd: NonNegativeNumberWireSchema, +}); + +const RecentUsageSnapshotWireSchema = z.object({ + startMs: z.number().nonnegative(), + endMs: z.number().nonnegative(), + summary: UsageSummaryWireSchema, + trends: z.array(UsageTrendPointWireSchema).max(25).catch([]).default([]), +}); + const optionalString = z .string() .nullish() @@ -214,7 +259,13 @@ const MemberRuntimeListEntryWireSchema = z.object({ reportedAt: z.string().nullish().catch(undefined), machine: MemberRuntimeMachineWireSchema.nullish().catch(undefined), sample: MemberRuntimeSampleWireSchema.nullish().catch(undefined), - stats: z.object({ totalSessions: z.number() }).nullish().catch(undefined), + stats: z + .object({ + totalSessions: z.number(), + recentUsage24h: RecentUsageSnapshotWireSchema.optional().catch(undefined), + }) + .nullish() + .catch(undefined), builderTypeCode: z.string().nullish().catch(undefined), profile: MemberBuilderProfileWireSchema.nullish().catch(undefined), installedAgents: z diff --git a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts index 9beeab2c7..f0441e30d 100644 --- a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts +++ b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts @@ -39,6 +39,7 @@ import { type MemberRuntimeSchedulerDeps, } from "./memberRuntimePushScheduler"; import { + MEMBER_STATUS_MAX_BYTES, MEMBER_USAGE_DAYS_MAX_PER_PUSH, SHARE_RUNTIME_SETTING_KEY, } from "./types"; @@ -149,6 +150,32 @@ function makeRollupDays(count: number): DailyRollupResult["days"] { })); } +function makeRecentUsage24h( + overrides: Partial = {} +): DailyRollupResult["recentUsage24h"] { + return { + startMs: NOW - UTC_DAY_MS, + endMs: NOW, + summary: { + sessionCount: 0, + requestCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + realTotalTokens: 0, + totalTokens: 0, + costUsd: 0, + estimatedCostUsd: 0, + recordedCostUsd: 0, + cacheHitRate: 0, + byBucket: [], + }, + trends: [], + ...overrides, + }; +} + function makeDeps( overrides: Partial = {} ): MemberRuntimeSchedulerDeps { @@ -171,7 +198,11 @@ function makeDeps( sampledOverMs: 1000, sampledAtMs: NOW, }), - getDailyRollup: vi.fn().mockResolvedValue({ days: [], totalSessions: 0 }), + getDailyRollup: vi.fn().mockResolvedValue({ + days: [], + totalSessions: 0, + recentUsage24h: makeRecentUsage24h(), + }), getProfileOverview: vi.fn().mockResolvedValue(makeProfileOverview()), detectInstalledAgents: vi.fn().mockResolvedValue([]), upsert: vi.fn().mockResolvedValue(undefined), @@ -545,9 +576,11 @@ describe("ORG2_RUNTIME_TOO_LARGE mitigation", () => { const upsert = vi.fn().mockResolvedValue(undefined); const deps = makeDeps({ now: () => NOW, - getDailyRollup: vi - .fn() - .mockResolvedValue({ days: makeRollupDays(5), totalSessions: 5 }), + getDailyRollup: vi.fn().mockResolvedValue({ + days: makeRollupDays(5), + totalSessions: 5, + recentUsage24h: makeRecentUsage24h(), + }), upsert, }); const scheduler = asPrivate(new MemberRuntimePushScheduler(deps)); @@ -565,13 +598,127 @@ describe("ORG2_RUNTIME_TOO_LARGE mitigation", () => { expect(input.usageDays).toHaveLength(2); }); + it("shares the bounded rolling-24h snapshot inside status stats", async () => { + const upsert = vi.fn().mockResolvedValue(undefined); + const trends = Array.from({ length: 25 }, (_, index) => ({ + bucketMs: NOW - (24 - index) * 60 * 60_000, + inputTokens: 999_999_999, + outputTokens: 999_999_999, + cacheReadTokens: 999_999_999, + cacheWriteTokens: 999_999_999, + costUsd: 999_999.1234, + })); + const recentUsage24h = makeRecentUsage24h({ + summary: { + sessionCount: 999_999, + requestCount: 999_999, + inputTokens: 999_999_999, + outputTokens: 999_999_999, + cacheReadTokens: 999_999_999, + cacheWriteTokens: 999_999_999, + realTotalTokens: 3_999_999_996, + totalTokens: 3_999_999_996, + costUsd: 999_999.1234, + estimatedCostUsd: 999_999.1234, + recordedCostUsd: 0, + cacheHitRate: 0.5, + byBucket: ["claude", "codex", "cursor", "org2", "other"].map( + (bucket) => ({ + bucket, + sessionCount: 999_999, + realTotalTokens: 999_999_999, + costUsd: 999_999.1234, + }) + ), + }, + trends, + }); + const deps = makeDeps({ + now: () => NOW, + getDailyRollup: vi.fn().mockResolvedValue({ + days: [], + totalSessions: 42, + recentUsage24h, + }), + upsert, + }); + const scheduler = asPrivate(new MemberRuntimePushScheduler(deps)); + + await scheduler.pushOrg( + "token-1", + "identity-recent-usage", + makeOrg(), + async () => null + ); + + const input = upsert.mock.calls[0][2] as { + status?: { stats?: unknown }; + }; + expect(input.status?.stats).toEqual({ + totalSessions: 42, + recentUsage24h, + }); + expect( + new TextEncoder().encode(JSON.stringify(input.status)).byteLength + ).toBeLessThan(MEMBER_STATUS_MAX_BYTES); + }); + + it("drops only the additive snapshot when status would approach the server cap", async () => { + const upsert = vi.fn().mockResolvedValue(undefined); + const recentUsage24h = makeRecentUsage24h({ + trends: Array.from({ length: 25 }, (_, index) => ({ + bucketMs: NOW - index * 60 * 60_000, + inputTokens: 10, + outputTokens: 10, + cacheReadTokens: 10, + cacheWriteTokens: 10, + costUsd: 1, + })), + }); + const deps = makeDeps({ + now: () => NOW, + getMachine: vi.fn().mockResolvedValue({ + deviceId: "device-1", + machineLabel: "x".repeat(5_000), + osName: "macOS", + osVersion: "15.0", + chipType: "Apple M3", + appVersion: "1.0.0", + }), + getDailyRollup: vi.fn().mockResolvedValue({ + days: [], + totalSessions: 42, + recentUsage24h, + }), + upsert, + }); + const scheduler = asPrivate(new MemberRuntimePushScheduler(deps)); + + await scheduler.pushOrg( + "token-1", + "identity-large-status", + makeOrg(), + async () => null + ); + + const status = upsert.mock.calls[0][2].status as { + stats?: { totalSessions: number; recentUsage24h?: unknown }; + }; + expect(status.stats).toEqual({ totalSessions: 42 }); + expect( + new TextEncoder().encode(JSON.stringify(status)).byteLength + ).toBeLessThan(MEMBER_STATUS_MAX_BYTES); + }); + it("pushOrg drops profile/installed-agents once flagged, even though both changed", async () => { const upsert = vi.fn().mockResolvedValue(undefined); const deps = makeDeps({ now: () => NOW, - getDailyRollup: vi - .fn() - .mockResolvedValue({ days: makeRollupDays(1), totalSessions: 1 }), + getDailyRollup: vi.fn().mockResolvedValue({ + days: makeRollupDays(1), + totalSessions: 1, + recentUsage24h: makeRecentUsage24h(), + }), getProfileOverview: vi .fn() .mockResolvedValue(makeProfileOverview({ code: "EAWH", sessions: 10 })), diff --git a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.ts b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.ts index b56da2793..849244e00 100644 --- a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.ts +++ b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.ts @@ -101,6 +101,7 @@ import type { } from "./types"; import { MEMBER_AGENTS_DETECT_MIN_INTERVAL_MS, + MEMBER_STATUS_MAX_BYTES, MEMBER_USAGE_DAYS_MAX_PER_PUSH, MEMBER_USAGE_ROLLUP_WINDOW_DAYS, SHARE_RUNTIME_SETTING_KEY, @@ -116,6 +117,35 @@ export const MEMBER_RUNTIME_CAPABILITY_RECHECK_MS = 6 * 60 * 60 * 1000; /** Pass-level floor after a failed token refresh (org backoffs are per-org; * an auth failure blocks the whole pass and must not tight-loop). */ const AUTH_RETRY_DELAY_MS = 5 * 60_000; +/** Leave room for jsonb's canonical text spacing at the server-side cap. */ +const MEMBER_STATUS_SIZE_SAFETY_BYTES = 512; + +function statusWithBoundedRecentUsage( + machine: Awaited>, + sample: Awaited>, + rollup: DailyRollupResult +): NonNullable { + const status: NonNullable = { + machine, + sample, + stats: { + totalSessions: rollup.totalSessions, + recentUsage24h: rollup.recentUsage24h, + }, + }; + const bytes = new TextEncoder().encode(JSON.stringify(status)).byteLength; + if (bytes <= MEMBER_STATUS_MAX_BYTES - MEMBER_STATUS_SIZE_SAFETY_BYTES) { + return status; + } + // Status is the heartbeat. If unusual machine labels plus the additive + // snapshot approach the server cap, keep the pre-feature census payload + // rather than turning every future push into ORG2_RUNTIME_TOO_LARGE. + return { + machine, + sample, + stats: { totalSessions: rollup.totalSessions }, + }; +} /** Non-DOM contexts (workers and node-side tests) behave as visible. */ function isDocumentHidden(): boolean { @@ -545,7 +575,8 @@ export class MemberRuntimePushScheduler { const sample = await this.deps.getSample(nowMs); // Usage: recompute the rolling UTC-day window, delta-push changed rows. - // The same scan carries the lifetime session census for status.stats. + // The same scan carries the lifetime census and bounded rolling-24h + // snapshot for status.stats, so hourly sharing adds no second DB pass. const windowStartMs = utcDayFloorMs(nowMs) - (MEMBER_USAGE_ROLLUP_WINDOW_DAYS - 1) * UTC_DAY_MS; const rollup = await this.deps.getDailyRollup(windowStartMs, nowMs); @@ -596,11 +627,7 @@ export class MemberRuntimePushScheduler { } const input: UpsertMemberRuntimeInput = { - status: { - machine, - sample, - stats: { totalSessions: rollup.totalSessions }, - }, + status: statusWithBoundedRecentUsage(machine, sample, rollup), ...(usagePlan.days.length > 0 ? { usageDays: usagePlan.days } : {}), ...(profilePart ? { profile: profilePart } : {}), }; diff --git a/src/features/Org2Cloud/memberRuntime/types.ts b/src/features/Org2Cloud/memberRuntime/types.ts index 6032fa1b0..450540555 100644 --- a/src/features/Org2Cloud/memberRuntime/types.ts +++ b/src/features/Org2Cloud/memberRuntime/types.ts @@ -31,14 +31,19 @@ * - the full installed-agent inventory (which CLI providers are present * and their detection status) — see `MemberInstalledAgent`; * - per-day cost and token figures broken out by bucket — see - * `MemberUsageDay`. + * `MemberUsageDay`; + * - a rolling 24-hour token/cost series at hourly resolution, aggregated + * across sources — see `MemberRuntimeStats.recentUsage24h`. * These are exactly what let a teammate see "who's running low on RAM" or * "who's spending the most on Claude this week"; they are not incidental * leakage, but they are NOT covered by the "no session titles/repo * paths/models" framing above and must be disclosed alongside it. */ import type { BuilderProfile } from "@src/api/tauri/builderProfile"; -import type { UsageBucket } from "@src/api/tauri/usageDashboard"; +import type { + RecentUsageSnapshot, + UsageBucket, +} from "@src/api/tauri/usageDashboard"; // --------------------------------------------------------------------------- // Buckets & days @@ -102,11 +107,15 @@ export interface MemberRuntimeSample { sampledAtMs: number; } -/** Lifetime local-machine session totals, pushed with every status update - * (the cloud only holds the retention-windowed daily rows, so a lifetime - * count must come from the client). Mirror-deduped like the dashboard. */ +/** Bounded usage metadata pushed with every status update. The lifetime + * census must come from the client because cloud daily rows are retained only + * for a window; the rolling snapshot powers team hourly charts without + * storing per-request rows. */ export interface MemberRuntimeStats { totalSessions: number; + /** Latest rolling 24h headline + hourly trend. Additive inside the opaque + * cloud status blob, so pre-feature peers simply omit it. */ + recentUsage24h?: RecentUsageSnapshot; } /** One (UTC day, bucket) usage rollup row. */ @@ -200,12 +209,11 @@ export const MEMBER_RUNTIME_SIGNAL_KIND = "member_runtime" as const; export const MEMBER_RUNTIME_COMMANDS = { /** → `{ cpuPercent, memUsedMb, memTotalMb, gpuPercent, sampledOverMs }` */ systemRuntimeSnapshot: "system_runtime_snapshot", - /** args `{ startMs, endMs }` → `{ days: DailyRollupRow[], totalSessions }` - * where a row is `{ dayStartMs, bucket, inputTokens, outputTokens, - * cacheReadTokens, cacheWriteTokens, totalTokens, costUsd, sessions, - * requests }` with UTC day floors and `all_sources: true` (includes - * `other`); `totalSessions` is the LIFETIME mirror-deduped session count, - * independent of the window. */ + /** args `{ startMs, endMs }` → `{ days, totalSessions, recentUsage24h }`. + * Daily rows use UTC day floors and `all_sources: true` (includes `other`); + * `totalSessions` is the LIFETIME mirror-deduped session count, independent + * of the window; `recentUsage24h` is the all-source rolling headline and + * hourly series ending at `endMs`. */ usageDailyRollup: "usage_dashboard_daily_rollup", /** → `{ deviceId, machineLabel }`, persisted at `~/.orgii/cloud_device_id`. */ cloudDeviceIdentity: "cloud_device_identity", @@ -242,6 +250,8 @@ export const MEMBER_STATUS_MAX_BYTES = 8_192; export const MEMBER_PROFILE_MAX_BYTES = 16_384; /** Retention for `member_usage_daily` (service-role GC). */ export const MEMBER_USAGE_RETENTION_DAYS = 90; +/** Rolling window carried in `stats.recentUsage24h`. */ +export const MEMBER_RECENT_USAGE_WINDOW_MS = 24 * 60 * 60 * 1000; /** Launch catch-up jitter so an org coming online together doesn't stampede. */ export const MEMBER_RUNTIME_CATCHUP_JITTER_MIN_MS = 30_000; diff --git a/src/modules/shared/dataSource/TeamMemberDetail.tsx b/src/modules/shared/dataSource/TeamMemberDetail.tsx index fb49d2ce6..9c0a8907d 100644 --- a/src/modules/shared/dataSource/TeamMemberDetail.tsx +++ b/src/modules/shared/dataSource/TeamMemberDetail.tsx @@ -1,8 +1,9 @@ /** * Runtime → Team drilldown for one member: builder-profile card (axes + * confidence, reusing `AxisMeter` and the type-gallery card surface), a usage - * range fetched via `getMemberUsage` and folded into the existing chart / - * stat-card props, installed agents with labels, and machine details. + * daily range fetched via `getMemberUsage` (or the inline rolling-24h + * snapshot) and folded into the existing chart / stat-card props, installed + * agents with labels, and machine details. * * Rendered as a second layer over the roster (the `BuilderTypesPanel` * back-button idiom of this folder). @@ -50,8 +51,8 @@ import { formatInt } from "./usageFormat"; const SOURCE_ALL = "all"; const UsageTrendChart = lazy(() => import("./UsageTrendChart")); -const RANGE_OPTIONS = [7, 30, 90] as const; -type MemberUsageRangeDays = (typeof RANGE_OPTIONS)[number]; +const RANGE_OPTIONS = ["24h", 7, 30, 90] as const; +type MemberUsageRange = (typeof RANGE_OPTIONS)[number]; interface TeamMemberDetailProps { entry: MemberRuntimeListEntry; @@ -75,7 +76,7 @@ export default function TeamMemberDetail({ keyPrefix: "kanban.dataSource", }); - const [rangeDays, setRangeDays] = useState(30); + const [usageRange, setUsageRange] = useState(7); const [bucket, setBucket] = useState(null); const [days, setDays] = useState(null); const [range, setRange] = useState<{ fromDay: string; toDay: string } | null>( @@ -97,12 +98,19 @@ export default function TeamMemberDetail({ useEffect(() => { let cancelled = false; const seq = ++requestRef.current; + if (usageRange === "24h") { + setLoading(false); + setError(null); + return () => { + cancelled = true; + }; + } void (async () => { setLoading(true); setError(null); try { const accessToken = await getFreshAccessToken(); - const nextRange = memberUsageDayRange(Date.now(), rangeDays); + const nextRange = memberUsageDayRange(Date.now(), usageRange); const rows = await getMemberUsage( accessToken, orgId, @@ -124,18 +132,42 @@ export default function TeamMemberDetail({ return () => { cancelled = true; }; - }, [entry.userId, orgId, rangeDays, retryNonce, getFreshAccessToken]); + }, [entry.userId, orgId, usageRange, retryNonce, getFreshAccessToken]); + + const hourlySnapshot = entry.stats?.recentUsage24h ?? null; + const hourly = usageRange === "24h"; const summary = useMemo( - () => (days ? foldMemberUsageSummary(days, bucket) : null), - [days, bucket] + () => + hourly + ? (hourlySnapshot?.summary ?? null) + : days + ? foldMemberUsageSummary(days, bucket) + : null, + [bucket, days, hourly, hourlySnapshot] ); const trendPoints = useMemo( - () => (days ? memberUsageDaysToTrendPoints(days, bucket) : []), - [days, bucket] + () => + hourly + ? (hourlySnapshot?.trends ?? []) + : days + ? memberUsageDaysToTrendPoints(days, bucket) + : [], + [bucket, days, hourly, hourlySnapshot] ); - const chartStartMs = range ? utcDayStartMs(range.fromDay) : null; - const chartEndMs = range ? utcDayStartMs(range.toDay) : null; + const chartStartMs = hourly + ? (hourlySnapshot?.startMs ?? null) + : range + ? utcDayStartMs(range.fromDay) + : null; + const chartEndMs = hourly + ? (hourlySnapshot?.endMs ?? null) + : range + ? utcDayStartMs(range.toDay) + : null; + const hasUsageData = hourly + ? hourlySnapshot !== null + : days !== null && days.length > 0; const bucketTabs = useMemo( () => [ @@ -151,9 +183,12 @@ export default function TeamMemberDetail({ () => RANGE_OPTIONS.map((preset) => ({ value: String(preset), - label: t(`detail.range.${preset}`), + label: + preset === "24h" + ? tUsage("usage.range.24h") + : t(`detail.range.${preset}`), })), - [t] + [t, tUsage] ); const displayName = entry.displayName ?? entry.userId; @@ -256,31 +291,45 @@ export default function TeamMemberDetail({

{t("detail.usageTitle")}

- - setBucket(key === SOURCE_ALL ? null : (key as TeamUsageBucket)) - } - variant="pill" - size="mini" - colorScheme="ghost" - fillWidth={false} - /> - + {!hourly ? ( + <> + + setBucket( + key === SOURCE_ALL ? null : (key as TeamUsageBucket) + ) + } + variant="pill" + size="mini" + colorScheme="ghost" + fillWidth={false} + /> + + + ) : null}