diff --git a/create-a-container/README.md b/create-a-container/README.md index c20ae675..9cc0e05c 100644 --- a/create-a-container/README.md +++ b/create-a-container/README.md @@ -24,9 +24,10 @@ make dev That's all you need. `make dev` installs dependencies, runs database migrations and dev seeders, builds the client, and starts the server, the job-runner, and -the client build watcher together. It uses SQLite and a dummy (mock) hypervisor, -so **no `.env`, PostgreSQL, or Proxmox cluster is required** — the Manager comes -up at and can "create" containers locally (simulated). +the client build watcher together. It uses SQLite and a +dummy (mock) hypervisor, so **no `.env`, PostgreSQL, or Proxmox cluster is +required** — the Manager comes up at and can "create" +containers locally (simulated). Pass `LOG_LEVEL=trace` to additionally log every SQL query: @@ -46,13 +47,16 @@ The Manager is not installed by hand in production. It ships as: - distribution **packages** built from this directory with `make deb`, `make rpm`, or `make apk` (via [fpm](https://fpm.readthedocs.io/)), which install the app under `/opt/opensource-server/create-a-container` and register the - `container-creator` and `job-runner` systemd services. The package depends + `container-creator` and `job-runner` systemd services. + The package depends on `opensource-mcp` — the [MCP server](../manager-control-program/) as its own package — and the Manager reverse-proxies `/mcp` to its service (`MCP_SERVER_URL`). In both cases the app runs `server.js` (HTTP API + UI) and `job-runner.js` -(background worker). Database connection settings come from the environment (see +(background worker). Per-owner resource usage is computed live in the UI at +`/sites/:siteId/usage` (API: `GET /api/v1/sites/:siteId/usage`). +Database connection settings come from the environment (see [Configuration](#configuration)); the manager image provisions PostgreSQL and writes these to `/etc/default/container-creator` on first boot. diff --git a/create-a-container/client/src/app/Sidebar.tsx b/create-a-container/client/src/app/Sidebar.tsx index 33fe8dce..bf6e4b17 100644 --- a/create-a-container/client/src/app/Sidebar.tsx +++ b/create-a-container/client/src/app/Sidebar.tsx @@ -13,6 +13,7 @@ import { useSidebar, } from '@mieweb/ui'; import { + Activity, Box, Building2, ClipboardList, @@ -166,6 +167,12 @@ export function AppSidebar() { icon: , match: `/sites/${currentSiteId}/containers`, })} + {isAdmin && renderLink({ + to: `/sites/${currentSiteId}/usage`, + label: 'Usage', + icon: , + match: `/sites/${currentSiteId}/usage`, + })} {isAdmin && renderLink({ to: `/sites/${currentSiteId}/nodes`, label: 'Nodes', @@ -185,6 +192,12 @@ export function AppSidebar() { icon: , match: `/sites/${currentSiteId}/containers`, })} + {isAdmin && renderLink({ + to: `/sites/${currentSiteId}/usage`, + label: 'Usage', + icon: , + match: `/sites/${currentSiteId}/usage`, + })} {isAdmin && renderLink({ to: `/sites/${currentSiteId}/nodes`, label: 'Nodes', diff --git a/create-a-container/client/src/app/router.tsx b/create-a-container/client/src/app/router.tsx index 50e2d7cc..a0450d92 100644 --- a/create-a-container/client/src/app/router.tsx +++ b/create-a-container/client/src/app/router.tsx @@ -14,6 +14,7 @@ import { ContainerFormPage } from '@/pages/containers/ContainerFormPage'; import { NodesListPage } from '@/pages/nodes/NodesListPage'; import { NodeFormPage } from '@/pages/nodes/NodeFormPage'; import { NodeImportPage } from '@/pages/nodes/NodeImportPage'; +import { UsagePage } from '@/pages/usage/UsagePage'; import { ExternalDomainsListPage } from '@/pages/external-domains/ExternalDomainsListPage'; import { ExternalDomainFormPage } from '@/pages/external-domains/ExternalDomainFormPage'; import { AgentsListPage } from '@/pages/agents/AgentsListPage'; @@ -62,6 +63,8 @@ export const router = createBrowserRouter([ { path: '/sites/:siteId/nodes/import', element: }, { path: '/sites/:siteId/nodes/:id/edit', element: }, + { path: '/sites/:siteId/usage', element: }, + { path: '/external-domains', element: }, { path: '/external-domains/new', element: }, { path: '/external-domains/:id/edit', element: }, diff --git a/create-a-container/client/src/components/nodes/ResourceBar.tsx b/create-a-container/client/src/components/nodes/ResourceBar.tsx index fa081612..a4c9d11b 100644 --- a/create-a-container/client/src/components/nodes/ResourceBar.tsx +++ b/create-a-container/client/src/components/nodes/ResourceBar.tsx @@ -1,18 +1,5 @@ import { Progress } from '@mieweb/ui'; - -const KiB = 1024; -const MiB = KiB * 1024; -const GiB = MiB * 1024; -const TiB = GiB * 1024; - -/** Human-readable byte size using binary units labelled with familiar suffixes. */ -function formatBytes(bytes: number): string { - if (bytes >= TiB) return `${(bytes / TiB).toFixed(1)} TB`; - if (bytes >= GiB) return `${(bytes / GiB).toFixed(1)} GB`; - if (bytes >= MiB) return `${Math.round(bytes / MiB)} MB`; - if (bytes >= KiB) return `${Math.round(bytes / KiB)} KB`; - return `${bytes} B`; -} +import { formatBytes } from '@/lib/format'; function variantFor(pct: number): 'success' | 'warning' | 'danger' { if (pct >= 90) return 'danger'; diff --git a/create-a-container/client/src/components/usage/AttributionWarnings.tsx b/create-a-container/client/src/components/usage/AttributionWarnings.tsx new file mode 100644 index 00000000..93c94beb --- /dev/null +++ b/create-a-container/client/src/components/usage/AttributionWarnings.tsx @@ -0,0 +1,50 @@ +import { Alert, AlertDescription } from '@mieweb/ui'; +import type { UsageFinding } from '@/lib/types'; + +export interface AttributionWarningsProps { + findings: UsageFinding[]; + /** Cluster members seen in Proxmox but not registered in the manager DB. */ + unknownNodeRows?: number; +} + +/** + * Admin-only warning banner for owner-attribution problems: drift between the + * Proxmox owner tag and the manager DB, containers with no owner at all, and + * cluster nodes the manager does not know about. + */ +export function AttributionWarnings({ findings, unknownNodeRows = 0 }: AttributionWarningsProps) { + if (findings.length === 0 && unknownNodeRows === 0) return null; + + const drift = findings.filter((f) => f.kind === 'drift'); + const unattributed = findings.filter((f) => f.kind === 'unattributed'); + + return ( + + +
+ {drift.length > 0 && ( + + Attribution drift on {drift.length} container{drift.length === 1 ? '' : 's'}:{' '} + {drift + .map((f) => `CT ${f.vmid} (tag '${f.tagOwner}' ≠ DB '${f.dbOwner}')`) + .join(', ')} + + )} + {unattributed.length > 0 && ( + + {unattributed.length} container{unattributed.length === 1 ? '' : 's'} with no owner + (no Proxmox tag, not in the manager DB):{' '} + {unattributed.map((f) => `CT ${f.vmid}`).join(', ')} + + )} + {unknownNodeRows > 0 && ( + + {unknownNodeRows} container{unknownNodeRows === 1 ? '' : 's'} on cluster nodes not + registered in the manager. + + )} +
+
+
+ ); +} diff --git a/create-a-container/client/src/components/usage/OwnerContainersTable.tsx b/create-a-container/client/src/components/usage/OwnerContainersTable.tsx new file mode 100644 index 00000000..2d0975e0 --- /dev/null +++ b/create-a-container/client/src/components/usage/OwnerContainersTable.tsx @@ -0,0 +1,88 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@mieweb/ui'; +import type { UsageContainer } from '@/lib/types'; +import { formatBytes } from '@/lib/format'; +import { PressureBadge } from './PressureBadge'; + +/** "left / right" pair where either side may be missing, e.g. used/alloc or read/write. */ +function pair( + left: number | null, + right: number | null, + format: (v: number) => string, +): string { + const l = left != null ? format(left) : '—'; + const r = right != null ? format(right) : '—'; + return `${l} / ${r}`; +} + +const cores = (v: number) => v.toFixed(2); + +/** Worst of the six PSI readings for a container, or null when unprobed. */ +function worstPsi(c: UsageContainer): number | null { + const values = [c.psiCpuSome, c.psiCpuFull, c.psiMemSome, c.psiMemFull, c.psiIoSome, c.psiIoFull] + .filter((v): v is number => v != null); + return values.length > 0 ? Math.max(...values) : null; +} + +export interface OwnerContainersTableProps { + containers: UsageContainer[]; +} + +/** + * Per-container usage detail shown when an owner row in the usage grid is + * expanded. I/O and network figures are cumulative since container boot. + */ +export function OwnerContainersTable({ containers }: OwnerContainersTableProps) { + return ( +
+ + + + CT + Name + Node + Status + CPU (cores) + Memory + Disk + Disk I/O (r / w) + Network (in / out) + Pressure + + + + {containers.map((c) => ( + + {c.vmid} + {c.name || '—'} + {c.node} + {c.status || '—'} + {pair(c.cpuUsed, c.cpuAlloc, cores)} + {pair(c.memUsed, c.memAlloc, formatBytes)} + {pair(c.diskUsed, c.diskAlloc, formatBytes)} + {pair(c.diskReadBytes, c.diskWriteBytes, formatBytes)} + {pair(c.netInBytes, c.netOutBytes, formatBytes)} + + v.toFixed(1))} · Mem ${pair(c.psiMemSome, c.psiMemFull, (v) => v.toFixed(1))} · I/O ${pair(c.psiIoSome, c.psiIoFull, (v) => v.toFixed(1))} (some / full, avg10 %)` + } + > + + + + + ))} + +
+
+ ); +} diff --git a/create-a-container/client/src/components/usage/PressureBadge.tsx b/create-a-container/client/src/components/usage/PressureBadge.tsx new file mode 100644 index 00000000..2748a644 --- /dev/null +++ b/create-a-container/client/src/components/usage/PressureBadge.tsx @@ -0,0 +1,25 @@ +export interface PressureBadgeProps { + /** Worst PSI stall percentage (avg10), or null when not probed. */ + value: number | null; +} + +/** + * Colored PSI readout: the issue #440 evidence puts sustained full-stall + * above 40 firmly in "thrashing" territory; 10+ is worth watching. Null + * means the container was not probed this cycle (PSI probes are budget-capped), + * not that it is healthy. + */ +export function PressureBadge({ value }: PressureBadgeProps) { + if (value == null) return ; + const cls = + value >= 40 + ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' + : value >= 10 + ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' + : 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'; + return ( + + {value.toFixed(1)}% + + ); +} diff --git a/create-a-container/client/src/components/usage/StackedBar.tsx b/create-a-container/client/src/components/usage/StackedBar.tsx new file mode 100644 index 00000000..e41f5ed2 --- /dev/null +++ b/create-a-container/client/src/components/usage/StackedBar.tsx @@ -0,0 +1,60 @@ +export interface StackedBarSegment { + label: string; + value: number; + color: string; +} + +export interface StackedBarProps { + /** e.g. "Memory" */ + title: string; + segments: StackedBarSegment[]; + /** Physical capacity the bar is drawn against. */ + capacity: number; + format: (value: number) => string; +} + +/** + * Horizontal stacked bar: one colored segment per owner, drawn against + * cluster capacity so the empty remainder is visible headroom. When the + * segments exceed capacity (over-commit) the scale grows to fit and a + * capacity tick marks 100%. + */ +export function StackedBar({ title, segments, capacity, format }: StackedBarProps) { + const total = segments.reduce((sum, s) => sum + s.value, 0); + const scale = Math.max(capacity, total); + if (scale <= 0) return null; + const capacityPct = (capacity / scale) * 100; + + return ( +
+
+ {title} + + {format(total)} / {format(capacity)} + {capacity > 0 && ` (${Math.round((total / capacity) * 100)}%)`} + +
+
+ {segments.map((s) => ( +
+ ))} + {total > capacity && capacity > 0 && ( +
+ )} +
+
+ ); +} diff --git a/create-a-container/client/src/components/usage/UsageDataGrid.tsx b/create-a-container/client/src/components/usage/UsageDataGrid.tsx new file mode 100644 index 00000000..3f27698e --- /dev/null +++ b/create-a-container/client/src/components/usage/UsageDataGrid.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { DataVisNitroGrid, DataVisNitroSource } from '@mieweb/ui/datavis'; +import type { TableColumn, TableRendererProps } from '@mieweb/datavis'; +import type { UsageOwner } from '@/lib/types'; +import { formatBytes } from '@/lib/format'; +import { OwnerContainersTable } from './OwnerContainersTable'; +import { PressureBadge } from './PressureBadge'; + +// Numeric `*Used` fields drive sorting; formatCell renders "used / allocated". +const TYPE_INFO: { field: string; type: string }[] = [ + { field: 'owner', type: 'string' }, + { field: 'containerCount', type: 'number' }, + { field: 'cpuUsed', type: 'number' }, + { field: 'memUsed', type: 'number' }, + { field: 'diskUsed', type: 'number' }, + { field: 'diskReadBytes', type: 'number' }, + { field: 'netInBytes', type: 'number' }, + { field: 'pressureMax', type: 'number' }, +]; + +const asOwner = (row: Record) => row as unknown as UsageOwner; + +type FormatCell = NonNullable; +type DetailRow = NonNullable; + +export interface UsageDataGridProps { + owners: UsageOwner[]; +} + +/** + * Per-owner usage rows (allocated alongside used, always) rendered with + * DataVis NITRO. Each row expands to the owner's per-container detail. + */ +export function UsageDataGrid({ owners }: UsageDataGridProps) { + const url = useMemo(() => { + const payload = { typeInfo: TYPE_INFO, data: owners }; + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + return URL.createObjectURL(blob); + }, [owners]); + useEffect(() => () => URL.revokeObjectURL(url), [url]); + + const columns = useMemo( + () => [ + { + field: 'owner', + header: 'Owner', + sortable: true, + filterable: true, + getSearchText: (_v, row) => asOwner(row).owner ?? 'unattributed', + }, + { + field: 'containerCount', + header: 'Containers (running)', + sortable: true, + filterable: false, + getSearchText: () => '', + }, + { field: 'cpuUsed', header: 'CPU cores (used / alloc)', sortable: true, filterable: false, getSearchText: () => '' }, + { field: 'memUsed', header: 'Memory (used / alloc)', sortable: true, filterable: false, getSearchText: () => '' }, + { field: 'diskUsed', header: 'Disk (used / alloc)', sortable: true, filterable: false, getSearchText: () => '' }, + { field: 'diskReadBytes', header: 'Disk I/O (r / w)', sortable: true, filterable: false, getSearchText: () => '' }, + { field: 'netInBytes', header: 'Network (in / out)', sortable: true, filterable: false, getSearchText: () => '' }, + { field: 'pressureMax', header: 'Pressure', sortable: true, filterable: false, getSearchText: () => '' }, + ], + [], + ); + + const formatCell = useCallback((value, row, column) => { + const o = asOwner(row); + switch (column.field) { + case 'owner': + return o.owner ? ( + {o.owner} + ) : ( + unattributed + ); + case 'containerCount': + return `${o.containerCount} (${o.runningCount})`; + case 'cpuUsed': + return `${o.cpuUsed.toFixed(2)} / ${o.cpuAlloc}`; + case 'memUsed': + return `${formatBytes(o.memUsed)} / ${formatBytes(o.memAlloc)}`; + case 'diskUsed': + return `${formatBytes(o.diskUsed)} / ${formatBytes(o.diskAlloc)}`; + case 'diskReadBytes': + return `${formatBytes(o.diskReadBytes)} / ${formatBytes(o.diskWriteBytes)}`; + case 'netInBytes': + return `${formatBytes(o.netInBytes)} / ${formatBytes(o.netOutBytes)}`; + case 'pressureMax': + return ; + default: + return value as React.ReactNode; + } + }, []); + + const renderDetailRow = useCallback( + (row) => , + [], + ); + + return ( + + + + ); +} diff --git a/create-a-container/client/src/components/usage/UsageStackedBars.tsx b/create-a-container/client/src/components/usage/UsageStackedBars.tsx new file mode 100644 index 00000000..a8cf5dc9 --- /dev/null +++ b/create-a-container/client/src/components/usage/UsageStackedBars.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import type { UsageOwner, UsageReport } from '@/lib/types'; +import { formatBytes } from '@/lib/format'; +import { StackedBar, type StackedBarSegment } from './StackedBar'; + +/** + * Deterministic owner colors: distinct hues for the top consumers, gray for + * the aggregated remainder. + */ +const PALETTE = [ + '#2563eb', '#dc2626', '#16a34a', '#d97706', '#9333ea', + '#0891b2', '#db2777', '#65a30d', '#7c3aed', '#ea580c', +]; +const OTHERS_COLOR = '#9ca3af'; +const OTHERS_LABEL = 'others'; +const TOP_OWNERS = PALETTE.length; + +/** + * One color per owner across every bar (ranked by memory use — the metric + * people get yelled at for); owners beyond the palette share a gray + * "others" bucket. + */ +function buildColorMap(owners: UsageOwner[]): Map { + const ranked = [...owners].sort((a, b) => b.memUsed - a.memUsed); + const map = new Map(); + ranked.forEach((o, i) => { + if (i < TOP_OWNERS) map.set(o.owner ?? 'unattributed', PALETTE[i]); + }); + return map; +} + +function segmentsFor( + owners: UsageOwner[], + metric: (o: UsageOwner) => number, + colorMap: Map, +): StackedBarSegment[] { + const named: StackedBarSegment[] = []; + let othersValue = 0; + let othersCount = 0; + + for (const o of owners) { + const value = metric(o); + if (value <= 0) continue; + const label = o.owner ?? 'unattributed'; + const color = colorMap.get(label); + if (color) { + named.push({ label, value, color }); + } else { + othersValue += value; + othersCount++; + } + } + + named.sort((a, b) => b.value - a.value); + if (othersValue > 0) { + named.push({ label: `${othersCount} ${OTHERS_LABEL}`, value: othersValue, color: OTHERS_COLOR }); + } + return named; +} + +const formatCores = (v: number) => `${v.toFixed(1)} cores`; + +export interface UsageStackedBarsProps { + report: UsageReport; +} + +/** + * Cluster-wide used-vs-capacity stacked bars for CPU and memory, one colored + * segment per owner — the "who is using the cluster" view. A shared legend + * covers both bars. + */ +export function UsageStackedBars({ report }: UsageStackedBarsProps) { + const { owners, capacity } = report; + + const colorMap = useMemo(() => buildColorMap(owners), [owners]); + const cpuSegments = useMemo(() => segmentsFor(owners, (o) => o.cpuUsed, colorMap), [owners, colorMap]); + const memSegments = useMemo(() => segmentsFor(owners, (o) => o.memUsed, colorMap), [owners, colorMap]); + + const legend = useMemo(() => { + const seen = new Map(); + for (const s of [...memSegments, ...cpuSegments]) { + if (!seen.has(s.label)) seen.set(s.label, s.color); + } + return [...seen.entries()]; + }, [cpuSegments, memSegments]); + + if (capacity.cpuCores <= 0 && capacity.memBytes <= 0) return null; + + return ( +
+ + +
    + {legend.map(([label, color]) => ( +
  • + + {label} +
  • + ))} +
+
+ ); +} diff --git a/create-a-container/client/src/lib/format.ts b/create-a-container/client/src/lib/format.ts new file mode 100644 index 00000000..8ad32bc7 --- /dev/null +++ b/create-a-container/client/src/lib/format.ts @@ -0,0 +1,17 @@ +/** + * Shared display formatters for byte sizes and counts. + */ + +const KiB = 1024; +const MiB = KiB * 1024; +const GiB = MiB * 1024; +const TiB = GiB * 1024; + +/** Human-readable byte size using binary units labelled with familiar suffixes. */ +export function formatBytes(bytes: number): string { + if (bytes >= TiB) return `${(bytes / TiB).toFixed(1)} TB`; + if (bytes >= GiB) return `${(bytes / GiB).toFixed(1)} GB`; + if (bytes >= MiB) return `${Math.round(bytes / MiB)} MB`; + if (bytes >= KiB) return `${Math.round(bytes / KiB)} KB`; + return `${bytes} B`; +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index 54ec9821..40fec213 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -19,6 +19,7 @@ import type { AppSettings, ResourceRequest, Site, + UsageReport, User, } from './types'; @@ -30,6 +31,7 @@ export const keys = { ['sites', String(siteId), 'nodes', String(id)] as const, nodeStats: (siteId: number | string, id: number | string) => ['sites', String(siteId), 'nodes', String(id), 'stats'] as const, + usage: (siteId: number | string) => ['sites', String(siteId), 'usage'] as const, containers: (siteId: number | string, params?: Record) => ['sites', String(siteId), 'containers', params ?? {}] as const, container: (siteId: number | string, id: number | string) => @@ -67,6 +69,10 @@ export const queries = { getNodeStats: (siteId: number | string, id: number | string) => api.get(`/api/v1/sites/${siteId}/nodes/${id}/stats`), + // Usage + getUsage: (siteId: number | string) => + api.get(`/api/v1/sites/${siteId}/usage`), + // Agents listAgents: () => api.get('/api/v1/agents'), diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index d24f3b0b..4ea343fb 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -73,6 +73,78 @@ export interface AgentServiceStatus { lastApply: 'success' | 'failure' | 'unknown'; } +/** One container's live usage sample in the per-owner usage report. */ +export interface UsageContainer { + vmid: string; + name: string | null; + owner: string | null; + containerDbId: number | null; + node: string; + siteId: number; + status: string | null; + cpuUsed: number | null; + cpuAlloc: number | null; + memUsed: number | null; + memAlloc: number | null; + diskUsed: number | null; + diskAlloc: number | null; + diskReadBytes: number | null; + diskWriteBytes: number | null; + netInBytes: number | null; + netOutBytes: number | null; + /** Seconds since container boot. */ + uptime: number | null; + /** + * PSI pressure stall percentages (avg10), probed for the highest-utilization + * containers only; null means "not probed this cycle", not "no pressure". + */ + psiCpuSome: number | null; + psiCpuFull: number | null; + psiMemSome: number | null; + psiMemFull: number | null; + psiIoSome: number | null; + psiIoFull: number | null; +} + +/** Per-owner aggregate row (owner null = unattributed, admin-visible only). */ +export interface UsageOwner { + owner: string | null; + containerCount: number; + runningCount: number; + cpuUsed: number; + cpuAlloc: number; + memUsed: number; + memAlloc: number; + diskUsed: number; + diskAlloc: number; + diskReadBytes: number; + diskWriteBytes: number; + netInBytes: number; + netOutBytes: number; + /** Worst PSI reading across this owner's probed containers (null = unprobed). */ + pressureMax: number | null; + containers: UsageContainer[]; +} + +/** Owner-attribution problem detected during collection (admin-only). */ +export interface UsageFinding { + kind: 'drift' | 'unattributed'; + vmid: string; + tagOwner: string | null; + dbOwner: string | null; +} + +export interface UsageReport { + generatedAt: string; + owners: UsageOwner[]; + /** Physical cluster capacity summed from the hypervisor node rows. */ + capacity: { cpuCores: number; memBytes: number; diskBytes: number }; + /** Admin-only. */ + findings?: UsageFinding[]; + /** Admin-only: cluster members not registered in the manager DB. */ + unknownNodeRows?: number; +} + export interface Agent { id: number; siteId: number; @@ -237,6 +309,8 @@ export interface AppSettings { defaultContainerEnvVars: { key: string; value: string; description?: string }[]; /** Announcement banner shown to all users. Supports [text](url) links. */ bannerMessage: string; + /** Max PSI probes per usage report; '' uses the server default (16), '0' disables. */ + usagePsiProbeLimit: string; } export type ResourceType = 'memory' | 'swap' | 'cpus' | 'rootfs'; diff --git a/create-a-container/client/src/pages/settings/SettingsPage.tsx b/create-a-container/client/src/pages/settings/SettingsPage.tsx index e873da66..d01628d8 100644 --- a/create-a-container/client/src/pages/settings/SettingsPage.tsx +++ b/create-a-container/client/src/pages/settings/SettingsPage.tsx @@ -37,6 +37,7 @@ const schema = z.object({ netboxUrl: z.string(), netboxToken: z.string(), bannerMessage: z.string(), + usagePsiProbeLimit: z.string().regex(/^\d*$/, 'Must be a non-negative whole number'), }); type FormData = z.infer; @@ -45,7 +46,7 @@ export function SettingsPage() { const toast = useToast(); const { data, isLoading, error } = useQuery({ queryKey: keys.settings(), queryFn: queries.getSettings }); - const { register, handleSubmit, reset, control } = useForm({ + const { register, handleSubmit, reset, control, formState } = useForm({ resolver: zodResolver(schema), defaultValues: { smtpUrl: '', @@ -54,6 +55,7 @@ export function SettingsPage() { netboxUrl: '', netboxToken: '', bannerMessage: '', + usagePsiProbeLimit: '', }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); @@ -164,6 +166,21 @@ export function SettingsPage() { /> +
+

Usage report

+ +
+ {mutation.isSuccess && ( Your settings have been saved successfully. diff --git a/create-a-container/client/src/pages/usage/UsagePage.tsx b/create-a-container/client/src/pages/usage/UsagePage.tsx new file mode 100644 index 00000000..1f7a2be0 --- /dev/null +++ b/create-a-container/client/src/pages/usage/UsagePage.tsx @@ -0,0 +1,68 @@ +import { useParams } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Alert, AlertDescription, PageHeader, Spinner } from '@mieweb/ui'; +import { Activity } from 'lucide-react'; +import { AttributionWarnings } from '@/components/usage/AttributionWarnings'; +import { UsageDataGrid } from '@/components/usage/UsageDataGrid'; +import { UsageStackedBars } from '@/components/usage/UsageStackedBars'; +import type { ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; + +/** + * Live per-owner resource usage for the current site (allocated vs used), + * computed on demand from the hypervisor. Admin-only for now; the API + * returns 403 for non-admins. + */ +export function UsagePage() { + const { siteId } = useParams<{ siteId: string }>(); + const { data: site } = useQuery({ + queryKey: keys.site(siteId!), + queryFn: () => queries.getSite(siteId!), + enabled: !!siteId, + }); + const { data, isLoading, error } = useQuery({ + queryKey: keys.usage(siteId!), + queryFn: () => queries.getUsage(siteId!), + enabled: !!siteId, + // The report is a live hypervisor query; keep it reasonably fresh while + // the page is open without hammering the Proxmox API. + refetchInterval: 60000, + }); + + return ( +
+ } + /> + {error && ( + + {(error as ApiError).message} + + )} + {isLoading && ( +
+ +
+ )} + {data && ( + + )} + {data && data.owners.length === 0 && ( + + No running containers to report on. + + )} + {data && data.owners.length > 0 && ( + <> + + + + )} +
+ ); +} diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index a9261dd3..b3a87d9b 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -44,6 +44,7 @@ tags: - name: Sites - name: Containers - name: Nodes + - name: Usage - name: Agents - name: Jobs - name: External Domains @@ -391,6 +392,7 @@ components: netboxUrl: { type: string } netboxToken: { type: string } bannerMessage: { type: string, description: 'Announcement banner shown at the top of the app (supports [text](url) links); blank disables it. Also surfaced unauthenticated via GET /health as `banner`.' } + usagePsiProbeLimit: { type: string, description: 'Max PSI (pressure stall) probes per usage report, spent on the highest-utilization running containers. Non-negative integer as string; "0" disables PSI, blank uses the default (16).' } ResourceRequest: type: object properties: @@ -1057,6 +1059,47 @@ paths: application/json: schema: { type: object, properties: { data: { type: object } } } '404': { description: 'Node not found (code: not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /sites/{siteId}/usage: + get: + operationId: get_site_usage + tags: [Usage] + summary: Live per-owner resource usage report (allocated vs used), computed from one Proxmox cluster-resources call per cluster + description: >- + Admins see every owner on the site; other users see their own containers + plus containers shared with them. `findings` (owner-tag vs DB attribution + drift, unattributed containers) and `unknownNodeRows` are admin-only. + parameters: + - { in: path, name: siteId, required: true, schema: { type: integer } } + responses: + '200': + description: Per-owner usage rows with per-container detail + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + generatedAt: { type: string, format: date-time } + owners: + type: array + items: + type: object + description: One row per owner (`owner` null = unattributed), with summed metrics, worst PSI pressure, and per-container `containers` detail + capacity: + type: object + description: Physical cluster capacity summed from the hypervisor node rows + properties: + cpuCores: { type: number } + memBytes: { type: number } + diskBytes: { type: number } + findings: + type: array + description: Admin-only attribution findings (kind drift|unattributed) + items: { type: object } + unknownNodeRows: { type: integer, description: 'Admin-only count of cluster members not registered in the manager DB' } + '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /jobs/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] diff --git a/create-a-container/resources/usage/__tests__/service.test.js b/create-a-container/resources/usage/__tests__/service.test.js new file mode 100644 index 00000000..f8f83a4d --- /dev/null +++ b/create-a-container/resources/usage/__tests__/service.test.js @@ -0,0 +1,53 @@ +/** Unit tests for the usage service — repository and collection are mocked. */ + +jest.mock('../repository'); +jest.mock('../../../utils/usage-collection'); + +const repo = require('../repository'); +const { collectUsage } = require('../../../utils/usage-collection'); +const svc = require('../service'); +const { ApiError } = require('../../../middlewares/api'); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('usage service', () => { + test('getSiteUsage throws ApiError 404 for an unknown site without collecting', async () => { + repo.findSiteById.mockResolvedValue(null); + const err = await svc.getSiteUsage(999).catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err).toMatchObject({ status: 404, code: 'site_not_found' }); + expect(collectUsage).not.toHaveBeenCalled(); + }); + + test('getSiteUsage collects for the site and aggregates samples by owner', async () => { + repo.findSiteById.mockResolvedValue({ id: 7 }); + collectUsage.mockResolvedValue({ + samples: [ + { owner: 'alice', status: 'running', cpuUsed: 0.5, memUsed: 100 }, + { owner: 'alice', status: 'stopped', cpuUsed: 0.25, memUsed: 50 }, + { owner: null, status: 'running' }, + ], + findings: [{ kind: 'unattributed', vmid: '104', tagOwner: null, dbOwner: null }], + unknownNodeRows: 2, + capacity: { cpuCores: 32, memBytes: 64e9, diskBytes: 1e12 }, + }); + + const report = await svc.getSiteUsage(7); + + expect(collectUsage).toHaveBeenCalledWith({ siteId: 7 }); + expect(report.generatedAt).toBeInstanceOf(Date); + expect(report.capacity).toEqual({ cpuCores: 32, memBytes: 64e9, diskBytes: 1e12 }); + expect(report.findings).toHaveLength(1); + expect(report.unknownNodeRows).toBe(2); + // Aggregated: one row per owner, null owner last. + expect(report.owners.map((o) => o.owner)).toEqual(['alice', null]); + expect(report.owners[0]).toMatchObject({ + containerCount: 2, + runningCount: 1, + cpuUsed: 0.75, + memUsed: 150, + }); + }); +}); diff --git a/create-a-container/resources/usage/__tests__/usage.api.test.js b/create-a-container/resources/usage/__tests__/usage.api.test.js new file mode 100644 index 00000000..0e2eedab --- /dev/null +++ b/create-a-container/resources/usage/__tests__/usage.api.test.js @@ -0,0 +1,83 @@ +/** + * Integration tests for /api/v1/sites/:siteId/usage — pin the wire contract + * (manifesto §6 step 1). The test site has no nodes, so the collection cycle + * completes without any Proxmox calls and returns the empty report shape. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../tests/helpers/db'); +const { Site } = require('../../../models'); + +describe('/api/v1/sites/:siteId/usage', () => { + let app; + let site; + let adminKey; // first user after resetDb() is auto-promoted to sysadmins + let userKey; + + beforeAll(async () => { + // Order matters: resetDb() first (see tests/helpers/app.js). + await resetDb(); + app = buildApp(); + const admin = await createUser({ uid: 'admin' }); + const regular = await createUser({ uid: 'regular' }); + adminKey = await createApiKey(admin, 'admin key'); + userKey = await createApiKey(regular, 'regular key'); + site = await Site.create({ name: 'test-site', internalDomain: 'test.internal' }); + }); + + afterAll(async () => { + await closeDb(); + }); + + test('401 without credentials', async () => { + const res = await request(app).get(`/api/v1/sites/${site.id}/usage`); + expect(res.status).toBe(401); + expect(res.body).toEqual({ + error: { code: 'unauthorized', message: 'Authentication required' }, + }); + }); + + test('403 for non-admins', async () => { + const res = await request(app) + .get(`/api/v1/sites/${site.id}/usage`) + .set(...bearer(userKey.plainKey)); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('forbidden'); + }); + + test('404 for an unknown site', async () => { + const res = await request(app) + .get('/api/v1/sites/999999/usage') + .set(...bearer(adminKey.plainKey)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ + error: { code: 'site_not_found', message: 'Site not found' }, + }); + }); + + test('400 for a non-numeric siteId', async () => { + const res = await request(app) + .get('/api/v1/sites/not-a-number/usage') + .set(...bearer(adminKey.plainKey)); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('invalid_request'); + }); + + test('200 empty report envelope for a site with no nodes', async () => { + const res = await request(app) + .get(`/api/v1/sites/${site.id}/usage`) + .set(...bearer(adminKey.plainKey)); + + expect(res.status).toBe(200); + const report = res.body.data; + expect(Object.keys(report).sort()).toEqual( + ['capacity', 'findings', 'generatedAt', 'owners', 'unknownNodeRows'], + ); + expect(new Date(report.generatedAt).toISOString()).toBe(report.generatedAt); + expect(report.owners).toEqual([]); + expect(report.capacity).toEqual({ cpuCores: 0, memBytes: 0, diskBytes: 0 }); + expect(report.findings).toEqual([]); + expect(report.unknownNodeRows).toBe(0); + }); +}); diff --git a/create-a-container/resources/usage/controller.js b/create-a-container/resources/usage/controller.js new file mode 100644 index 00000000..920f9f2a --- /dev/null +++ b/create-a-container/resources/usage/controller.js @@ -0,0 +1,10 @@ +const svc = require('./service'); +const { serializeUsageReport } = require('./serializer'); +const { asyncHandler, ok } = require('../../middlewares/api'); + +const report = asyncHandler(async (req, res) => { + const usage = await svc.getSiteUsage(req.validated.params.siteId); + return ok(res, serializeUsageReport(usage)); +}); + +module.exports = { report }; diff --git a/create-a-container/resources/usage/repository.js b/create-a-container/resources/usage/repository.js new file mode 100644 index 00000000..635b01bd --- /dev/null +++ b/create-a-container/resources/usage/repository.js @@ -0,0 +1,7 @@ +const { Site } = require('../../models'); + +async function findSiteById(id) { + return Site.findByPk(id); +} + +module.exports = { findSiteById }; diff --git a/create-a-container/resources/usage/router.js b/create-a-container/resources/usage/router.js new file mode 100644 index 00000000..fc817148 --- /dev/null +++ b/create-a-container/resources/usage/router.js @@ -0,0 +1,19 @@ +/** + * /api/v1/sites/:siteId/usage — live per-owner resource usage report + * (issue #440). Admin-only for now. + */ + +const express = require('express'); +const { apiAuth, apiAdmin } = require('../../middlewares/api'); +const { validate } = require('../../middlewares/validate'); +const { siteIdParam } = require('./validator'); +const ctrl = require('./controller'); + +// mergeParams: siteId belongs to the parent /sites/:siteId mount. +const router = express.Router({ mergeParams: true }); + +router.use(apiAuth, apiAdmin); + +router.get('/', validate({ params: siteIdParam }), ctrl.report); + +module.exports = router; diff --git a/create-a-container/resources/usage/serializer.js b/create-a-container/resources/usage/serializer.js new file mode 100644 index 00000000..887d8b3d --- /dev/null +++ b/create-a-container/resources/usage/serializer.js @@ -0,0 +1,11 @@ +function serializeUsageReport(report) { + return { + generatedAt: report.generatedAt.toISOString(), + owners: report.owners, + capacity: report.capacity, + findings: report.findings, + unknownNodeRows: report.unknownNodeRows, + }; +} + +module.exports = { serializeUsageReport }; diff --git a/create-a-container/resources/usage/service.js b/create-a-container/resources/usage/service.js new file mode 100644 index 00000000..e49445a8 --- /dev/null +++ b/create-a-container/resources/usage/service.js @@ -0,0 +1,26 @@ +const repo = require('./repository'); +const { ApiError } = require('../../middlewares/api'); +const { collectUsage } = require('../../utils/usage-collection'); +const { aggregateByOwner } = require('../../utils/usage-report'); + +/** + * Live per-owner resource usage report for one site (issue #440), computed + * on demand from one collection cycle (utils/usage-collection.js). + */ +async function getSiteUsage(siteId) { + const site = await repo.findSiteById(siteId); + if (!site) throw new ApiError(404, 'site_not_found', 'Site not found'); + + const { samples, findings, unknownNodeRows, capacity } = await collectUsage({ siteId: site.id }); + + return { + generatedAt: new Date(), + owners: aggregateByOwner(samples), + // Physical cluster capacity (node rows), for used-vs-capacity charts. + capacity, + findings, + unknownNodeRows, + }; +} + +module.exports = { getSiteUsage }; diff --git a/create-a-container/resources/usage/validator.js b/create-a-container/resources/usage/validator.js new file mode 100644 index 00000000..c80b0c45 --- /dev/null +++ b/create-a-container/resources/usage/validator.js @@ -0,0 +1,9 @@ +const { z } = require('zod'); + +// siteId arrives via the parent /sites/:siteId mount (mergeParams). Coerce +// and reject garbage here so it fails as a 400 instead of reaching the DB. +const siteIdParam = z.object({ + siteId: z.coerce.number().int().positive(), +}); + +module.exports = { siteIdParam }; diff --git a/create-a-container/routers/api/v1/__tests__/settings-psi.api.test.js b/create-a-container/routers/api/v1/__tests__/settings-psi.api.test.js new file mode 100644 index 00000000..ff228a9c --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/settings-psi.api.test.js @@ -0,0 +1,64 @@ +/** + * Integration tests for the usage_psi_probe_limit system setting (PR #452 + * review): the PSI probe budget is an admin Setting, not an env var. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db'); +const { Setting } = require('../../../../models'); + +describe('usage PSI probe limit setting', () => { + let app; + let adminKey; + + beforeAll(async () => { + // Order matters: resetDb() first (see tests/helpers/app.js). + await resetDb(); + app = buildApp(); + // First user created is auto-promoted to sysadmins (User.afterCreate). + const admin = await createUser({ uid: 'psimin' }); + adminKey = (await createApiKey(admin, 'admin key')).plainKey; + }); + + afterAll(async () => { + await closeDb(); + }); + + async function putLimit(usagePsiProbeLimit) { + return request(app) + .put('/api/v1/settings') + .set(...bearer(adminKey)) + .send({ usagePsiProbeLimit }); + } + + test('defaults to empty (server default applies)', async () => { + const res = await request(app) + .get('/api/v1/settings') + .set(...bearer(adminKey)); + expect(res.status).toBe(200); + expect(res.body.data.usagePsiProbeLimit).toBe(''); + }); + + test('stores a non-negative integer, trimmed', async () => { + expect((await putLimit(' 8 ')).status).toBe(200); + expect(await Setting.get('usage_psi_probe_limit')).toBe('8'); + + const res = await request(app) + .get('/api/v1/settings') + .set(...bearer(adminKey)); + expect(res.body.data.usagePsiProbeLimit).toBe('8'); + }); + + test('accepts 0 (disables PSI collection)', async () => { + expect((await putLimit('0')).status).toBe(200); + expect(await Setting.get('usage_psi_probe_limit')).toBe('0'); + }); + + test('normalizes garbage and negatives to empty (default)', async () => { + for (const bad of ['abc', '-3', null]) { + expect((await putLimit(bad)).status).toBe(200); + expect(await Setting.get('usage_psi_probe_limit')).toBe(''); + } + }); +}); diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index e69ead83..bbe3e3dc 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -17,6 +17,7 @@ const KEYS = [ 'netbox_url', 'netbox_token', 'banner_message', + 'usage_psi_probe_limit', ]; router.get( @@ -36,6 +37,7 @@ router.get( netboxUrl: settings.netbox_url || '', netboxToken: settings.netbox_token || '', bannerMessage: settings.banner_message || '', + usagePsiProbeLimit: settings.usage_psi_probe_limit || '', }); }), ); @@ -50,6 +52,7 @@ router.put( netboxUrl, netboxToken, bannerMessage, + usagePsiProbeLimit, } = req.body || {}; const envVars = []; @@ -71,6 +74,12 @@ router.put( await Setting.set('netbox_url', netboxUrl || ''); await Setting.set('netbox_token', netboxToken || ''); await Setting.set('banner_message', (bannerMessage || '').trim()); + // Store a clean non-negative integer, or '' to fall back to the default. + const psiParsed = parseInt(String(usagePsiProbeLimit ?? '').trim(), 10); + await Setting.set( + 'usage_psi_probe_limit', + Number.isNaN(psiParsed) || psiParsed < 0 ? '' : String(psiParsed), + ); return ok(res, { saved: true }); }), diff --git a/create-a-container/routers/api/v1/sites.js b/create-a-container/routers/api/v1/sites.js index 2ee1b59e..f0d3f6e8 100644 --- a/create-a-container/routers/api/v1/sites.js +++ b/create-a-container/routers/api/v1/sites.js @@ -15,6 +15,7 @@ router.use(apiAuth); // Nested mounts router.use('/:siteId/containers', require('./containers')); router.use('/:siteId/nodes', require('./nodes')); +router.use('/:siteId/usage', require('../../../resources/usage/router')); function serialize(site) { return { diff --git a/create-a-container/utils/__tests__/usage-psi.test.js b/create-a-container/utils/__tests__/usage-psi.test.js new file mode 100644 index 00000000..19f1ff43 --- /dev/null +++ b/create-a-container/utils/__tests__/usage-psi.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const { selectPsiCandidates, latestPsi } = require('../usage-psi'); + +function sample(overrides = {}) { + return { + vmid: '100', + status: 'running', + cpuUsed: 0.1, + cpuAlloc: 4, + memUsed: 100, + memAlloc: 1000, + ...overrides, + }; +} + +describe('selectPsiCandidates', () => { + test('orders by worst utilization (memory or CPU fraction) and caps at the limit', () => { + const low = sample({ vmid: 'low', memUsed: 100 }); + const hot = sample({ vmid: 'hot', memUsed: 950 }); + const cpuHot = sample({ vmid: 'cpuhot', cpuUsed: 3.6 }); + const mid = sample({ vmid: 'mid', memUsed: 500 }); + + const picked = selectPsiCandidates([low, hot, cpuHot, mid], 2); + expect(picked.map((s) => s.vmid)).toEqual(['hot', 'cpuhot']); + }); + + test('only probes running containers', () => { + const stopped = sample({ vmid: 'stopped', status: 'stopped', memUsed: 999 }); + const running = sample({ vmid: 'running' }); + expect(selectPsiCandidates([stopped, running], 5).map((s) => s.vmid)).toEqual(['running']); + }); + + test('handles null metrics and a zero limit', () => { + const sparse = sample({ vmid: 'sparse', cpuUsed: null, memUsed: null, memAlloc: null, cpuAlloc: null }); + expect(selectPsiCandidates([sparse], 5)).toEqual([sparse]); + expect(selectPsiCandidates([sparse], 0)).toEqual([]); + }); +}); + +describe('latestPsi', () => { + test('takes the newest non-null value per field', () => { + const rows = [ + { time: 1, pressurememoryfull: 5, pressurecpusome: 1 }, + { time: 2, pressurememoryfull: 42, pressurecpusome: null }, + ]; + const psi = latestPsi(rows); + expect(psi.psiMemFull).toBe(42); + expect(psi.psiCpuSome).toBe(1); // newest row was null; falls back one row + expect(psi.psiIoFull).toBeNull(); // never present + }); + + test('returns null when the series is empty or has no PSI fields', () => { + expect(latestPsi([])).toBeNull(); + expect(latestPsi(null)).toBeNull(); + expect(latestPsi([{ time: 1, cpu: 0.5 }])).toBeNull(); + }); +}); diff --git a/create-a-container/utils/__tests__/usage-report.test.js b/create-a-container/utils/__tests__/usage-report.test.js new file mode 100644 index 00000000..53acc8d4 --- /dev/null +++ b/create-a-container/utils/__tests__/usage-report.test.js @@ -0,0 +1,87 @@ +'use strict'; + +const { aggregateByOwner } = require('../usage-report'); + +function sample(overrides = {}) { + return { + vmid: '100', + name: 'ct-100', + owner: 'cmyers', + containerDbId: 1, + node: 'pve1', + siteId: 1, + status: 'running', + cpuUsed: 0.5, + cpuAlloc: 4, + memUsed: 1024, + memAlloc: 4096, + diskUsed: 10, + diskAlloc: 50, + diskReadBytes: 100, + diskWriteBytes: 200, + netInBytes: 300, + netOutBytes: 400, + uptime: 3600, + ...overrides, + }; +} + +describe('aggregateByOwner', () => { + test('groups samples by owner and sums metrics', () => { + const rows = aggregateByOwner([ + sample({ vmid: '100', cpuUsed: 0.5, cpuAlloc: 4, memUsed: 1000 }), + sample({ vmid: '101', cpuUsed: 1.5, cpuAlloc: 2, memUsed: 2000, status: 'stopped' }), + sample({ vmid: '102', owner: 'horner', cpuUsed: 3, cpuAlloc: 8 }), + ]); + + expect(rows.map((r) => r.owner)).toEqual(['cmyers', 'horner']); + + const cmyers = rows[0]; + expect(cmyers.containerCount).toBe(2); + expect(cmyers.runningCount).toBe(1); + expect(cmyers.cpuUsed).toBe(2); + expect(cmyers.cpuAlloc).toBe(6); + expect(cmyers.memUsed).toBe(3000); + expect(cmyers.containers.map((c) => c.vmid)).toEqual(['100', '101']); + + expect(rows[1].containerCount).toBe(1); + expect(rows[1].cpuAlloc).toBe(8); + }); + + test('ignores null metrics instead of treating them as zero', () => { + const rows = aggregateByOwner([ + sample({ cpuUsed: null, memUsed: null }), + sample({ vmid: '101', cpuUsed: 1, memUsed: 500 }), + ]); + + expect(rows[0].cpuUsed).toBe(1); + expect(rows[0].memUsed).toBe(500); + expect(rows[0].containerCount).toBe(2); + }); + + test('collapses unattributed samples into one null-owner row sorted last', () => { + const rows = aggregateByOwner([ + sample({ owner: null, vmid: '200' }), + sample({ owner: 'zbarrell', vmid: '201' }), + sample({ owner: null, vmid: '202' }), + ]); + + expect(rows.map((r) => r.owner)).toEqual(['zbarrell', null]); + expect(rows[1].containerCount).toBe(2); + }); + + test('pressureMax is the worst PSI across the owner containers, null when unprobed', () => { + const rows = aggregateByOwner([ + sample({ vmid: '100', psiMemFull: 42, psiIoSome: 5 }), + sample({ vmid: '101', psiCpuSome: 7 }), + sample({ vmid: '102', owner: 'horner' }), + ]); + + expect(rows[0].pressureMax).toBe(42); + expect(rows[1].pressureMax).toBeNull(); + }); + + test('returns an empty array for no samples', () => { + expect(aggregateByOwner([])).toEqual([]); + }); +}); diff --git a/create-a-container/utils/__tests__/usage-sample.test.js b/create-a-container/utils/__tests__/usage-sample.test.js new file mode 100644 index 00000000..5c0c934b --- /dev/null +++ b/create-a-container/utils/__tests__/usage-sample.test.js @@ -0,0 +1,141 @@ +'use strict'; + +const { parseOwnerTag, cpuCoresUsed, buildUsageSample } = require('../usage-sample'); + +describe('parseOwnerTag', () => { + test('returns the single tag as owner', () => { + expect(parseOwnerTag('horner')).toBe('horner'); + }); + + test('returns the first tag of a semicolon-separated list', () => { + expect(parseOwnerTag('cmyers;gpu;prod')).toBe('cmyers'); + }); + + test('skips empty leading segments and trims whitespace', () => { + expect(parseOwnerTag('; rgara ;x')).toBe('rgara'); + }); + + test('returns null for missing or non-string tags', () => { + expect(parseOwnerTag(undefined)).toBeNull(); + expect(parseOwnerTag(null)).toBeNull(); + expect(parseOwnerTag('')).toBeNull(); + expect(parseOwnerTag(';;')).toBeNull(); + expect(parseOwnerTag(42)).toBeNull(); + }); +}); + +describe('cpuCoresUsed', () => { + test('converts the utilization fraction to cores', () => { + expect(cpuCoresUsed(0.5, 4)).toBe(2); + expect(cpuCoresUsed(0, 8)).toBe(0); + }); + + test('returns null when either input is missing', () => { + expect(cpuCoresUsed(undefined, 4)).toBeNull(); + expect(cpuCoresUsed(0.5, undefined)).toBeNull(); + }); +}); + +describe('buildUsageSample', () => { + const node = { name: 'pve2', siteId: 3 }; + const resource = { + vmid: 284, + name: 'ozwell-studio-92e3d441', + node: 'pve2', + status: 'running', + tags: 'zbarrell', + cpu: 0.25, + maxcpu: 4, + mem: 4262461440, + maxmem: 4294967296, + disk: 10737418240, + maxdisk: 53687091200, + diskread: 1000, + diskwrite: 2000, + netin: 3000, + netout: 4000, + uptime: 86400, + }; + + test('maps all fields from a cluster-resources entry', () => { + const container = { id: 11, username: 'zbarrell' }; + const { sample, finding } = buildUsageSample({ resource, node, container }); + + expect(finding).toBeNull(); + expect(sample).toEqual({ + vmid: '284', + name: 'ozwell-studio-92e3d441', + owner: 'zbarrell', + containerDbId: 11, + node: 'pve2', + siteId: 3, + status: 'running', + cpuUsed: 1, + cpuAlloc: 4, + memUsed: 4262461440, + memAlloc: 4294967296, + diskUsed: 10737418240, + diskAlloc: 53687091200, + diskReadBytes: 1000, + diskWriteBytes: 2000, + netInBytes: 3000, + netOutBytes: 4000, + uptime: 86400, + psiCpuSome: null, + psiCpuFull: null, + psiMemSome: null, + psiMemFull: null, + psiIoSome: null, + psiIoFull: null, + }); + }); + + test('reports drift when tag and DB owner disagree; tag wins', () => { + const container = { id: 11, username: 'someoneelse' }; + const { sample, finding } = buildUsageSample({ resource, node, container }); + + expect(sample.owner).toBe('zbarrell'); + expect(finding).toEqual({ + kind: 'drift', + vmid: '284', + tagOwner: 'zbarrell', + dbOwner: 'someoneelse', + }); + }); + + test('falls back to the DB owner when the container is untagged', () => { + const untagged = { ...resource, tags: undefined }; + const container = { id: 11, username: 'cmyers' }; + const { sample, finding } = buildUsageSample({ resource: untagged, node, container }); + + expect(sample.owner).toBe('cmyers'); + expect(finding).toBeNull(); + }); + + test('flags unattributed containers (no tag, not in DB)', () => { + const untagged = { ...resource, tags: undefined }; + const { sample, finding } = buildUsageSample({ resource: untagged, node, container: null }); + + expect(sample.owner).toBeNull(); + expect(sample.containerDbId).toBeNull(); + expect(finding).toEqual({ + kind: 'unattributed', + vmid: '284', + tagOwner: null, + dbOwner: null, + }); + }); + + test('maps absent metrics to null (sparse sources like DummyApi)', () => { + const sparse = { vmid: '5', node: 'dummy', status: 'running', tags: 'cmyers' }; + const { sample } = buildUsageSample({ resource: sparse, node, container: null }); + + expect(sample.cpuUsed).toBeNull(); + expect(sample.cpuAlloc).toBeNull(); + expect(sample.memUsed).toBeNull(); + expect(sample.diskReadBytes).toBeNull(); + expect(sample.uptime).toBeNull(); + expect(sample.name).toBeNull(); + expect(sample.status).toBe('running'); + }); +}); diff --git a/create-a-container/utils/dummy-api.js b/create-a-container/utils/dummy-api.js index 0dc0de0c..02d2ac0a 100644 --- a/create-a-container/utils/dummy-api.js +++ b/create-a-container/utils/dummy-api.js @@ -309,13 +309,26 @@ class DummyApi { * @returns {Promise>} */ async clusterResources(type = null) { - if (type && type !== 'lxc') return []; + if (type && type !== 'lxc' && type !== 'node') return []; if (this.node.id == null) return []; + // A single node row supplies simulated cluster capacity (matches the + // nodeStatus() figures) for the usage report's stacked bars. + if (type === 'node') { + const GiB = 1024 * 1024 * 1024; + return [{ + type: 'node', + node: this.node.name || 'dummy', + status: 'online', + maxcpu: 8, + maxmem: 32 * GiB, + maxdisk: 500 * GiB, + }]; + } // Lazy require to avoid a load-time cycle (models/node.js -> dummy-api.js). const { Container } = require('../models'); const containers = await Container.findAll({ where: { nodeId: this.node.id, containerId: { [require('sequelize').Op.ne]: null } }, - attributes: ['containerId', 'hostname'], + attributes: ['containerId', 'hostname', 'username'], }); return containers.map((c) => ({ vmid: c.containerId, @@ -323,8 +336,41 @@ class DummyApi { type: 'lxc', status: 'running', node: this.node.name || 'dummy', + // Real nodes tag containers with their owner (see bin/create-container.js); + // mirror that so owner attribution (utils/usage-sample.js) works in dev. + tags: c.username, + // Simulated resource usage so the usage report has data in dev. + cpu: 0.05, + maxcpu: 2, + mem: 256 * 1024 * 1024, + maxmem: 1024 * 1024 * 1024, + disk: 2 * 1024 * 1024 * 1024, + maxdisk: 8 * 1024 * 1024 * 1024, + diskread: 100 * 1024 * 1024, + diskwrite: 50 * 1024 * 1024, + netin: 10 * 1024 * 1024, + netout: 5 * 1024 * 1024, + uptime: 3600, })); } + + /** + * Simulated RRD series with PSI pressure fields so the pressure path of the + * usage report/collector renders in dev. One recent point is enough — the + * consumers only read the latest values. + * @returns {Promise>} + */ + async rrdData() { + return [{ + time: Math.floor(Date.now() / 1000) - 60, + pressurecpusome: Math.random() * 5, + pressurecpufull: Math.random() * 1, + pressurememorysome: Math.random() * 10, + pressurememoryfull: Math.random() * 2, + pressureiosome: Math.random() * 8, + pressureiofull: Math.random() * 2, + }]; + } } module.exports = DummyApi; diff --git a/create-a-container/utils/proxmox-api.js b/create-a-container/utils/proxmox-api.js index e24fd86f..3e0b5189 100644 --- a/create-a-container/utils/proxmox-api.js +++ b/create-a-container/utils/proxmox-api.js @@ -164,6 +164,23 @@ class ProxmoxApi { return response.data.data.filter(r => r.type === type); } + /** + * Get RRD time-series data for a container. At 60 s resolution this includes + * the PSI pressure fields (pressurecpusome/full, pressurememorysome/full, + * pressureiosome/full) used for health detection. + * @param {string} node - The node name + * @param {number|string} vmid + * @param {'hour'|'day'|'week'|'month'|'year'} [timeframe] + * @returns {Promise>} - The API response data + */ + async rrdData(node, vmid, timeframe = 'hour') { + const response = await axios.get( + `${this.baseUrl}/api2/json/nodes/${node}/lxc/${vmid}/rrddata?timeframe=${timeframe}`, + this.options, + ); + return response.data.data; + } + /** * Get container configuration * @param {string} node diff --git a/create-a-container/utils/usage-collection.js b/create-a-container/utils/usage-collection.js new file mode 100644 index 00000000..e6585ac7 --- /dev/null +++ b/create-a-container/utils/usage-collection.js @@ -0,0 +1,163 @@ +'use strict'; + +/** + * usage-collection.js — one polling cycle of per-container usage samples + * (issue #440), backing the /sites/:siteId/usage report endpoint + * (resources/usage). + * + * One Proxmox `/cluster/resources` call per cluster for LXCs plus one for + * node capacity: after each successful call, every node name appearing in the + * response is marked covered (within the same site), so cluster peers are not + * re-polled. Per-node failures are logged and skipped; a cycle always + * completes. A second, budget-capped pass probes `rrddata` PSI for the + * highest-utilization containers (utils/usage-psi.js) — never the whole + * fleet. + * + * Pure per-entry mapping/attribution lives in utils/usage-sample.js. + */ + +const db = require('../models'); +const { buildUsageSample } = require('./usage-sample'); +const { selectPsiCandidates, latestPsi } = require('./usage-psi'); + +// PSI probes are one API call per container; the per-cycle fan-out is capped +// by the admin 'usage_psi_probe_limit' setting (Settings page), this default +// when unset. +const DEFAULT_PSI_PROBE_LIMIT = 16; + +async function getPsiProbeLimit() { + const parsed = parseInt(await db.Setting.get('usage_psi_probe_limit'), 10); + return Number.isNaN(parsed) || parsed < 0 ? DEFAULT_PSI_PROBE_LIMIT : parsed; +} + +/** + * Load every DB container that exists in Proxmox, keyed by `${nodeId}:${vmid}` + * for O(1) attribution lookups against cluster-resources entries. + * @param {number|null} siteId - Restrict to one site, or null for all + * @returns {Promise>} + */ +async function loadContainerIndex(siteId) { + const where = { containerId: { [db.Sequelize.Op.ne]: null } }; + if (siteId != null) where.siteId = siteId; + const containers = await db.Container.findAll({ + where, + attributes: ['id', 'containerId', 'nodeId', 'username'], + }); + const index = new Map(); + for (const c of containers) { + index.set(`${c.nodeId}:${c.containerId}`, c); + } + return index; +} + +/** + * Gather one cycle of normalized usage samples, attribution findings, and + * cluster capacity, then enrich the highest-utilization containers with PSI. + * @param {object} [options] + * @param {number|null} [options.siteId] - Restrict to one site, or null for all + * @param {number|null} [options.psiProbeLimit] - Max rrddata calls this cycle + * (0 disables); null reads the 'usage_psi_probe_limit' admin setting + * @returns {Promise<{ + * samples: Array, + * findings: Array<{ kind: 'drift'|'unattributed', vmid: string, tagOwner: string|null, dbOwner: string|null }>, + * unknownNodeRows: number, + * capacity: { cpuCores: number, memBytes: number, diskBytes: number }, + * }>} + */ +async function collectUsage({ siteId = null, psiProbeLimit = null } = {}) { + const nodeWhere = db.Node.provisionableWhere(); + if (siteId != null) nodeWhere.siteId = siteId; + const nodes = await db.Node.findAll({ where: nodeWhere }); + const capacity = { cpuCores: 0, memBytes: 0, diskBytes: 0 }; + if (nodes.length === 0) return { samples: [], findings: [], unknownNodeRows: 0, capacity }; + + const containerIndex = await loadContainerIndex(siteId); + + // Node lookup by `${siteId}:${name}` — cluster-resources rows carry the + // Proxmox node name, and node names are only meaningful within a site. + const nodesByName = new Map(nodes.map((n) => [`${n.siteId}:${n.name}`, n])); + const covered = new Set(); + + const samples = []; + const findings = []; + // API clients per sample, for the PSI probe pass below. + const apiBySample = new Map(); + let unknownNodeRows = 0; + + for (const node of nodes) { + const nodeKey = `${node.siteId}:${node.name}`; + if (covered.has(nodeKey)) continue; + covered.add(nodeKey); + + let api; + let resources; + let nodeRows; + try { + api = await node.api(); + resources = await api.clusterResources('lxc'); + nodeRows = await api.clusterResources('node'); + } catch (err) { + console.error(`UsageCollection: node ${node.name} (site ${node.siteId}) unreachable: ${err.message}`); + continue; + } + + // Cluster capacity from the node rows (each cluster contributes once). + for (const row of Array.isArray(nodeRows) ? nodeRows : []) { + covered.add(`${node.siteId}:${row.node}`); + capacity.cpuCores += row.maxcpu || 0; + capacity.memBytes += row.maxmem || 0; + capacity.diskBytes += row.maxdisk || 0; + } + + if (!Array.isArray(resources)) continue; + + for (const resource of resources) { + if (resource.vmid == null) continue; + const resourceNodeKey = `${node.siteId}:${resource.node}`; + covered.add(resourceNodeKey); + + const dbNode = nodesByName.get(resourceNodeKey); + if (!dbNode) { + // Cluster member not registered in the DB — no site/node to attribute + // the sample to; count it so the drift is visible to callers. + unknownNodeRows++; + continue; + } + + const container = containerIndex.get(`${dbNode.id}:${resource.vmid}`) || null; + const { sample, finding } = buildUsageSample({ resource, node: dbNode, container }); + samples.push(sample); + apiBySample.set(sample, api); + if (finding) findings.push(finding); + } + } + + await probePsi(samples, apiBySample, psiProbeLimit ?? (await getPsiProbeLimit())); + + return { samples, findings, unknownNodeRows, capacity }; +} + +/** + * Tier-2 PSI pass: probe `rrddata` for the highest-utilization running + * containers (budget-capped) and fill in their psi* sample fields in place. + * Probe failures are logged and leave the sample's PSI null. + * @param {Array} samples + * @param {Map} apiBySample - Sample -> API client that reported it + * @param {number} limit + */ +async function probePsi(samples, apiBySample, limit) { + const candidates = selectPsiCandidates(samples, limit); + await Promise.all(candidates.map(async (sample) => { + const api = apiBySample.get(sample); + if (!api || typeof api.rrdData !== 'function') return; + try { + const rows = await api.rrdData(sample.node, sample.vmid, 'hour'); + const psi = latestPsi(rows); + if (psi) Object.assign(sample, psi); + } catch (err) { + console.error(`UsageCollection: PSI probe failed for CT ${sample.vmid} on ${sample.node}: ${err.message}`); + } + })); +} + +module.exports = { collectUsage }; diff --git a/create-a-container/utils/usage-psi.js b/create-a-container/utils/usage-psi.js new file mode 100644 index 00000000..d98bcaec --- /dev/null +++ b/create-a-container/utils/usage-psi.js @@ -0,0 +1,84 @@ +'use strict'; + +/** + * usage-psi.js — pure helpers for PSI (pressure stall information) collection + * (issue #440, tier 2). + * + * Proxmox exports per-container PSI through `rrddata` — but that is one API + * call per container, so the fleet is never swept. Each cycle probes only the + * containers most likely to be under pressure (highest memory/CPU utilization + * relative to their allocation), capped at a fixed budget. Raw stats miss + * exactly the incidents PSI catches (a container at 99% memory can be healthy; + * one at 99% with psiMemFull > 40 is thrashing), which is why the report + * carries both. + * + * No I/O here — candidate selection and RRD parsing are pure so they are + * trivially unit-tested; utils/usage-collection.js owns the API calls. + */ + +/** RRD field -> sample field for the six PSI series. */ +const PSI_FIELDS = { + pressurecpusome: 'psiCpuSome', + pressurecpufull: 'psiCpuFull', + pressurememorysome: 'psiMemSome', + pressurememoryfull: 'psiMemFull', + pressureiosome: 'psiIoSome', + pressureiofull: 'psiIoFull', +}; + +/** + * Utilization score used to prioritize PSI probes: the worst of memory and + * CPU usage as a fraction of allocation. Containers without usable ratios + * score 0 (still probed when the budget allows). + * @param {object} sample - Normalized sample (utils/usage-sample.js) + * @returns {number} + */ +function utilizationScore(sample) { + const mem = sample.memAlloc > 0 && sample.memUsed != null ? sample.memUsed / sample.memAlloc : 0; + const cpu = sample.cpuAlloc > 0 && sample.cpuUsed != null ? sample.cpuUsed / sample.cpuAlloc : 0; + return Math.max(mem, cpu); +} + +/** + * Pick which running containers to probe for PSI this cycle, ordered by + * utilization score (highest first) and capped at `limit`. + * @param {Array} samples - Normalized samples + * @param {number} limit - Probe budget for the cycle + * @returns {Array} Subset of samples to probe + */ +function selectPsiCandidates(samples, limit) { + if (limit <= 0) return []; + return samples + .filter((s) => s.status === 'running') + .sort((a, b) => utilizationScore(b) - utilizationScore(a)) + .slice(0, limit); +} + +/** + * Extract the most recent PSI readings from an rrddata series. RRD rows may + * trail off with nulls, so the scan walks backwards and takes the newest + * non-null value per field. + * @param {Array|null|undefined} rows - rrddata response (oldest first) + * @returns {object|null} `{ psiCpuSome, ..., psiIoFull }` (each number|null), + * or null when the series has no PSI data at all + */ +function latestPsi(rows) { + if (!Array.isArray(rows) || rows.length === 0) return null; + + const psi = {}; + let found = false; + for (const [rrdField, sampleField] of Object.entries(PSI_FIELDS)) { + psi[sampleField] = null; + for (let i = rows.length - 1; i >= 0; i--) { + const value = rows[i]?.[rrdField]; + if (typeof value === 'number' && Number.isFinite(value)) { + psi[sampleField] = value; + found = true; + break; + } + } + } + return found ? psi : null; +} + +module.exports = { selectPsiCandidates, latestPsi, PSI_FIELDS }; diff --git a/create-a-container/utils/usage-report.js b/create-a-container/utils/usage-report.js new file mode 100644 index 00000000..5ead19c9 --- /dev/null +++ b/create-a-container/utils/usage-report.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * usage-report.js — pure aggregation for the live per-owner usage report + * (issue #440). Groups normalized samples (utils/usage-sample.js) by owner + * with allocated-vs-used totals; the /sites/:siteId/usage endpoint owns + * visibility filtering and serialization. + */ + +const SUM_FIELDS = [ + 'cpuUsed', 'cpuAlloc', + 'memUsed', 'memAlloc', + 'diskUsed', 'diskAlloc', + 'diskReadBytes', 'diskWriteBytes', + 'netInBytes', 'netOutBytes', +]; + +const PSI_SAMPLE_FIELDS = [ + 'psiCpuSome', 'psiCpuFull', + 'psiMemSome', 'psiMemFull', + 'psiIoSome', 'psiIoFull', +]; + +/** + * Group usage samples by owner. Null owners collapse into a single + * `owner: null` row (rendered as "unattributed" by callers). Absent metrics + * (null) are excluded from sums rather than treated as zero. `pressureMax` is + * the worst PSI reading across the owner's probed containers (null when none + * were probed this cycle). + * @param {Array} samples - Normalized samples from utils/usage-sample.js + * @returns {Array} One row per owner, sorted by owner name (null last): + * { owner, containerCount, runningCount, , pressureMax, containers } + */ +function aggregateByOwner(samples) { + const byOwner = new Map(); + + for (const sample of samples) { + const key = sample.owner ?? null; + let row = byOwner.get(key); + if (!row) { + row = { owner: key, containerCount: 0, runningCount: 0, pressureMax: null, containers: [] }; + for (const field of SUM_FIELDS) row[field] = 0; + byOwner.set(key, row); + } + row.containerCount++; + if (sample.status === 'running') row.runningCount++; + for (const field of SUM_FIELDS) { + if (sample[field] != null) row[field] += sample[field]; + } + for (const field of PSI_SAMPLE_FIELDS) { + if (sample[field] != null && (row.pressureMax === null || sample[field] > row.pressureMax)) { + row.pressureMax = sample[field]; + } + } + row.containers.push(sample); + } + + return [...byOwner.values()].sort((a, b) => { + if (a.owner === null) return 1; + if (b.owner === null) return -1; + return a.owner.localeCompare(b.owner); + }); +} + +module.exports = { aggregateByOwner }; diff --git a/create-a-container/utils/usage-sample.js b/create-a-container/utils/usage-sample.js new file mode 100644 index 00000000..fd296e2b --- /dev/null +++ b/create-a-container/utils/usage-sample.js @@ -0,0 +1,122 @@ +'use strict'; + +/** + * usage-sample.js — pure helpers for usage collection (issue #440). + * + * Turns one Proxmox `/cluster/resources` LXC entry into a normalized usage + * sample and performs owner attribution: the Proxmox tag is the primary source + * (it lives on the container itself and survives DB loss), cross-checked + * against Container.username. Divergence between the two is reported as + * attribution drift. + * + * No I/O here — everything is a pure function so it is trivially unit-tested; + * utils/usage-collection.js owns the polling and DB lookups. + */ + +/** + * Extract the owner from a Proxmox `tags` field. create-container.js and the + * owner-change path in routers/api/v1/containers.js write the owner username + * as the container's tag; Proxmox stores tags as a `;`-separated list, so the + * first tag is the owner. + * @param {string|null|undefined} tags - Raw `tags` value from /cluster/resources + * @returns {string|null} Owner username, or null when untagged + */ +function parseOwnerTag(tags) { + if (!tags || typeof tags !== 'string') return null; + const first = tags.split(';').map((t) => t.trim()).find((t) => t.length > 0); + return first || null; +} + +/** + * Convert Proxmox's `cpu` field (utilization as a fraction of the container's + * allocated cores) into cores in use. + * @param {number|undefined} cpu - Fraction 0..1 of maxcpu + * @param {number|undefined} maxcpu - Allocated cores + * @returns {number|null} Cores in use + */ +function cpuCoresUsed(cpu, maxcpu) { + if (typeof cpu !== 'number' || typeof maxcpu !== 'number') return null; + return cpu * maxcpu; +} + +/** + * Coerce an optional numeric API field, mapping absent/invalid to null so + * sparse sources (e.g. DummyApi snapshots) yield omitted metrics, not NaN. + * @param {*} value + * @returns {number|null} + */ +function numberOrNull(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * Build one normalized usage sample (plus any attribution finding) from a + * cluster-resources LXC entry. + * + * Owner resolution: Proxmox tag if present, else the DB container's username. + * When both exist and disagree, the tag wins and a `drift` finding is + * returned. When neither exists an `unattributed` finding is returned. + * + * Metric semantics: `cpuUsed`/`memUsed`/`diskUsed` and their `*Alloc` + * counterparts are point-in-time gauges; `diskReadBytes`/`diskWriteBytes`/ + * `netInBytes`/`netOutBytes` are cumulative counters since container boot + * (monotonic sums — a decrease means the container rebooted). + * + * @param {object} params + * @param {object} params.resource - One `/cluster/resources` entry (type lxc) + * @param {object} params.node - DB Node the entry belongs to (name, siteId) + * @param {object|null} params.container - Matching DB Container row, if any + * @returns {{ sample: object, finding: { kind: 'drift'|'unattributed', vmid: string, tagOwner: string|null, dbOwner: string|null }|null }} + */ +function buildUsageSample({ resource, node, container }) { + const tagOwner = parseOwnerTag(resource.tags); + const dbOwner = container ? container.username : null; + const owner = tagOwner || dbOwner; + + let finding = null; + if (tagOwner && dbOwner && tagOwner !== dbOwner) { + finding = { kind: 'drift', vmid: String(resource.vmid), tagOwner, dbOwner }; + } else if (!owner) { + finding = { kind: 'unattributed', vmid: String(resource.vmid), tagOwner, dbOwner }; + } + + const sample = { + vmid: String(resource.vmid), + name: resource.name || null, + owner, + // DB primary key when the container is registered in the manager — used + // by the report endpoint to honour per-container sharing visibility. + containerDbId: container ? container.id : null, + node: node.name, + siteId: node.siteId, + status: resource.status || null, + cpuUsed: cpuCoresUsed(resource.cpu, resource.maxcpu), + cpuAlloc: numberOrNull(resource.maxcpu), + memUsed: numberOrNull(resource.mem), + memAlloc: numberOrNull(resource.maxmem), + diskUsed: numberOrNull(resource.disk), + diskAlloc: numberOrNull(resource.maxdisk), + diskReadBytes: numberOrNull(resource.diskread), + diskWriteBytes: numberOrNull(resource.diskwrite), + netInBytes: numberOrNull(resource.netin), + netOutBytes: numberOrNull(resource.netout), + uptime: numberOrNull(resource.uptime), + // PSI pressure readings are filled in by the tier-2 rrddata probe + // (utils/usage-collection.js) for high-utilization containers only; + // null means "not probed this cycle", not "no pressure". + psiCpuSome: null, + psiCpuFull: null, + psiMemSome: null, + psiMemFull: null, + psiIoSome: null, + psiIoFull: null, + }; + + return { sample, finding }; +} + +module.exports = { + parseOwnerTag, + cpuCoresUsed, + buildUsageSample, +}; diff --git a/mie-opensource-landing/docs/developers/development-workflow.md b/mie-opensource-landing/docs/developers/development-workflow.md index 3ace4b25..90216e69 100644 --- a/mie-opensource-landing/docs/developers/development-workflow.md +++ b/mie-opensource-landing/docs/developers/development-workflow.md @@ -31,7 +31,8 @@ This will: `localhost` external domain) — but only when the database is empty, so it never interferes with the Docker stack's bootstrap. 4. Build the React client. -5. Start the **server** and the **job-runner** together, serving at +5. Start the **server** and the **job-runner** + together, serving at [http://localhost:3000](http://localhost:3000). No configuration is required — the server's defaults are sufficient for