diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 806499c2273b..388d4fb86ec6 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -10,7 +10,7 @@ import { resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useState } from "react"; +import { type ReactNode, useCallback, useState } from "react"; import { ActivityIndicator, Pressable, @@ -28,6 +28,7 @@ import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-typ import { serverEnvironment } from "../../state/server"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { ConnectionHostStorage } from "./ConnectionHostStorage"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; @@ -225,6 +226,13 @@ function ConnectedCloudEnvironmentRow(props: { errorExpanded={props.errorExpanded} label={props.environment.environmentLabel} machine={resolveEnvironmentMachineKind(serverConfig)} + storageIndicator={ + + } onValueChange={(enabled) => { if (enabled) { props.onConnect(); @@ -286,6 +294,7 @@ function CloudEnvironmentRowShell(props: { readonly onToggleError: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; + readonly storageIndicator?: ReactNode; readonly value: boolean; }) { const isRetrying = @@ -406,6 +415,7 @@ function CloudEnvironmentRowShell(props: { ) : null} + {props.storageIndicator} - - + + + - - - - - {props.environment.environmentLabel} + + + + + {props.environment.environmentLabel} + + + + {props.environment.displayUrl} + {statusLabel ? ( + + {statusLabel} + {statusTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); + }} + onPress={(event) => { + event.stopPropagation(); + }} + > + {statusTraceId} + + + ) : null} + + ) : null} - - {props.environment.displayUrl} - - {statusLabel ? ( - - {statusLabel} - {statusTraceId ? ( - <> - {" Trace ID: "} - { - event.stopPropagation(); - copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); - }} - onPress={(event) => { - event.stopPropagation(); - }} - > - {statusTraceId} - - - ) : null} - - ) : null} - - + + + - + {props.expanded ? ( ; + readonly onRefresh?: () => void; +}) { + const { presentation } = props; + const loading = presentation.status === "loading"; + const [open, setOpen] = useState(false); + const usedPercent = + presentation.status === "available" ? 100 - presentation.availablePercent : null; + + return ( + <> + setOpen(true)} + className="h-11 w-11 items-center justify-center rounded-full active:bg-subtle" + > + + + {usedPercent !== null ? ( + + ) : null} + + + setOpen(false)}> + setOpen(false)} + > + event.stopPropagation()} + > + + + Disk containing T3 data + + {presentation.status === "available" + ? presentation.label + : loading + ? "Checking storage…" + : "Storage unavailable"} + + + {props.onRefresh ? ( + + + + ) : null} + + {presentation.status === "available" ? ( + + {Math.round(100 - presentation.availablePercent)}% used or reserved{"\n"} + Checked at {new Date(presentation.sampledAt).toLocaleTimeString()} + + ) : null} + + Other drives may have different space available. + + setOpen(false)} + className="min-h-11 items-center justify-center rounded-xl bg-subtle" + > + Done + + + + + + ); +} + +function ConnectedHostStorage(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const query = serverEnvironment.hostStorage({ environmentId: props.environmentId, input: {} }); + const result = useAtomValue(query); + const refresh = useAtomRefresh(query); + + return ( + + ); +} + +export function ConnectionHostStorage(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly connected: boolean; +}) { + const isFocused = useIsFocused(); + if (!isFocused) return null; + + return props.connected ? ( + + ) : ( + + ); +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..1def9a08df47 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -54,6 +54,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostStorage]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/resourceTelemetry/HostResources.test.ts b/apps/server/src/resourceTelemetry/HostResources.test.ts new file mode 100644 index 000000000000..7d0ec7fcd480 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.test.ts @@ -0,0 +1,192 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import { ServerConfig } from "../config.ts"; +import * as HostResources from "./HostResources.ts"; + +const TestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-host-resources-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTest = Effect.fn(function* (statfs: typeof HostResources.HostStorageStatFs.Service) { + return yield* HostResources.make().pipe( + Effect.provideService(HostResources.HostStorageStatFs, statfs), + Effect.provideService(HostProcessPlatform, "win32"), + ); +}); + +describe("HostResources storage", () => { + it.effect( + "shares an uncancellable filesystem call across concurrent reads and timed-out retries", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const pending = Promise.withResolvers<{ blocks: bigint; bsize: bigint; bavail: bigint }>(); + let reads = 0; + const service = yield* makeTest( + HostResources.makeHostStorageStatFs(() => { + reads++; + Deferred.doneUnsafe(started, Effect.void); + return pending.promise; + }), + ); + const clients = yield* Effect.all([service.readStorage, service.readStorage], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(clients)).map((reading) => reading.storage)).toEqual([ + null, + null, + ]); + const retry = yield* service.readStorage.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(retry)).storage).toBeNull(); + expect(reads).toBe(1); + + pending.resolve({ blocks: 1000n, bsize: 4096n, bavail: 100n }); + yield* Effect.promise(() => pending.promise); + expect((yield* service.readStorage).storage).toEqual({ + totalBytes: 4096000, + availableBytes: 409600, + }); + expect(reads).toBe(2); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("releases the in-flight sample after a filesystem rejection", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeTest( + HostResources.makeHostStorageStatFs(() => { + reads++; + return reads === 1 + ? Promise.reject(new Error("filesystem unavailable")) + : Promise.resolve({ blocks: 1000n, bsize: 4096n, bavail: 100n }); + }), + ); + expect((yield* service.readStorage).storage).toBeNull(); + expect((yield* service.readStorage).storage).toEqual({ + totalBytes: 4096000, + availableBytes: 409600, + }); + expect(reads).toBe(2); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("samples the configured data filesystem again on every explicit read", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const paths: string[] = []; + const service = yield* makeTest((path) => + Effect.sync(() => { + paths.push(path); + return { blocks: 1000n, bsize: 4096n, bavail: BigInt(paths.length) }; + }), + ); + const first = yield* service.readStorage; + expect(first).toEqual({ + sampledAt: 0, + storage: { totalBytes: 4096000, availableBytes: 4096 }, + }); + yield* TestClock.adjust("1 milli"); + const refreshed = yield* service.readStorage; + expect(refreshed).toEqual({ + sampledAt: 1, + storage: { totalBytes: 4096000, availableBytes: 8192 }, + }); + expect(paths).toEqual([stateDir, stateDir]); + }).pipe(Effect.provide(TestLayer)), + ); + + for (const { name, stats, storage } of [ + { + name: "reports zero available bytes for a full filesystem", + stats: { blocks: 1000n, bsize: 4096n, bavail: 0n }, + storage: { totalBytes: 4096000, availableBytes: 0 }, + }, + { + name: "rejects an unknown zero capacity", + stats: { blocks: 0n, bsize: 4096n, bavail: 0n }, + storage: null, + }, + { + name: "rejects negative available blocks", + stats: { blocks: 1000n, bsize: 4096n, bavail: -1n }, + storage: null, + }, + { + name: "rejects available capacity above the total", + stats: { blocks: 1000n, bsize: 4096n, bavail: 1001n }, + storage: null, + }, + { + name: "rejects nonpositive block size", + stats: { blocks: -1000n, bsize: -4096n, bavail: -1n }, + storage: null, + }, + { + name: "rejects capacity exceeding safe integer precision", + stats: { blocks: BigInt(Number.MAX_SAFE_INTEGER), bsize: 4096n, bavail: 0n }, + storage: null, + }, + ]) { + it.effect(name, () => + Effect.gen(function* () { + const service = yield* makeTest(() => Effect.succeed(stats)); + expect((yield* service.readStorage).storage).toEqual(storage); + }).pipe(Effect.provide(TestLayer)), + ); + } + + it.effect("recovers from a filesystem failure on the next read", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeTest(() => + Effect.suspend(() => { + reads++; + return reads === 1 + ? Effect.fail(new HostResources.HostStorageError({ cause: "filesystem unavailable" })) + : Effect.succeed({ blocks: 1000n, bsize: 4096n, bavail: 100n }); + }), + ); + expect((yield* service.readStorage).storage).toBeNull(); + expect((yield* service.readStorage).storage).toEqual({ + totalBytes: 4096000, + availableBytes: 409600, + }); + expect(reads).toBe(2); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("keeps cached load-balancing reads independent of a stuck storage request", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + let storageReads = 0; + const service = yield* makeTest(() => + Effect.gen(function* () { + storageReads++; + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }), + ); + const storage = yield* service.readStorage.pipe(Effect.forkChild); + yield* Deferred.await(started); + const resources = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + const first = yield* Fiber.join(resources); + expect(first.sampledAt).toBe(200); + expect(first.totalMemoryBytes).toBeGreaterThan(0); + expect(yield* service.read).toEqual(first); + expect(storageReads).toBe(1); + yield* TestClock.adjust("800 millis"); + expect(yield* Fiber.join(storage)).toEqual({ sampledAt: 1000, storage: null }); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 032832dd4869..c06cfaaa71a7 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -1,5 +1,12 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect FileSystem has no free-space query. +import * as NodeFSP from "node:fs/promises"; +import type * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { + HostStorageSnapshot, + type HostResourcesSnapshot, + type HostStorageResult, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -7,11 +14,48 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ServerConfig } from "../config.ts"; + +export class HostStorageError extends Schema.TaggedError()("HostStorageError", { + cause: Schema.Defect(), +}) {} + +type StorageStats = Pick; + +/** A timed-out caller must not start another uncancellable statfs on the same filesystem. */ +export function makeHostStorageStatFs( + read: (path: string) => Promise = (path) => NodeFSP.statfs(path, { bigint: true }), +) { + const pending = new Map>(); + return (path: string) => + Effect.tryPromise({ + try: () => { + const current = pending.get(path); + if (current) return current; + const next = read(path).finally(() => pending.delete(path)); + pending.set(path, next); + return next; + }, + catch: (cause) => new HostStorageError({ cause }), + }); +} + +export const HostStorageStatFs = Context.Reference>( + "t3/resourceTelemetry/HostStorageStatFs", + { defaultValue: makeHostStorageStatFs }, +); + +const decodeStorage = Schema.decodeUnknownEffect(HostStorageSnapshot); + export class HostResources extends Context.Service< HostResources, - { readonly read: Effect.Effect } + { + readonly read: Effect.Effect; + readonly readStorage: Effect.Effect; + } >()("t3/resourceTelemetry/HostResources") {} function readCpu() { @@ -42,6 +86,21 @@ export const make = Effect.fn("makeHostResources")(function* () { const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { stateDir } = yield* ServerConfig; + const statfs = yield* HostStorageStatFs; + + const sampleStorage = Effect.fn("HostResources.sampleStorage")( + function* () { + const stats = yield* statfs(stateDir); + if (stats.bsize <= 0n) return null; + return yield* decodeStorage({ + totalBytes: Number(stats.blocks * stats.bsize), + availableBytes: Number(stats.bavail * stats.bsize), + }); + }, + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed(null)), + ); const sample = Effect.fn("HostResources.sample")(function* () { const previousCpu = readCpu(); @@ -87,7 +146,15 @@ export const make = Effect.fn("makeHostResources")(function* () { lookup: (_key: "host") => sample(), timeToLive: "5 seconds", }); - return HostResources.of({ read: Cache.get(cache, "host") }); + return HostResources.of({ + read: Cache.get(cache, "host"), + // Settings reads storage on demand. Keep it independent of load balancing and + // uncached so an explicit refresh always checks the filesystem again. + readStorage: Effect.gen(function* () { + const storage = yield* sampleStorage(); + return { sampledAt: DateTime.toEpochMillis(yield* DateTime.now), storage }; + }), + }); }); export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2f32b6524d7b..c0cfd4de3b98 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6193,6 +6193,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns disk capacity independently over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetHostStorage]({})), + ); + assert.isAtLeast(result.sampledAt, 0); + assert.isNotNull(result.storage); + if (result.storage) { + assert.isAbove(result.storage.totalBytes, 0); + assert.isAtLeast(result.storage.availableBytes, 0); + assert.isAtMost(result.storage.availableBytes, result.storage.totalBytes); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("returns cached whole-host resources over websocket", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -6225,6 +6242,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const commandCalls = yield* Ref.make(0); const hostResources = yield* HostResources.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-host-resources-" })), Effect.provideService(HostProcessPlatform, "darwin"), Effect.provide( Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ @@ -6254,6 +6272,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const started = yield* Deferred.make(); const commandCalls = yield* Ref.make(0); const hostResources = yield* HostResources.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-host-resources-" })), Effect.provideService(HostProcessPlatform, "darwin"), Effect.provide( Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..da3e7aeb30ee 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2028,6 +2028,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostStorage]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostStorage, hostResources.readStorage, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ad7665651171..61cee7424587 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -61,6 +61,7 @@ import { import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; +import { HostStorageIndicator } from "./HostStorageIndicator"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -1491,6 +1492,7 @@ function SavedBackendListRow({

{environment.label}

+ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

@@ -3133,7 +3135,12 @@ export function ConnectionsSettings() { {canManageLocalBackend ? ( <> - + : null + } + > {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( - + : null + } + > ; + onRefresh?: () => void; +}) { + const reading = + storage.status === "available" + ? storage.label + : storage.status === "loading" + ? "Reading storage…" + : "Storage unavailable"; + const usedPercent = storage.status === "available" ? 100 - storage.availablePercent : null; + + return ( + + + + + } + /> + +
+
+ Disk space + {onRefresh ? ( + + ) : null} +
+

+ {reading} +

+ {storage.status === "available" ? ( +

+ {Math.round(100 - storage.availablePercent)}% used or reserved +
+ Checked {new Date(storage.sampledAt).toLocaleTimeString()} +

+ ) : null} +

+ Disk containing this server's T3 data. Other drives may have different space available. +

+
+
+
+ ); +} + +function ConnectedStorageIndicator({ environment }: { environment: EnvironmentPresentation }) { + const query = serverEnvironment.hostStorage({ + environmentId: environment.environmentId, + input: {}, + }); + const result = useAtomValue(query); + const refresh = useAtomRefresh(query); + return ( + + ); +} + +export function HostStorageIndicator({ environment }: { environment: EnvironmentPresentation }) { + return environment.connection.phase === "connected" ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..929f6c005a8c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -547,6 +547,13 @@ export const SETTINGS_SEARCH_ITEMS = [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", ], }, + { + id: "disk-storage", + title: "Disk space", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["disk space capacity free available storage machine environment server"], + }, { id: "remote-environments", title: "Remote environments", diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..5d711ac99d28 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -61,6 +61,14 @@ created in Settings can only be copied from the client that created them while its Connections page stays open. If you leave or reload that page, create another link to share. +### Check available disk space + +Hover or tap the ring beside an environment in **Settings → Connections** on web +and desktop, or tap it in **Settings → Environments** on mobile. Each reading describes the disk containing +that server's T3 data. Projects on another drive may have different space available; +WSL and containers report the filesystem visible to their server. Refresh the reading +to check again. An offline or unsupported environment shows storage as unavailable. + ### Balance new threads across machines Auto balance is off by default. On web and desktop, enable it in diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 368585556cfe..6be449b23719 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./host-storage": { + "types": "./src/hostStorage.ts", + "default": "./src/hostStorage.ts" + }, "./load-balancing": { "types": "./src/load-balancing.ts", "default": "./src/load-balancing.ts" diff --git a/packages/client-runtime/src/hostStorage.test.ts b/packages/client-runtime/src/hostStorage.test.ts new file mode 100644 index 000000000000..4029b9d9a0f3 --- /dev/null +++ b/packages/client-runtime/src/hostStorage.test.ts @@ -0,0 +1,61 @@ +import type { HostStorageResult } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { getHostStoragePresentation } from "./hostStorage.ts"; + +const snapshot: HostStorageResult = { + sampledAt: 1_789_000_000_000, + storage: { totalBytes: 457 * 1024 ** 3, availableBytes: 254 * 1024 ** 3 }, +}; + +describe("host storage presentation", () => { + it("formats the available capacity and keeps the server's reading time", () => { + expect(getHostStoragePresentation(AsyncResult.success(snapshot))).toEqual({ + status: "available", + label: "254 GiB available of 457 GiB", + availablePercent: (254 / 457) * 100, + sampledAt: snapshot.sampledAt, + }); + }); + + it("shows a full disk as zero available, not unknown", () => { + expect( + getHostStoragePresentation( + AsyncResult.success({ + ...snapshot, + storage: { totalBytes: 2 * 1024 ** 4, availableBytes: 0 }, + }), + ), + ).toMatchObject({ + status: "available", + label: "0 B available of 2 TiB", + availablePercent: 0, + }); + }); + + it("treats unavailable storage as unknown", () => { + expect(getHostStoragePresentation(AsyncResult.success({ ...snapshot, storage: null }))).toEqual( + { status: "unavailable" }, + ); + }); + + it("does not show the previous capacity while refreshing", () => { + expect(getHostStoragePresentation(AsyncResult.initial())).toEqual({ status: "loading" }); + expect(getHostStoragePresentation(AsyncResult.waiting(AsyncResult.success(snapshot)))).toEqual({ + status: "loading", + }); + }); + + it("shows unavailable after disconnection or an older server rejects the storage RPC", () => { + expect( + getHostStoragePresentation( + AsyncResult.failure(Cause.fail(new Error("Disconnected")), { + previousSuccess: Option.some(AsyncResult.success(snapshot)), + }), + ), + ).toEqual({ status: "unavailable" }); + }); +}); diff --git a/packages/client-runtime/src/hostStorage.ts b/packages/client-runtime/src/hostStorage.ts new file mode 100644 index 000000000000..f8e6e1e01eac --- /dev/null +++ b/packages/client-runtime/src/hostStorage.ts @@ -0,0 +1,29 @@ +import type { HostStorageResult } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; + +const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; + +function formatStorageBytes(bytes: number): string { + const unit = bytes === 0 ? 0 : Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 5); + const value = (bytes / 1024 ** unit).toLocaleString(undefined, { maximumFractionDigits: 1 }); + return `${value} ${BYTE_UNITS[unit]}`; +} + +/** A refreshing or failed reading must not present cached capacity as current. */ +export function getHostStoragePresentation( + result: AsyncResult.AsyncResult, +) { + if (AsyncResult.isInitial(result) || result.waiting) { + return { status: "loading" } as const; + } + if (!AsyncResult.isSuccess(result) || result.value.storage == null) { + return { status: "unavailable" } as const; + } + const { totalBytes, availableBytes } = result.value.storage; + return { + status: "available", + label: `${formatStorageBytes(availableBytes)} available of ${formatStorageBytes(totalBytes)}`, + availablePercent: (availableBytes / totalBytes) * 100, + sampledAt: result.value.sampledAt, + } as const; +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 18127076ff19..01175c782ba2 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1019,6 +1019,13 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostStorage: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-storage", + idleTtlMs: 0, + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostStorage, input).pipe(Effect.timeout("5 seconds")), + }), hostResources: createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:server:host-resources", idleTtlMs: 0, diff --git a/packages/contracts/src/resourceTelemetry.test.ts b/packages/contracts/src/resourceTelemetry.test.ts new file mode 100644 index 000000000000..a5f70d37308f --- /dev/null +++ b/packages/contracts/src/resourceTelemetry.test.ts @@ -0,0 +1,32 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { HostStorageResult } from "./resourceTelemetry.ts"; + +const decode = Schema.decodeUnknownSync(HostStorageResult); +const resources = { sampledAt: 0 }; + +describe("HostStorageResult storage", () => { + it("accepts unavailable readings", () => { + expect(decode({ ...resources, storage: null }).storage).toBeNull(); + }); + + it("accepts a full filesystem with zero available bytes", () => { + const storage = { totalBytes: 4096, availableBytes: 0 }; + expect(decode({ ...resources, storage }).storage).toEqual(storage); + }); + + it.each([ + { totalBytes: 0, availableBytes: 0 }, + { totalBytes: -1, availableBytes: 0 }, + { totalBytes: 4096, availableBytes: -1 }, + { totalBytes: 4096, availableBytes: 4097 }, + { totalBytes: 1.5, availableBytes: 0 }, + { totalBytes: 4096, availableBytes: 1.5 }, + { totalBytes: Number.MAX_SAFE_INTEGER + 1, availableBytes: 0 }, + { totalBytes: Infinity, availableBytes: 0 }, + { totalBytes: 4096, availableBytes: NaN }, + ])("rejects invalid capacity %j", (storage) => { + expect(() => decode({ ...resources, storage })).toThrow(); + }); +}); diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index ee9d2b3ac258..c31f63ed9471 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,19 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const; +/** Capacity of the filesystem containing this environment's T3 data directory. */ +export const HostStorageSnapshot = Schema.Struct({ + totalBytes: PositiveInt, + availableBytes: NonNegativeInt, +}).check(Schema.makeFilter((storage) => storage.availableBytes <= storage.totalBytes)); +export type HostStorageSnapshot = typeof HostStorageSnapshot.Type; + +export const HostStorageResult = Schema.Struct({ + sampledAt: NonNegativeInt, + storage: Schema.NullOr(HostStorageSnapshot), +}); +export type HostStorageResult = typeof HostStorageResult.Type; + /** Whole-host capacity, independent of T3's process diagnostics. */ export const HostResourcesSnapshot = Schema.Struct({ sampledAt: NonNegativeInt, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..eb5a698ed113 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -211,6 +211,7 @@ import { } from "./server.ts"; import { HostResourcesSnapshot, + HostStorageResult, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -326,6 +327,7 @@ export const WS_METHODS = { serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetHostResources: "server.getHostResources", + serverGetHostStorage: "server.getHostStorage", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -547,6 +549,12 @@ const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, error: EnvironmentAuthorizationError, }); +const WsServerGetHostStorageRpc = Rpc.make(WS_METHODS.serverGetHostStorage, { + payload: Schema.Struct({}), + success: HostStorageResult, + error: EnvironmentAuthorizationError, +}); + const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { payload: ServerProcessResourceHistoryInput, success: ServerProcessResourceHistoryResult, @@ -1207,6 +1215,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetHostResourcesRpc, + WsServerGetHostStorageRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc,