diff --git a/Cargo.lock b/Cargo.lock index c93b8139e..4c5912079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2304,6 +2304,7 @@ dependencies = [ "serde_yaml", "sha2", "strum", + "sysinfo", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/apps/fabro-web/app/lib/format.ts b/apps/fabro-web/app/lib/format.ts index 460ec9165..8f699a8cc 100644 --- a/apps/fabro-web/app/lib/format.ts +++ b/apps/fabro-web/app/lib/format.ts @@ -82,7 +82,10 @@ export function formatDurationSecs(secs: number): string { export function formatDurationMs(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 60_000) { + const seconds = ms / 1000; + return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}s`; + } const minutes = Math.floor(ms / 60_000); const seconds = Math.round((ms % 60_000) / 1000); return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index eca7ae931..b6681caf1 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -22,6 +22,7 @@ import type { SandboxServiceListResponse, ServerSettings, SystemInfoResponse, + SystemResourcesResponse, VncPreviewResponse, WorkflowDetailResponse, WorkflowSettings, @@ -93,6 +94,14 @@ export function useSystemInfo() { ); } +export function useSystemResources() { + return useSWR( + queryKeys.system.resources(), + () => apiData(() => systemApi.getSystemResources()), + { refreshInterval: 5_000 }, + ); +} + export function useBoardsRuns(includeArchived: boolean = false) { return useSWR( queryKeys.boards.runs(includeArchived), diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 5f36b5341..6be29bb52 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -35,6 +35,7 @@ export const queryKeys = { }, system: { info: () => ["system", "info"] as const, + resources: () => ["system", "resources"] as const, attachUrl: () => "/api/v1/attach", }, boards: { diff --git a/apps/fabro-web/app/router.test.tsx b/apps/fabro-web/app/router.test.tsx index 70007f8fd..2901413f5 100644 --- a/apps/fabro-web/app/router.test.tsx +++ b/apps/fabro-web/app/router.test.tsx @@ -32,4 +32,10 @@ describe("browser router", () => { expect(paths).toContain("/setup"); expect(paths).not.toContain("/setup/complete"); }); + + test("exposes the resources settings page", () => { + const paths = collectPaths(routes); + + expect(paths).toContain("/settings/resources"); + }); }); diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 53f960b92..a59eaa426 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -35,6 +35,7 @@ import * as Settings from "./routes/settings"; import * as SettingsGeneral from "./routes/settings-general"; import * as SettingsIntegrations from "./routes/settings-integrations"; import * as SettingsModels from "./routes/settings-models"; +import * as SettingsResources from "./routes/settings-resources"; import * as SettingsSecurity from "./routes/settings-security"; import * as SettingsStorage from "./routes/settings-storage"; import * as SettingsLiveEvents from "./routes/settings-live-events"; @@ -139,6 +140,7 @@ export const routes: RouteObject[] = [ route("models", SettingsModels), route("security", SettingsSecurity), route("storage", SettingsStorage), + route("resources", SettingsResources), route("live-events", SettingsLiveEvents), ], }), diff --git a/apps/fabro-web/app/routes/settings-general.tsx b/apps/fabro-web/app/routes/settings-general.tsx index e76aec902..1d4838683 100644 --- a/apps/fabro-web/app/routes/settings-general.tsx +++ b/apps/fabro-web/app/routes/settings-general.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import type { ServerListenSettings, ServerSettings, + SystemInfoResponse, } from "@qltysh/fabro-api-client"; -import { useServerSettings } from "../lib/queries"; +import { formatDurationSecs } from "../lib/format"; +import { useServerSettings, useSystemInfo } from "../lib/queries"; import { CollapsibleFile } from "../components/collapsible-file"; import { Badge, @@ -48,7 +50,10 @@ export default function SettingsGeneral() {
{view === "settings" ? ( - + <> + + + ) : ( ; + } + return ( + + + {info.version ? info.version : Unknown} + + + + + + {info.uptime_secs != null ? ( + formatDurationSecs(info.uptime_secs) + ) : ( + Unknown + )} + + + ); +} + +function OperatingSystemValue({ info }: { info: SystemInfoResponse }) { + return ( + + {info.os ?? "unknown"} + {info.arch ?? "unknown"} + + ); +} + function ListenValue({ listen }: { listen: ServerListenSettings }) { if (listen.type === "tcp") { return ( diff --git a/apps/fabro-web/app/routes/settings-resources.test.tsx b/apps/fabro-web/app/routes/settings-resources.test.tsx new file mode 100644 index 000000000..7b3f5c2b2 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-resources.test.tsx @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + SystemCpuResources, + SystemDiskResources, + SystemMemoryResources, + SystemResourcesResponse, +} from "@qltysh/fabro-api-client"; +import TestRenderer, { act } from "react-test-renderer"; +import { setupReactTestEnv } from "../lib/test-utils"; + +let systemResources: SystemResourcesResponse | undefined; +let teardownReactTestEnv: (() => void) | undefined; + +mock.module("../lib/queries", () => ({ + useSystemResources: () => ({ data: systemResources }), +})); + +const { default: SettingsResources } = await import("./settings-resources"); + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderSettingsResources() { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create(); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function textContent(node: ReturnType): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textContent).join(""); + return node.children?.map(textContent).join("") ?? ""; +} + +type ResourceOverrides = Partial< + Omit +> & { + cpu?: Partial; + memory?: Partial; + disk?: Partial; +}; + +function sampleResources(overrides: ResourceOverrides = {}): SystemResourcesResponse { + const resources: SystemResourcesResponse = { + sampled_at: "2026-05-20T15:42:10Z", + cpu: { + supported: true, + scope: "server_environment", + unavailable_reason: null, + logical_cpus: 10, + usage_percent: 18.4, + sample_window_ms: 5000, + }, + memory: { + supported: true, + scope: "cgroup", + unavailable_reason: null, + total_bytes: 8 * 1024 * 1024 * 1024, + used_bytes: 3 * 1024 * 1024 * 1024, + available_bytes: 5 * 1024 * 1024 * 1024, + used_percent: 37.5, + host_total_bytes: 32 * 1024 * 1024 * 1024, + }, + disk: { + supported: true, + scope: "storage_filesystem", + unavailable_reason: null, + storage_path: "/var/lib/fabro", + mount_point: "/", + filesystem: "apfs", + total_bytes: 500 * 1024 * 1024 * 1024, + used_bytes: 200 * 1024 * 1024 * 1024, + available_bytes: 300 * 1024 * 1024 * 1024, + used_percent: 40, + fabro_managed_bytes: 2 * 1024 * 1024 * 1024, + fabro_reclaimable_bytes: 512 * 1024 * 1024, + }, + notes: [], + }; + return { + sampled_at: overrides.sampled_at ?? resources.sampled_at, + cpu: { ...resources.cpu, ...overrides.cpu }, + memory: { ...resources.memory, ...overrides.memory }, + disk: { ...resources.disk, ...overrides.disk }, + notes: overrides.notes ?? resources.notes, + }; +} + +describe("SettingsResources route", () => { + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + systemResources = undefined; + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("renders loaded resource data", () => { + systemResources = sampleResources(); + + const renderer = renderSettingsResources(); + const text = textContent(renderer.toJSON()); + + expect(text).toContain("18.4%"); + expect(text).toContain("5s"); + expect(text).toContain("3 GiB"); + expect(text).toContain("8 GiB"); + expect(text).toContain("2 GiB"); + expect(text).toContain("512 MiB"); + }); + + test("shows CPU warmup state while usage is null", () => { + systemResources = sampleResources({ + cpu: { + supported: true, + scope: "server_environment", + unavailable_reason: null, + logical_cpus: 10, + usage_percent: null, + sample_window_ms: null, + }, + }); + + const renderer = renderSettingsResources(); + + expect(textContent(renderer.toJSON())).toContain("Collecting sample"); + }); + + test("renders unsupported resource sections", () => { + systemResources = sampleResources({ + cpu: { + supported: false, + scope: "server_environment", + unavailable_reason: "CPU metrics unavailable", + logical_cpus: null, + usage_percent: null, + sample_window_ms: null, + }, + disk: { + supported: false, + scope: "storage_filesystem", + unavailable_reason: "No storage filesystem matched", + storage_path: "/var/lib/fabro", + mount_point: null, + filesystem: null, + total_bytes: null, + used_bytes: null, + available_bytes: null, + used_percent: null, + fabro_managed_bytes: 0, + fabro_reclaimable_bytes: 0, + }, + }); + + const renderer = renderSettingsResources(); + const text = textContent(renderer.toJSON()); + + expect(text).toContain("Unsupported"); + expect(text).toContain("CPU metrics unavailable"); + expect(text).toContain("No storage filesystem matched"); + }); + + test("renders notes only when present", () => { + systemResources = sampleResources({ + notes: ["Memory is scoped to the current container."], + }); + + const renderer = renderSettingsResources(); + + expect(textContent(renderer.toJSON())).toContain( + "Memory is scoped to the current container.", + ); + }); +}); diff --git a/apps/fabro-web/app/routes/settings-resources.tsx b/apps/fabro-web/app/routes/settings-resources.tsx new file mode 100644 index 000000000..2e0d1914c --- /dev/null +++ b/apps/fabro-web/app/routes/settings-resources.tsx @@ -0,0 +1,214 @@ +import type { + SystemCpuResources, + SystemDiskResources, + SystemMemoryResources, + SystemResourcesResponse, +} from "@qltysh/fabro-api-client"; +import { formatBytesAsMemory, formatDurationMs } from "../lib/format"; +import { useSystemResources } from "../lib/queries"; +import { + Badge, + Mono, + Muted, + Panel, + PanelSkeleton, + Row, + SettingsPageIntro, +} from "../components/settings-panel"; + +export function meta() { + return [{ title: "Resources — Fabro" }]; +} + +const DESCRIPTION = + "Server-visible CPU, memory, and storage filesystem usage for this Fabro process."; + +export default function SettingsResources() { + const resourcesQuery = useSystemResources(); + const resources = resourcesQuery.data; + + return ( +
+ + {resources ? ( + <> + + + + + + ) : ( + <> + + + + + )} +
+ ); +} + +function CpuPanel({ cpu }: { cpu: SystemCpuResources }) { + if (!cpu.supported) { + return ( + + + + ); + } + + return ( + + + {cpu.usage_percent == null ? ( + Collecting sample + ) : ( + + )} + + + {cpu.sample_window_ms != null ? ( + formatDurationMs(cpu.sample_window_ms) + ) : ( + Unknown + )} + + + ); +} + +function MemoryPanel({ memory }: { memory: SystemMemoryResources }) { + if (!memory.supported) { + return ( + + + + ); + } + + return ( + + + + + + ); +} + +function DiskPanel({ disk }: { disk: SystemDiskResources }) { + if (!disk.supported) { + return ( + + + + {disk.storage_path} + + + + ); + } + + return ( + + + + + + {disk.mount_point ? {disk.mount_point} : Unknown} + + + + ); +} + +function FabroStorageRows({ disk }: { disk: SystemDiskResources }) { + return ( + <> + + {formatBytesAsMemory(disk.fabro_managed_bytes)} + + + {formatBytesAsMemory(disk.fabro_reclaimable_bytes)} + + + ); +} + +function NotesPanel({ resources }: { resources: SystemResourcesResponse }) { + const notes = Array.from(new Set(resources.notes)); + if (notes.length === 0) return null; + + return ( + + {notes.map((note) => ( + + {note} + + ))} + + ); +} + +function UnsupportedRows({ reason }: { reason: string | null }) { + return ( + <> + + Unsupported + + + {reason ?? "Metric unavailable"} + + + ); +} + +function UsageMeter({ + percent, + label, +}: { + percent: number | null | undefined; + label?: string; +}) { + const safePercent = percent == null ? null : Math.min(100, Math.max(0, percent)); + const value = safePercent == null ? "Not available" : formatPercent(safePercent); + return ( +
+
+ {label ?? value} + {label ? ( + {value} + ) : null} +
+
+
+
+
+ ); +} + +function formatPercent(value: number) { + return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; +} diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index 4313b328f..d5503242e 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -4,6 +4,7 @@ import { Cog6ToothIcon, CpuChipIcon, PuzzlePieceIcon, + ServerStackIcon, ShieldCheckIcon, } from "@heroicons/react/24/outline"; import { Link, Outlet, useLocation, useMatches } from "react-router"; @@ -57,6 +58,12 @@ const navItems: NavEntry[] = [ icon: CircleStackIcon, match: (p) => p.startsWith("/settings/storage"), }, + { + name: "Resources", + href: "/settings/resources", + icon: ServerStackIcon, + match: (p) => p.startsWith("/settings/resources"), + }, { type: "divider", key: "after-storage" }, { name: "Live Events", diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 7e53a255c..2839ea9f5 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -3620,6 +3620,20 @@ paths: schema: $ref: "#/components/schemas/SystemInfoResponse" + /api/v1/system/resources: + get: + operationId: getSystemResources + tags: [System] + summary: Retrieve System Resources + description: Returns server-visible CPU, memory, and storage filesystem resource usage. + responses: + "200": + description: System resource usage + content: + application/json: + schema: + $ref: "#/components/schemas/SystemResourcesResponse" + /api/v1/system/df: get: operationId: getSystemDiskUsage @@ -10246,6 +10260,175 @@ components: format: int64 description: Runs currently queued or executing. + SystemResourcesResponse: + description: Server-visible runtime resource usage for the active Fabro process environment. + type: object + required: [sampled_at, cpu, memory, disk, notes] + properties: + sampled_at: + type: string + format: date-time + description: Timestamp when the sample was collected. + cpu: + $ref: "#/components/schemas/SystemCpuResources" + memory: + $ref: "#/components/schemas/SystemMemoryResources" + disk: + $ref: "#/components/schemas/SystemDiskResources" + notes: + type: array + description: Human-readable caveats about unavailable or scoped metrics. + items: + type: string + + SystemCpuResources: + description: CPU resources visible to the Fabro server process. + type: object + required: + - supported + - scope + - unavailable_reason + - logical_cpus + - usage_percent + - sample_window_ms + properties: + supported: + type: boolean + description: Whether CPU metrics are available on this platform. + scope: + $ref: "#/components/schemas/SystemCpuResourceScope" + unavailable_reason: + type: ["string", "null"] + description: Reason metrics are unavailable when unsupported. + logical_cpus: + type: ["integer", "null"] + format: int64 + description: Logical CPUs visible to the server process. + usage_percent: + type: ["number", "null"] + format: double + description: CPU usage percentage, null until a delta sample is available. + sample_window_ms: + type: ["integer", "null"] + format: int64 + description: Elapsed milliseconds since the previous CPU sample, null until a delta sample is available. + + SystemCpuResourceScope: + description: Scope for CPU resource metrics. + type: string + enum: [server_environment] + + SystemMemoryResources: + description: Memory resources visible to the Fabro server process. + type: object + required: + - supported + - scope + - unavailable_reason + - total_bytes + - used_bytes + - available_bytes + - used_percent + - host_total_bytes + properties: + supported: + type: boolean + description: Whether memory metrics are available on this platform. + scope: + $ref: "#/components/schemas/SystemMemoryResourceScope" + unavailable_reason: + type: ["string", "null"] + description: Reason metrics are unavailable when unsupported. + total_bytes: + type: ["integer", "null"] + format: int64 + description: Total memory for the reported scope. + used_bytes: + type: ["integer", "null"] + format: int64 + description: Used memory for the reported scope. + available_bytes: + type: ["integer", "null"] + format: int64 + description: Available memory for the reported scope. + used_percent: + type: ["number", "null"] + format: double + description: Used memory percentage for the reported scope. + host_total_bytes: + type: ["integer", "null"] + format: int64 + description: Host total memory, included even when reporting a cgroup scope. + + SystemMemoryResourceScope: + description: "`cgroup` when Linux cgroup limits are available, otherwise `host`." + type: string + enum: [host, cgroup] + + SystemDiskResources: + description: Filesystem resources for the configured Fabro storage root. + type: object + required: + - supported + - scope + - unavailable_reason + - storage_path + - mount_point + - filesystem + - total_bytes + - used_bytes + - available_bytes + - used_percent + - fabro_managed_bytes + - fabro_reclaimable_bytes + properties: + supported: + type: boolean + description: Whether storage filesystem metrics are available. + scope: + $ref: "#/components/schemas/SystemDiskResourceScope" + unavailable_reason: + type: ["string", "null"] + description: Reason metrics are unavailable when unsupported. + storage_path: + type: string + description: Configured Fabro storage directory. + mount_point: + type: ["string", "null"] + description: Mount point for the filesystem containing the storage directory. + filesystem: + type: ["string", "null"] + description: Filesystem name reported by the operating system. + total_bytes: + type: ["integer", "null"] + format: int64 + description: Total bytes on the storage filesystem. + used_bytes: + type: ["integer", "null"] + format: int64 + description: Used bytes on the storage filesystem. + available_bytes: + type: ["integer", "null"] + format: int64 + description: Available bytes on the storage filesystem. + used_percent: + type: ["number", "null"] + format: double + description: Used percentage on the storage filesystem. + fabro_managed_bytes: + type: integer + format: int64 + description: Bytes managed by Fabro under the storage root. + fabro_reclaimable_bytes: + type: integer + format: int64 + description: Bytes reclaimable through Fabro pruning. + + SystemDiskResourceScope: + description: Scope for disk resource metrics. + type: string + enum: [storage_filesystem] + SystemRepairRunsResponse: description: Runs that need manual repair or deletion because they cannot be loaded. type: object diff --git a/docs/public/images/web/settings-resources.jpg b/docs/public/images/web/settings-resources.jpg new file mode 100644 index 000000000..43b213a2e Binary files /dev/null and b/docs/public/images/web/settings-resources.jpg differ diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 304ae45ec..1ade0e182 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -93,6 +93,7 @@ percent-encoding.workspace = true url = "2" zeroize.workspace = true strum.workspace = true +sysinfo = { version = "0.35.2", default-features = false, features = ["system", "disk"] } [build-dependencies] fabro-build-support = { path = "../build-support" } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 213df8cd1..aab074d94 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -882,6 +882,52 @@ pub(crate) async fn get_system_info( .into_response() } +pub(crate) async fn get_system_resources( + _auth: RequiredUser, + State(_state): State>, +) -> Response { + ( + StatusCode::OK, + Json(json!({ + "sampled_at": "2026-05-20T15:42:10Z", + "cpu": { + "supported": true, + "scope": "server_environment", + "unavailable_reason": null, + "logical_cpus": 8, + "usage_percent": 18.4, + "sample_window_ms": 5000 + }, + "memory": { + "supported": true, + "scope": "host", + "unavailable_reason": null, + "total_bytes": 17179869184_i64, + "used_bytes": 6442450944_i64, + "available_bytes": 10737418240_i64, + "used_percent": 37.5, + "host_total_bytes": 17179869184_i64 + }, + "disk": { + "supported": true, + "scope": "storage_filesystem", + "unavailable_reason": null, + "storage_path": "/demo/fabro/storage", + "mount_point": "/", + "filesystem": "demo-fs", + "total_bytes": 536870912000_i64, + "used_bytes": 214748364800_i64, + "available_bytes": 322122547200_i64, + "used_percent": 40.0, + "fabro_managed_bytes": 1280, + "fabro_reclaimable_bytes": 1280 + }, + "notes": [] + })), + ) + .into_response() +} + pub(crate) async fn get_system_disk_usage( _auth: RequiredUser, State(_state): State>, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 4688d2bef..9da0576dd 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -36,9 +36,11 @@ pub use fabro_api::types::{ RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState, - StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, - SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, - WriteBlobResponse, + StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources, + SystemDiskResourceScope, SystemDiskResources, SystemFeatures, SystemInfoResponse, + SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue, + SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, TimelineEntryResponse, + VncPreviewResponse, WriteBlobResponse, }; use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message}; #[cfg(test)] @@ -142,6 +144,7 @@ use crate::{ }; mod handler; +mod resource_sampler; mod session_runtime; pub(crate) use handler::events::EventListParams; @@ -519,6 +522,7 @@ pub struct AppState { artifact_store: ArtifactStore, worker_tokens: WorkerTokenKeys, started_at: Instant, + resource_sampler: resource_sampler::ResourceSampler, max_concurrent_runs: usize, scheduler_notify: Notify, global_event_tx: broadcast::Sender, @@ -1646,6 +1650,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Router> { .route("/health/diagnostics", post(demo::run_diagnostics)) .route("/settings", get(demo::get_server_settings)) .route("/system/info", get(demo::get_system_info)) + .route("/system/resources", get(demo::get_system_resources)) .route("/system/df", get(demo::get_system_disk_usage)) .route("/system/repair/runs", get(demo::get_system_repair_runs)) .route("/system/prune/runs", post(demo::prune_runs)) diff --git a/lib/crates/fabro-server/src/server/handler/system.rs b/lib/crates/fabro-server/src/server/handler/system.rs index c7f18cd4a..fcc279119 100644 --- a/lib/crates/fabro-server/src/server/handler/system.rs +++ b/lib/crates/fabro-server/src/server/handler/system.rs @@ -6,8 +6,8 @@ use super::super::{ PruneRunsRequest, PruneRunsResponse, Query, RequiredUser, Response, Router, RunStatus, State, StatusCode, SystemInfoResponse, SystemRepairRunIssue, SystemRepairRunsResponse, SystemRunCounts, build_disk_usage_response, build_prune_plan, delete_run_internal, diagnostics, - get, post, resolve_interp_string, spawn_blocking, system_features, system_sandbox_provider, - to_i64, + get, post, resolve_interp_string, resource_sampler, spawn_blocking, system_features, + system_sandbox_provider, to_i64, }; pub(super) fn routes() -> Router> { @@ -17,6 +17,7 @@ pub(super) fn routes() -> Router> { .route("/health/diagnostics", post(run_diagnostics)) .route("/settings", get(get_server_settings)) .route("/system/info", get(get_system_info)) + .route("/system/resources", get(get_system_resources)) .route("/system/df", get(get_system_df)) .route("/system/repair/runs", get(get_system_repair_runs)) .route("/system/prune/runs", post(prune_runs)) @@ -83,6 +84,15 @@ async fn get_system_info(_auth: RequiredUser, State(state): State> (StatusCode::OK, Json(response)).into_response() } +async fn get_system_resources(_auth: RequiredUser, State(state): State>) -> Response { + match resource_sampler::sample_system_resources(&state).await { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + } +} + async fn get_system_df( _auth: RequiredUser, State(state): State>, diff --git a/lib/crates/fabro-server/src/server/resource_sampler.rs b/lib/crates/fabro-server/src/server/resource_sampler.rs new file mode 100644 index 000000000..bc083c2cb --- /dev/null +++ b/lib/crates/fabro-server/src/server/resource_sampler.rs @@ -0,0 +1,423 @@ +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use sysinfo::{Disks, System}; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::spawn_blocking; + +use super::{ + AppState, SystemCpuResourceScope, SystemCpuResources, SystemDiskResourceScope, + SystemDiskResources, SystemMemoryResourceScope, SystemMemoryResources, SystemResourcesResponse, + build_disk_usage_response, to_i64, +}; + +const FABRO_STORAGE_USAGE_CACHE_TTL: Duration = Duration::from_mins(1); + +pub(in crate::server) struct ResourceSampler { + system: Mutex, + fabro_storage_usage: AsyncMutex>, +} + +struct SystemSamplerState { + system: System, + last_cpu_sample_at: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CachedFabroStorageUsage { + sampled_at: Instant, + usage: FabroStorageUsage, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FabroStorageUsage { + managed_bytes: i64, + reclaimable_bytes: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CgroupMemory { + total_bytes: u64, + available_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct MemorySelection { + scope: SystemMemoryResourceScope, + total_bytes: u64, + used_bytes: u64, + available_bytes: u64, + host_total_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DiskCandidate { + mount_point: PathBuf, + filesystem: String, + total_bytes: u64, + available_bytes: u64, +} + +impl ResourceSampler { + pub(in crate::server) fn new() -> Self { + Self { + system: Mutex::new(SystemSamplerState { + system: System::new(), + last_cpu_sample_at: None, + }), + fabro_storage_usage: AsyncMutex::new(None), + } + } + + fn sample_cpu_and_memory(&self) -> (SystemCpuResources, SystemMemoryResources) { + let mut state = self.system.lock().expect("resource sampler lock poisoned"); + let sampled_at = Instant::now(); + let sample_window_ms = state + .last_cpu_sample_at + .map(|last_sampled_at| to_i64(sampled_at.duration_since(last_sampled_at).as_millis())); + + state.system.refresh_cpu_usage(); + state.system.refresh_memory(); + + let logical_cpus = logical_cpu_count(&state.system); + let usage_percent = sample_window_ms + .is_some() + .then(|| round_one(f64::from(state.system.global_cpu_usage()))); + state.last_cpu_sample_at = Some(sampled_at); + + let cpu = if sysinfo::IS_SUPPORTED_SYSTEM { + SystemCpuResources { + supported: true, + scope: SystemCpuResourceScope::ServerEnvironment, + unavailable_reason: None, + logical_cpus: Some(to_i64(logical_cpus)), + usage_percent, + sample_window_ms, + } + } else { + SystemCpuResources { + supported: false, + scope: SystemCpuResourceScope::ServerEnvironment, + unavailable_reason: Some( + "system metrics are not supported on this platform".to_string(), + ), + logical_cpus: None, + usage_percent: None, + sample_window_ms: None, + } + }; + + let cgroup = state.system.cgroup_limits().map(|limits| CgroupMemory { + total_bytes: limits.total_memory, + available_bytes: limits.free_memory, + }); + let memory = memory_response(select_memory( + state.system.total_memory(), + state.system.used_memory(), + state.system.available_memory(), + cgroup, + )); + + (cpu, memory) + } + + async fn sample_fabro_storage_usage( + &self, + state: &AppState, + storage_path: &Path, + ) -> anyhow::Result { + let mut cached = self.fabro_storage_usage.lock().await; + if let Some(cached) = cached + .as_ref() + .filter(|cached| cached.sampled_at.elapsed() < FABRO_STORAGE_USAGE_CACHE_TTL) + { + return Ok(cached.usage); + } + + let summaries = state + .store + .list_runs(&fabro_store::ListRunsQuery::default()) + .await + .context("failed to list runs for resource sampling")?; + let storage_path = storage_path.to_path_buf(); + let usage = spawn_blocking(move || compute_fabro_storage_usage(&summaries, &storage_path)) + .await + .context("resource storage usage task failed")??; + + *cached = Some(CachedFabroStorageUsage { + sampled_at: Instant::now(), + usage, + }); + Ok(usage) + } +} + +pub(in crate::server) async fn sample_system_resources( + state: &AppState, +) -> anyhow::Result { + let sampled_at = chrono::Utc::now(); + let (cpu, memory) = state.resource_sampler.sample_cpu_and_memory(); + let storage_path = state.server_storage_dir(); + let fabro_usage = state + .resource_sampler + .sample_fabro_storage_usage(state, &storage_path) + .await + .context("failed to sample Fabro-managed storage usage")?; + + let disk = sample_disk_resources(&storage_path, fabro_usage); + + Ok(SystemResourcesResponse { + sampled_at, + cpu, + memory, + disk, + notes: Vec::new(), + }) +} + +fn logical_cpu_count(system: &System) -> usize { + let sysinfo_count = system.cpus().len(); + if sysinfo_count > 0 { + return sysinfo_count; + } + std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get) +} + +fn memory_response(selection: Option) -> SystemMemoryResources { + let Some(selection) = selection else { + return SystemMemoryResources { + supported: false, + scope: SystemMemoryResourceScope::Host, + unavailable_reason: Some("memory metrics reported zero total bytes".to_string()), + total_bytes: None, + used_bytes: None, + available_bytes: None, + used_percent: None, + host_total_bytes: None, + }; + }; + + SystemMemoryResources { + supported: true, + scope: selection.scope, + unavailable_reason: None, + total_bytes: Some(to_i64(selection.total_bytes)), + used_bytes: Some(to_i64(selection.used_bytes)), + available_bytes: Some(to_i64(selection.available_bytes)), + used_percent: percent(selection.used_bytes, selection.total_bytes), + host_total_bytes: Some(to_i64(selection.host_total_bytes)), + } +} + +fn select_memory( + host_total_bytes: u64, + host_used_bytes: u64, + host_available_bytes: u64, + cgroup: Option, +) -> Option { + if let Some(cgroup) = cgroup.filter(|cgroup| cgroup.total_bytes > 0) { + let available_bytes = cgroup.available_bytes.min(cgroup.total_bytes); + let used_bytes = cgroup.total_bytes.saturating_sub(available_bytes); + return Some(MemorySelection { + scope: SystemMemoryResourceScope::Cgroup, + total_bytes: cgroup.total_bytes, + used_bytes, + available_bytes, + host_total_bytes, + }); + } + + if host_total_bytes == 0 { + return None; + } + + Some(MemorySelection { + scope: SystemMemoryResourceScope::Host, + total_bytes: host_total_bytes, + used_bytes: host_used_bytes.min(host_total_bytes), + available_bytes: host_available_bytes.min(host_total_bytes), + host_total_bytes, + }) +} + +fn compute_fabro_storage_usage( + summaries: &[fabro_types::Run], + storage_path: &Path, +) -> anyhow::Result { + let usage = build_disk_usage_response(summaries, storage_path, false)?; + Ok(FabroStorageUsage { + managed_bytes: usage.total_size_bytes.unwrap_or_default(), + reclaimable_bytes: usage.total_reclaimable_bytes.unwrap_or_default(), + }) +} + +fn sample_disk_resources( + storage_path: &Path, + fabro_usage: FabroStorageUsage, +) -> SystemDiskResources { + let disks = Disks::new_with_refreshed_list(); + let candidates = disks + .list() + .iter() + .map(|disk| DiskCandidate { + mount_point: disk.mount_point().to_path_buf(), + filesystem: disk.file_system().to_string_lossy().to_string(), + total_bytes: disk.total_space(), + available_bytes: disk.available_space(), + }) + .collect::>(); + + let Some(disk) = select_storage_disk(storage_path, &candidates) else { + return SystemDiskResources { + supported: false, + scope: SystemDiskResourceScope::StorageFilesystem, + unavailable_reason: Some(format!( + "no filesystem mount matched storage path {}", + storage_path.display() + )), + storage_path: storage_path.display().to_string(), + mount_point: None, + filesystem: None, + total_bytes: None, + used_bytes: None, + available_bytes: None, + used_percent: None, + fabro_managed_bytes: fabro_usage.managed_bytes, + fabro_reclaimable_bytes: fabro_usage.reclaimable_bytes, + }; + }; + + if disk.total_bytes == 0 { + return SystemDiskResources { + supported: false, + scope: SystemDiskResourceScope::StorageFilesystem, + unavailable_reason: Some(format!( + "filesystem {} reported zero total bytes", + disk.mount_point.display() + )), + storage_path: storage_path.display().to_string(), + mount_point: Some(disk.mount_point.display().to_string()), + filesystem: Some(disk.filesystem.clone()), + total_bytes: None, + used_bytes: None, + available_bytes: None, + used_percent: None, + fabro_managed_bytes: fabro_usage.managed_bytes, + fabro_reclaimable_bytes: fabro_usage.reclaimable_bytes, + }; + } + + let available_bytes = disk.available_bytes.min(disk.total_bytes); + let used_bytes = disk.total_bytes.saturating_sub(available_bytes); + + SystemDiskResources { + supported: true, + scope: SystemDiskResourceScope::StorageFilesystem, + unavailable_reason: None, + storage_path: storage_path.display().to_string(), + mount_point: Some(disk.mount_point.display().to_string()), + filesystem: Some(disk.filesystem.clone()), + total_bytes: Some(to_i64(disk.total_bytes)), + used_bytes: Some(to_i64(used_bytes)), + available_bytes: Some(to_i64(available_bytes)), + used_percent: percent(used_bytes, disk.total_bytes), + fabro_managed_bytes: fabro_usage.managed_bytes, + fabro_reclaimable_bytes: fabro_usage.reclaimable_bytes, + } +} + +fn select_storage_disk<'a>( + storage_path: &Path, + disks: &'a [DiskCandidate], +) -> Option<&'a DiskCandidate> { + disks + .iter() + .filter(|disk| storage_path.starts_with(&disk.mount_point)) + .max_by_key(|disk| disk.mount_point.components().count()) +} + +fn percent(used: u64, total: u64) -> Option { + if total == 0 { + return None; + } + Some(round_one((used as f64 / total as f64) * 100.0)) +} + +fn round_one(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{ + CgroupMemory, DiskCandidate, SystemMemoryResourceScope, percent, select_memory, + select_storage_disk, + }; + + #[test] + fn percent_returns_one_decimal_percentage() { + assert_eq!(percent(1, 3), Some(33.3)); + assert_eq!(percent(0, 10), Some(0.0)); + assert_eq!(percent(1, 0), None); + } + + #[test] + fn select_memory_uses_host_values_without_cgroup_limits() { + let selection = + select_memory(1_000, 400, 600, None).expect("host memory should be selected"); + + assert_eq!(selection.scope, SystemMemoryResourceScope::Host); + assert_eq!(selection.total_bytes, 1_000); + assert_eq!(selection.used_bytes, 400); + assert_eq!(selection.available_bytes, 600); + assert_eq!(selection.host_total_bytes, 1_000); + } + + #[test] + fn select_memory_prefers_cgroup_limits_when_available() { + let selection = select_memory( + 1_000, + 200, + 800, + Some(CgroupMemory { + total_bytes: 500, + available_bytes: 125, + }), + ) + .expect("cgroup memory should be selected"); + + assert_eq!(selection.scope, SystemMemoryResourceScope::Cgroup); + assert_eq!(selection.total_bytes, 500); + assert_eq!(selection.used_bytes, 375); + assert_eq!(selection.available_bytes, 125); + assert_eq!(selection.host_total_bytes, 1_000); + } + + #[test] + fn select_storage_disk_uses_longest_mount_point_prefix() { + let disks = vec![ + disk("/"), + disk("/var"), + disk("/var/lib"), + disk("/var/lib-other"), + ]; + + let selected = select_storage_disk(Path::new("/var/lib/fabro/runs"), &disks) + .expect("storage disk should match"); + + assert_eq!(selected.mount_point, Path::new("/var/lib")); + } + + fn disk(mount_point: &str) -> DiskCandidate { + DiskCandidate { + mount_point: Path::new(mount_point).to_path_buf(), + filesystem: "testfs".to_string(), + total_bytes: 1_000, + available_bytes: 500, + } + } +} diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index 5f11d5f20..923959db6 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -144,6 +144,97 @@ async fn get_system_info_returns_runtime_fields() { ); } +#[tokio::test] +async fn get_system_resources_returns_server_visible_metrics() { + let (_temp, settings, expected_storage_dir) = temp_storage_settings(); + let app = + fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5)); + + let request = Request::builder() + .method("GET") + .uri(api("/system/resources")) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + + let body = response_json(response, StatusCode::OK, "GET /api/v1/system/resources").await; + let sampled_at = body["sampled_at"] + .as_str() + .expect("sampled_at should be an RFC3339 timestamp"); + chrono::DateTime::parse_from_rfc3339(sampled_at).expect("sampled_at should parse as RFC3339"); + + assert_eq!(body["cpu"]["supported"], true); + assert_eq!(body["cpu"]["scope"], "server_environment"); + assert!( + body["cpu"]["sample_window_ms"].is_null() + || body["cpu"]["sample_window_ms"] + .as_i64() + .is_some_and(|value| value >= 0), + "sample window should be null until a delta sample is available or nonnegative: {body}" + ); + assert!( + body["cpu"]["logical_cpus"].as_i64().unwrap_or_default() > 0, + "logical CPU count should be positive" + ); + assert_percent_if_present(&body["cpu"]["usage_percent"]); + + assert_eq!(body["memory"]["supported"], true); + assert!(matches!( + body["memory"]["scope"].as_str(), + Some("host" | "cgroup") + )); + assert_nonnegative_i64(&body["memory"]["total_bytes"], "memory.total_bytes"); + assert_nonnegative_i64(&body["memory"]["used_bytes"], "memory.used_bytes"); + assert_nonnegative_i64(&body["memory"]["available_bytes"], "memory.available_bytes"); + assert_percent_if_present(&body["memory"]["used_percent"]); + + assert_eq!(body["disk"]["supported"], true); + assert_eq!(body["disk"]["scope"], "storage_filesystem"); + assert_eq!( + body["disk"]["storage_path"], + expected_storage_dir.display().to_string() + ); + assert!(body["disk"]["mount_point"].as_str().is_some()); + assert_nonnegative_i64(&body["disk"]["total_bytes"], "disk.total_bytes"); + assert_nonnegative_i64(&body["disk"]["used_bytes"], "disk.used_bytes"); + assert_nonnegative_i64(&body["disk"]["available_bytes"], "disk.available_bytes"); + assert_percent_if_present(&body["disk"]["used_percent"]); + assert_nonnegative_i64( + &body["disk"]["fabro_managed_bytes"], + "disk.fabro_managed_bytes", + ); + assert_nonnegative_i64( + &body["disk"]["fabro_reclaimable_bytes"], + "disk.fabro_reclaimable_bytes", + ); + assert!( + body["notes"] + .as_array() + .is_some_and(std::vec::Vec::is_empty), + "initial test app resources should not need notes: {body}" + ); +} + +fn assert_nonnegative_i64(value: &serde_json::Value, name: &str) { + assert!( + value.as_i64().is_some_and(|value| value >= 0), + "{name} should be a nonnegative integer: {value:?}" + ); +} + +fn assert_percent_if_present(value: &serde_json::Value) { + if value.is_null() { + return; + } + let percent = value + .as_f64() + .expect("percent should be numeric when present"); + assert!( + (0.0..=100.0).contains(&percent), + "percent should be between 0 and 100: {percent}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_app_state_with_options_respects_max_concurrent_runs() { let app = test_app_with_scheduler(test_app_state_with_options(test_settings(), 1)); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 46ad2dc83..bfc203488 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -378,10 +378,17 @@ models/submit-answer-yes-request.ts models/submit-turn-request.ts models/success-reason.ts models/system-actor-kind.ts +models/system-cpu-resource-scope.ts +models/system-cpu-resources.ts +models/system-disk-resource-scope.ts +models/system-disk-resources.ts models/system-features.ts models/system-info-response.ts +models/system-memory-resource-scope.ts +models/system-memory-resources.ts models/system-repair-run-issue.ts models/system-repair-runs-response.ts +models/system-resources-response.ts models/system-run-counts.ts models/timeline-entry-response.ts models/tls-mode.ts diff --git a/lib/packages/fabro-api-client/src/api/system-api.ts b/lib/packages/fabro-api-client/src/api/system-api.ts index a18e7b642..27856ebaa 100644 --- a/lib/packages/fabro-api-client/src/api/system-api.ts +++ b/lib/packages/fabro-api-client/src/api/system-api.ts @@ -33,6 +33,8 @@ import type { PruneRunsResponse } from '../models'; import type { SystemInfoResponse } from '../models'; // @ts-ignore import type { SystemRepairRunsResponse } from '../models'; +// @ts-ignore +import type { SystemResourcesResponse } from '../models'; /** * SystemApi - axios parameter creator */ @@ -192,6 +194,42 @@ export const SystemApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * Returns server-visible CPU, memory, and storage filesystem resource usage. + * @summary Retrieve System Resources + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSystemResources: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/system/resources`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Deletes completed runs matching the provided filters, or previews the deletion set when dry-run is enabled. * @summary Prune Runs @@ -292,6 +330,18 @@ export const SystemApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['SystemApi.getSystemRepairRuns']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Returns server-visible CPU, memory, and storage filesystem resource usage. + * @summary Retrieve System Resources + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSystemResources(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSystemResources(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SystemApi.getSystemResources']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Deletes completed runs matching the provided filters, or previews the deletion set when dry-run is enabled. * @summary Prune Runs @@ -352,6 +402,15 @@ export const SystemApiFactory = function (configuration?: Configuration, basePat getSystemRepairRuns(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getSystemRepairRuns(options).then((request) => request(axios, basePath)); }, + /** + * Returns server-visible CPU, memory, and storage filesystem resource usage. + * @summary Retrieve System Resources + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSystemResources(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getSystemResources(options).then((request) => request(axios, basePath)); + }, /** * Deletes completed runs matching the provided filters, or previews the deletion set when dry-run is enabled. * @summary Prune Runs @@ -411,6 +470,16 @@ export class SystemApi extends BaseAPI { return SystemApiFp(this.configuration).getSystemRepairRuns(options).then((request) => request(this.axios, this.basePath)); } + /** + * Returns server-visible CPU, memory, and storage filesystem resource usage. + * @summary Retrieve System Resources + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getSystemResources(options?: RawAxiosRequestConfig) { + return SystemApiFp(this.configuration).getSystemResources(options).then((request) => request(this.axios, this.basePath)); + } + /** * Deletes completed runs matching the provided filters, or previews the deletion set when dry-run is enabled. * @summary Prune Runs diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 178d33d36..e258475fe 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -354,10 +354,17 @@ export * from './submit-answer-yes-request'; export * from './submit-turn-request'; export * from './success-reason'; export * from './system-actor-kind'; +export * from './system-cpu-resource-scope'; +export * from './system-cpu-resources'; +export * from './system-disk-resource-scope'; +export * from './system-disk-resources'; export * from './system-features'; export * from './system-info-response'; +export * from './system-memory-resource-scope'; +export * from './system-memory-resources'; export * from './system-repair-run-issue'; export * from './system-repair-runs-response'; +export * from './system-resources-response'; export * from './system-run-counts'; export * from './timeline-entry-response'; export * from './tls-mode'; diff --git a/lib/packages/fabro-api-client/src/models/system-cpu-resource-scope.ts b/lib/packages/fabro-api-client/src/models/system-cpu-resource-scope.ts new file mode 100644 index 000000000..e4637a6e7 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-cpu-resource-scope.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Scope for CPU resource metrics. + */ + +export const SystemCpuResourceScope = { + SERVER_ENVIRONMENT: 'server_environment' +} as const; + +export type SystemCpuResourceScope = typeof SystemCpuResourceScope[keyof typeof SystemCpuResourceScope]; diff --git a/lib/packages/fabro-api-client/src/models/system-cpu-resources.ts b/lib/packages/fabro-api-client/src/models/system-cpu-resources.ts new file mode 100644 index 000000000..216912783 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-cpu-resources.ts @@ -0,0 +1,46 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SystemCpuResourceScope } from './system-cpu-resource-scope'; + +/** + * CPU resources visible to the Fabro server process. + */ +export interface SystemCpuResources { + /** + * Whether CPU metrics are available on this platform. + */ + 'supported': boolean; + 'scope': SystemCpuResourceScope; + /** + * Reason metrics are unavailable when unsupported. + */ + 'unavailable_reason': string | null; + /** + * Logical CPUs visible to the server process. + */ + 'logical_cpus': number | null; + /** + * CPU usage percentage, null until a delta sample is available. + */ + 'usage_percent': number | null; + /** + * Elapsed milliseconds since the previous CPU sample, null until a delta sample is available. + */ + 'sample_window_ms': number | null; +} + diff --git a/lib/packages/fabro-api-client/src/models/system-disk-resource-scope.ts b/lib/packages/fabro-api-client/src/models/system-disk-resource-scope.ts new file mode 100644 index 000000000..79daf2b31 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-disk-resource-scope.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Scope for disk resource metrics. + */ + +export const SystemDiskResourceScope = { + STORAGE_FILESYSTEM: 'storage_filesystem' +} as const; + +export type SystemDiskResourceScope = typeof SystemDiskResourceScope[keyof typeof SystemDiskResourceScope]; diff --git a/lib/packages/fabro-api-client/src/models/system-disk-resources.ts b/lib/packages/fabro-api-client/src/models/system-disk-resources.ts new file mode 100644 index 000000000..5ff66fb39 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-disk-resources.ts @@ -0,0 +1,70 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SystemDiskResourceScope } from './system-disk-resource-scope'; + +/** + * Filesystem resources for the configured Fabro storage root. + */ +export interface SystemDiskResources { + /** + * Whether storage filesystem metrics are available. + */ + 'supported': boolean; + 'scope': SystemDiskResourceScope; + /** + * Reason metrics are unavailable when unsupported. + */ + 'unavailable_reason': string | null; + /** + * Configured Fabro storage directory. + */ + 'storage_path': string; + /** + * Mount point for the filesystem containing the storage directory. + */ + 'mount_point': string | null; + /** + * Filesystem name reported by the operating system. + */ + 'filesystem': string | null; + /** + * Total bytes on the storage filesystem. + */ + 'total_bytes': number | null; + /** + * Used bytes on the storage filesystem. + */ + 'used_bytes': number | null; + /** + * Available bytes on the storage filesystem. + */ + 'available_bytes': number | null; + /** + * Used percentage on the storage filesystem. + */ + 'used_percent': number | null; + /** + * Bytes managed by Fabro under the storage root. + */ + 'fabro_managed_bytes': number; + /** + * Bytes reclaimable through Fabro pruning. + */ + 'fabro_reclaimable_bytes': number; +} + diff --git a/lib/packages/fabro-api-client/src/models/system-memory-resource-scope.ts b/lib/packages/fabro-api-client/src/models/system-memory-resource-scope.ts new file mode 100644 index 000000000..31340786e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-memory-resource-scope.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * `cgroup` when Linux cgroup limits are available, otherwise `host`. + */ + +export const SystemMemoryResourceScope = { + HOST: 'host', + CGROUP: 'cgroup' +} as const; + +export type SystemMemoryResourceScope = typeof SystemMemoryResourceScope[keyof typeof SystemMemoryResourceScope]; diff --git a/lib/packages/fabro-api-client/src/models/system-memory-resources.ts b/lib/packages/fabro-api-client/src/models/system-memory-resources.ts new file mode 100644 index 000000000..3f0d4a8bd --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-memory-resources.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SystemMemoryResourceScope } from './system-memory-resource-scope'; + +/** + * Memory resources visible to the Fabro server process. + */ +export interface SystemMemoryResources { + /** + * Whether memory metrics are available on this platform. + */ + 'supported': boolean; + 'scope': SystemMemoryResourceScope; + /** + * Reason metrics are unavailable when unsupported. + */ + 'unavailable_reason': string | null; + /** + * Total memory for the reported scope. + */ + 'total_bytes': number | null; + /** + * Used memory for the reported scope. + */ + 'used_bytes': number | null; + /** + * Available memory for the reported scope. + */ + 'available_bytes': number | null; + /** + * Used memory percentage for the reported scope. + */ + 'used_percent': number | null; + /** + * Host total memory, included even when reporting a cgroup scope. + */ + 'host_total_bytes': number | null; +} + diff --git a/lib/packages/fabro-api-client/src/models/system-resources-response.ts b/lib/packages/fabro-api-client/src/models/system-resources-response.ts new file mode 100644 index 000000000..c2fb6fbdc --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-resources-response.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SystemCpuResources } from './system-cpu-resources'; +// May contain unused imports in some cases +// @ts-ignore +import type { SystemDiskResources } from './system-disk-resources'; +// May contain unused imports in some cases +// @ts-ignore +import type { SystemMemoryResources } from './system-memory-resources'; + +/** + * Server-visible runtime resource usage for the active Fabro process environment. + */ +export interface SystemResourcesResponse { + /** + * Timestamp when the sample was collected. + */ + 'sampled_at': string; + 'cpu': SystemCpuResources; + 'memory': SystemMemoryResources; + 'disk': SystemDiskResources; + /** + * Human-readable caveats about unavailable or scoped metrics. + */ + 'notes': Array; +} +