Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/hooks/useMiniCardStatuses.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ function health(over: { data?: VersionInfo; error?: { message: string; status?:

function admin(isAdmin = true) {
mockUseAuth.mockReturnValue({
hasPermission: (perm: string) => isAdmin && perm === "admin.system_config",
hasPermission: (perm: string) =>
isAdmin && (perm === "admin.system_config" || perm === "audit:read"),
} as unknown as ReturnType<typeof useAuth>);
}

Expand Down Expand Up @@ -102,6 +103,20 @@ describe("useMiniCardStatuses — /version gating", () => {
});
});

describe("useMiniCardStatuses — activity gating", () => {
it("does not fetch activity for a caller without audit:read", () => {
admin(false);
renderHook(() => useMiniCardStatuses());
expect(mockUseRecentActivity).toHaveBeenCalledWith({ pollIntervalMs: 0, enabled: false });
});

it("fetches activity when the caller holds audit:read", () => {
admin(true);
renderHook(() => useMiniCardStatuses());
expect(mockUseRecentActivity).toHaveBeenCalledWith({ pollIntervalMs: 0, enabled: true });
});
});

describe("useMiniCardStatuses — headline health axis", () => {
it("stays optimistic (reachable undefined) while health is loading / for a non-admin", () => {
admin(false); // no /version -> no data, no error
Expand Down
9 changes: 7 additions & 2 deletions src/hooks/useMiniCardStatuses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* `/version` is admin-only, so it is fetched only when the caller can view
* system diagnostics (`admin.system_config`); non-admins never poll a guaranteed
* 403. It is polled once here (the hook is resolved at the page level) and feeds
* both the mini cards and the headline.
* both the mini cards and the headline. Activity is gated the same way on
* `audit:read`.
*/

import { useMemo } from "react";
Expand Down Expand Up @@ -75,7 +76,11 @@ export function useMiniCardStatuses(): HomeStatus {
const { data: health, error: healthError } = systemHealth;
const { data: mcpServers, error: mcpServersError } = useQuery<ServersResponse>(MCP_REACH_PATH);
const { data: a2aAgents, error: a2aError } = useQuery<Activatable[]>(A2A_REACH_PATH);
const { items } = useRecentActivity({ pollIntervalMs: 0 });
// /api/logs/activity requires audit:read, which no default non-admin role
// holds. security:read is not checked: that half of the feed is additive
// server-side, so an audit:read-only caller gets a narrower feed, not an error.
const canViewActivity = hasPermission("audit:read");
const { items } = useRecentActivity({ pollIntervalMs: 0, enabled: canViewActivity });

const derived = useMemo(() => {
const healthy = safeHealthy(health);
Expand Down
24 changes: 24 additions & 0 deletions src/hooks/useRecentActivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ describe("useRecentActivity", () => {
expect(result.current.items).toEqual([]);
});

it("makes no request while disabled and fetches once enabled", async () => {
let callCount = 0;
server.use(
http.get("*/api/logs/activity", () => {
callCount += 1;
return HttpResponse.json({ items: RECENT_ACTIVITY_FIXTURE.slice(0, 2) });
}),
);

const { result, rerender } = renderHook(
({ enabled }) => useRecentActivity({ pollIntervalMs: 0, enabled }),
{ initialProps: { enabled: false } },
);

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(callCount).toBe(0);
expect(result.current.items).toEqual([]);

rerender({ enabled: true });

await waitFor(() => expect(result.current.items).toHaveLength(2));
expect(callCount).toBe(1);
});

it("refetch re-hits the endpoint and clears the error", async () => {
let callCount = 0;
server.use(
Expand Down
16 changes: 13 additions & 3 deletions src/hooks/useRecentActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ interface UseRecentActivityOptions {
limit?: number;
/** Polling cadence override. Pass 0 to disable. */
pollIntervalMs?: number;
/** When false, no request is made and the feed stays empty. */
enabled?: boolean;
}

function isMockEnabled(): boolean {
return import.meta.env.VITE_USE_MOCK_ACTIVITY === "true";
}

export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRecentActivityResult {
const { limit = 10, pollIntervalMs = RECENT_ACTIVITY_POLL_INTERVAL_MS } = options;
const { limit = 10, pollIntervalMs = RECENT_ACTIVITY_POLL_INTERVAL_MS, enabled = true } = options;
const mock = isMockEnabled();

const [items, setItems] = useState<ActivityItem[]>([]);
Expand Down Expand Up @@ -68,6 +70,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe
);

useEffect(() => {
if (!enabled) {
setItems([]);
setError(null);
setIsLoading(false);
return;
}

const controller = new AbortController();
void fetchOnce(controller.signal);

Expand All @@ -83,12 +92,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe
controller.abort();
window.clearInterval(intervalId);
};
}, [fetchOnce, mock, pollIntervalMs]);
}, [fetchOnce, mock, pollIntervalMs, enabled]);

const refetch = useCallback(async (): Promise<void> => {
if (!enabled) return;
setIsLoading(true);
await fetchOnce();
}, [fetchOnce]);
}, [fetchOnce, enabled]);

return { items, isLoading, error, refetch };
}
Loading