Skip to content
Merged
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
32 changes: 26 additions & 6 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export function UsageRouteScreen() {
),
),
];
const canReadDiagnostics = selectedEnvironments.some(
(environment) => environment.canReadDiagnostics,
);

const days = useMemo(
() => enumerateDays(window.sinceDay, window.untilDay),
Expand Down Expand Up @@ -272,10 +275,12 @@ export function UsageRouteScreen() {
contentContainerClassName="gap-6 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
refreshControl={
<RefreshControl
refreshing={showingLimits ? limits.refreshing : refreshingUsage}
onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
/>
showingLimits || canReadDiagnostics ? (
<RefreshControl
refreshing={showingLimits ? limits.refreshing : refreshingUsage}
onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
/>
) : undefined
}
>
<SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
Expand Down Expand Up @@ -335,6 +340,20 @@ export function UsageRouteScreen() {
? "Connect an environment to see usage."
: "Select an environment to see usage."}
</Text>
) : !canReadDiagnostics ? (
// Each environment explains itself: a denied grant and a failed
// access check are different problems.
<View className="gap-2 py-16">
{selectedEnvironments.map((environment) => (
<Text
key={environment.environmentId}
className="text-center text-base text-foreground-muted"
>
{selectedEnvironments.length > 1 ? `${environment.label}: ` : null}
{environment.error}
</Text>
))}
</View>
) : (
<>
{sourceMessages.map((message) => (
Expand Down Expand Up @@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
: "clientBehind",
});
}
// The reason matters: a denied grant and a failed scan need different fixes.
if (environment.error)
return environment.summary ? `${environment.error} Showing saved totals.` : environment.error;
if (!environment.isConnected)
return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
if (environment.error)
return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable";
if (isUsageLoading(environment))
return environment.summary ? "Updating usage…" : "Loading usage…";
return "Usage up to date";
Expand Down
32 changes: 31 additions & 1 deletion apps/mobile/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
*/
import { useAtomValue } from "@effect/atom-react";
import {
AuthDiagnosticsReadScope,
USAGE_CONTRACT_VERSION,
type EnvironmentId,
type UsageSummary,
type UsageSummaryInput,
} from "@t3tools/contracts";
import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
Expand All @@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react";
import { appAtomRegistry } from "./atom-registry";
import { environmentPresentations } from "./presentation";
import { serverEnvironment } from "./server";
import { environmentSession, readEnvironmentScope } from "./session";

export interface EnvironmentUsageStatus {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly isPending: boolean;
readonly isConnected: boolean;
readonly canReadDiagnostics: boolean;
readonly error: string | null;
readonly summary: UsageSummary | null;
readonly needsCursorKeychainAccess: boolean;
Expand All @@ -50,13 +54,31 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>

const statuses: EnvironmentUsageStatus[] = [];
for (const [environmentId, presentation] of presentations) {
const sessionResult = get(environmentSession.sessionStateAtom(environmentId));
const access = resolveUsageAccess({
connectionPhase: presentation.connection.phase,
session: Option.getOrNull(AsyncResult.value(sessionResult)),
hasSessionError: sessionResult._tag === "Failure",
});
if (!access.canReadDiagnostics) {
statuses.push({
environmentId,
label: presentation.entry.target.label,
isConnected: presentation.connection.phase === "connected",
...access,
summary: null,
needsCursorKeychainAccess: false,
});
continue;
}
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
const summary = Option.getOrNull(AsyncResult.value(result));
statuses.push({
environmentId,
label: presentation.entry.target.label,
isPending: result.waiting,
isConnected: presentation.connection.phase === "connected",
canReadDiagnostics: true,
error: result._tag === "Failure" ? "This environment could not report usage." : null,
summary,
needsCursorKeychainAccess: needsCursorKeychainAccess(
Expand Down Expand Up @@ -123,7 +145,15 @@ export function useUsage(
registry: appAtomRegistry,
server: serverEnvironment,
presentations: environmentPresentations,
environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),
// Only environments this connection may read; the others report a
// permission error instead of a stale or failed rescan.
environmentIds: selectedEnvironments
.filter(
(environment) =>
environment.canReadDiagnostics &&
readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope),
)
.map(({ environmentId }) => environmentId),
input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
}),
[selectedEnvironments, windowKey],
Expand Down
17 changes: 14 additions & 3 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AuthEnvironmentMaintainScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
AuthProvidersManageScope,
AuthSettingsWriteScope,
Expand Down Expand Up @@ -193,7 +194,17 @@ describe("RPC scope middleware", () => {
),
);

it.effect("checks each RPC's declared scope before its handler runs", () =>
it.effect.each([
{ scopes: [AuthOrchestrationReadScope], missing: AuthEnvironmentMaintainScope },
{
scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope],
missing: AuthDiagnosticsReadScope,
},
{
scopes: [AuthOrchestrationReadScope, AuthDiagnosticsReadScope],
missing: AuthEnvironmentMaintainScope,
},
])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) =>
Effect.gen(function* () {
const handled: Array<string> = [];
const client = yield* RpcTest.makeClient(group).pipe(
Expand All @@ -203,7 +214,7 @@ describe("RPC scope middleware", () => {
group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
),
RpcAuthorization.layer([AuthOrchestrationReadScope]),
RpcAuthorization.layer(scopes),
),
),
);
Expand All @@ -213,7 +224,7 @@ describe("RPC scope middleware", () => {
yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
).toMatchObject({
_tag: "EnvironmentAuthorizationError",
requiredScope: AuthEnvironmentMaintainScope,
requiredScope: missing,
});
expect(handled).toEqual([]);
}).pipe(Effect.scoped),
Expand Down
22 changes: 14 additions & 8 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AuthEnvironmentMaintainScope,
AuthFilesystemReadScope,
AuthFilesystemWriteScope,
AuthDiagnosticsReadScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
Expand Down Expand Up @@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,
[WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope,
// Load-balancing new threads reads host load; that is part of operating
// threads, not of inspecting diagnostics.
[WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope,
[WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
[WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope,
Expand Down Expand Up @@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
[WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope,
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
[WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
Expand Down Expand Up @@ -230,6 +233,9 @@ const requiredScopesForRpcCall = (
method: string,
payload: unknown,
): ReadonlyArray<AuthEnvironmentScope> => {
if (method === WS_METHODS.serverRetryResourceTelemetry) {
return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope];
}
if (method === WS_METHODS.assetsCreateUrl) {
const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload);
return [
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
AuthDiagnosticsReadScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
Expand Down Expand Up @@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
title: "Control previews",
description: "Open browser previews and host browser automation.",
},
{
scope: AuthDiagnosticsReadScope,
title: "View diagnostics and usage",
description: "Read process diagnostics, resource history, and usage totals.",
},
{
scope: AuthTerminalOperateScope,
title: "Use terminals",
Expand Down Expand Up @@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
disabled={isCreatingPairingLink}
onClick={() =>
setPairingScopes(
[AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) =>
delegatableScopes.includes(scope),
),
[
AuthOrchestrationReadScope,
AuthFilesystemReadScope,
AuthDiagnosticsReadScope,
].filter((scope) => delegatableScopes.includes(scope)),
)
}
>
Expand Down
32 changes: 28 additions & 4 deletions apps/web/src/components/settings/DiagnosticsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ProcessSignalActions } from "./ProcessSignalActions";
import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
import { environmentSession } from "../../state/session";
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
Expand All @@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences";
import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
import { useEnvironmentQuery } from "../../state/query";
import { serverEnvironment } from "../../state/server";
import { shellEnvironment } from "../../state/shell";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { Button } from "../ui/button";
import { MorphIcon } from "~/components/MorphIcon";
Expand Down Expand Up @@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() {
const observability = environment?.serverConfig?.observability;
const availableEditors = environment?.serverConfig?.availableEditors;
const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
const session = useEnvironmentQuery(
environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
);
const diagnosticsAccess = resolveUsageAccess({
connectionPhase: environment?.connection.phase ?? "available",
session: session.data,
hasSessionError: session.error !== null,
});
const canReadDiagnostics = diagnosticsAccess.canReadDiagnostics;
const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
reportFailure: false,
});
Expand All @@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() {
RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
RESOURCE_HISTORY_WINDOWS[1];
const { data, error, isPending, refresh } = useEnvironmentQuery(
environmentId === null
environmentId === null || !canReadDiagnostics
? null
: serverEnvironment.traceDiagnostics({ environmentId, input: {} }),
);
Expand All @@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() {
isPending: isProcessPending,
refresh: refreshProcesses,
} = useEnvironmentQuery(
environmentId === null
environmentId === null || !canReadDiagnostics
? null
: serverEnvironment.processDiagnostics({ environmentId, input: {} }),
);
Expand All @@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() {
isPending: isResourcePending,
refresh: refreshResources,
} = useEnvironmentQuery(
environmentId === null
environmentId === null || !canReadDiagnostics
? null
: serverEnvironment.processResourceHistory({
environmentId,
Expand Down Expand Up @@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() {
? Option.getOrElse(data.partialFailure, () => false)
: false;

if (!canReadDiagnostics) {
return (
<SettingsPageContainer>
<p className="text-sm text-muted-foreground">
{environmentId === null
? "Connect an environment to see diagnostics."
: diagnosticsAccess.isPending
? "Checking diagnostics access…"
: diagnosticsAccess.error}
</p>
</SettingsPageContainer>
);
}
Comment thread
juliusmarminge marked this conversation as resolved.

return (
<SettingsPageContainer width="expanded" className="gap-10">
<ResourceTelemetryDiagnostics environmentId={environmentId} />
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/usage/UsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const environments = [
environmentId: EnvironmentId.make("test-environment"),
label: "Test environment",
isPending: false,
canReadDiagnostics: true,
error: null,
summary: {
contractVersion: USAGE_CONTRACT_VERSION,
Expand Down
Loading
Loading