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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions create-a-container/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ INSTALL_DATA := $(INSTALL) -m 0644

# Server runtime files and directories. Client sources, dev tooling and repo
# metadata are intentionally excluded; only the built client/dist ships.
APP_FILES := server.js app.js job-runner.js package.json package-lock.json openapi.v1.yaml
APP_FILES := server.js app.js job-runner.js usage-collector.js package.json package-lock.json openapi.v1.yaml
APP_DIRS := bin config data middlewares migrations models node_modules \
public resources routers seeders utils

Expand Down Expand Up @@ -56,11 +56,13 @@ build: deps
# rewrites package-lock.json (so `make dev` leaves the lockfile untouched even
# when the local npm would otherwise renormalize it). Then run migrations (which
# also runs the dev-only seeders, e.g. a localhost site + dummy node, via
# `db:seed:all`), build the client once, and run three processes together via
# `db:seed:all`), build the client once, and run four processes together via
# concurrently:
# - server (nodemon, restarts on change)
# - job-runner (so container creation exercises the real POST /containers ->
# Job -> job-runner -> bin/create-container.js -> DummyApi path)
# - usage-collector (per-container OTLP metrics; exits immediately unless an
# OTLP endpoint is configured in .env)
# - client build in watch mode (rebuilds client/dist, which the server serves)
#
# Override behavior on the command line, e.g.:
Expand All @@ -76,9 +78,10 @@ dev:
npm run db:migrate
npm --prefix client run build
@echo "Starting Manager (server + job-runner + client watch) at http://localhost:3000 ..."
$(LOG_LEVEL_PREFIX)npx concurrently -n server,jobs,client -c blue,magenta,green \
$(LOG_LEVEL_PREFIX)npx concurrently -n server,jobs,usage,client -c blue,magenta,yellow,green \
"npx nodemon server.js" \
"node job-runner.js" \
"node usage-collector.js" \
"npm --prefix client run build:watch"

# Run the server test suite (jest + supertest against a throwaway SQLite DB in
Expand All @@ -99,6 +102,7 @@ install: build
$(INSTALL) -d $(UNIT_DIR)
$(INSTALL_DATA) contrib/systemd/container-creator.service $(UNIT_DIR)/
$(INSTALL_DATA) contrib/systemd/job-runner.service $(UNIT_DIR)/
$(INSTALL_DATA) contrib/systemd/usage-collector.service $(UNIT_DIR)/
$(INSTALL) -d $(DESTDIR)/etc/logrotate.d
$(INSTALL_DATA) contrib/opensource-server.logrotate $(DESTDIR)/etc/logrotate.d/opensource-server

Expand Down
20 changes: 13 additions & 7 deletions create-a-container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ 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 <http://localhost:3000> and can "create" containers locally (simulated).
and dev seeders, builds the client, and starts the server, the job-runner, the
usage-collector, 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 <http://localhost:3000> and can "create"
containers locally (simulated).

Pass `LOG_LEVEL=trace` to additionally log every SQL query:

Expand All @@ -46,13 +47,18 @@ 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`, `job-runner`, and `usage-collector` 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
In both cases the app runs `server.js` (HTTP API + UI), `job-runner.js`
(background worker), and `usage-collector.js` (per-container resource metrics
exported as OTLP; a no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` is configured).
The same per-owner data is available live in the UI at `/sites/:siteId/usage`
(API: `GET /api/v1/sites/:siteId/usage`) with no OTel backend required.
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.

Expand Down
13 changes: 13 additions & 0 deletions create-a-container/client/src/app/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
useSidebar,
} from '@mieweb/ui';
import {
Activity,
Box,
Building2,
ClipboardList,
Expand Down Expand Up @@ -166,6 +167,12 @@ export function AppSidebar() {
icon: <ContainerIcon className="size-4" />,
match: `/sites/${currentSiteId}/containers`,
})}
{renderLink({
to: `/sites/${currentSiteId}/usage`,
label: 'Usage',
icon: <Activity className="size-4" />,
match: `/sites/${currentSiteId}/usage`,
})}
{isAdmin && renderLink({
to: `/sites/${currentSiteId}/nodes`,
label: 'Nodes',
Expand All @@ -185,6 +192,12 @@ export function AppSidebar() {
icon: <ContainerIcon className="size-4" />,
match: `/sites/${currentSiteId}/containers`,
})}
{renderLink({
to: `/sites/${currentSiteId}/usage`,
label: 'Usage',
icon: <Activity className="size-4" />,
match: `/sites/${currentSiteId}/usage`,
})}
{isAdmin && renderLink({
to: `/sites/${currentSiteId}/nodes`,
label: 'Nodes',
Expand Down
3 changes: 3 additions & 0 deletions create-a-container/client/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,6 +63,8 @@ export const router = createBrowserRouter([
{ path: '/sites/:siteId/nodes/import', element: <NodeImportPage /> },
{ path: '/sites/:siteId/nodes/:id/edit', element: <NodeFormPage /> },

{ path: '/sites/:siteId/usage', element: <UsagePage /> },

{ path: '/external-domains', element: <ExternalDomainsListPage /> },
{ path: '/external-domains/new', element: <ExternalDomainFormPage /> },
{ path: '/external-domains/:id/edit', element: <ExternalDomainFormPage /> },
Expand Down
15 changes: 1 addition & 14 deletions create-a-container/client/src/components/nodes/ResourceBar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Alert variant="warning" role="alert" aria-live="polite">
<AlertDescription>
<div className="flex flex-col gap-1">
{drift.length > 0 && (
<span>
Attribution drift on {drift.length} container{drift.length === 1 ? '' : 's'}:{' '}
{drift
.map((f) => `CT ${f.vmid} (tag '${f.tagOwner}' ≠ DB '${f.dbOwner}')`)
.join(', ')}
</span>
)}
{unattributed.length > 0 && (
<span>
{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(', ')}
</span>
)}
{unknownNodeRows > 0 && (
<span>
{unknownNodeRows} container{unknownNodeRows === 1 ? '' : 's'} on cluster nodes not
registered in the manager.
</span>
)}
</div>
</AlertDescription>
</Alert>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-3">
<Table responsive>
<TableHeader>
<TableRow>
<TableHead>CT</TableHead>
<TableHead>Name</TableHead>
<TableHead>Node</TableHead>
<TableHead>Status</TableHead>
<TableHead>CPU (cores)</TableHead>
<TableHead>Memory</TableHead>
<TableHead>Disk</TableHead>
<TableHead>Disk I/O (r / w)</TableHead>
<TableHead>Network (in / out)</TableHead>
<TableHead>Pressure</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{containers.map((c) => (
<TableRow key={c.vmid}>
<TableCell className="font-mono text-sm">{c.vmid}</TableCell>
<TableCell className="font-medium">{c.name || '—'}</TableCell>
<TableCell>{c.node}</TableCell>
<TableCell>{c.status || '—'}</TableCell>
<TableCell>{pair(c.cpuUsed, c.cpuAlloc, cores)}</TableCell>
<TableCell>{pair(c.memUsed, c.memAlloc, formatBytes)}</TableCell>
<TableCell>{pair(c.diskUsed, c.diskAlloc, formatBytes)}</TableCell>
<TableCell>{pair(c.diskReadBytes, c.diskWriteBytes, formatBytes)}</TableCell>
<TableCell>{pair(c.netInBytes, c.netOutBytes, formatBytes)}</TableCell>
<TableCell>
<span
title={
worstPsi(c) == null
? 'Not probed this cycle'
: `CPU ${pair(c.psiCpuSome, c.psiCpuFull, (v) => 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 %)`
}
>
<PressureBadge value={worstPsi(c)} />
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
25 changes: 25 additions & 0 deletions create-a-container/client/src/components/usage/PressureBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="text-muted-foreground">—</span>;
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 (
<span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${cls}`}>
{value.toFixed(1)}%
</span>
);
}
60 changes: 60 additions & 0 deletions create-a-container/client/src/components/usage/StackedBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium">{title}</span>
<span className="text-muted-foreground">
{format(total)} / {format(capacity)}
{capacity > 0 && ` (${Math.round((total / capacity) * 100)}%)`}
</span>
</div>
<div
className="relative flex h-6 w-full overflow-hidden rounded bg-neutral-200 dark:bg-neutral-700"
role="img"
aria-label={`${title}: ${format(total)} used of ${format(capacity)} capacity`}
>
{segments.map((s) => (
<div
key={s.label}
className="h-full"
style={{ width: `${(s.value / scale) * 100}%`, backgroundColor: s.color }}
title={`${s.label}: ${format(s.value)}`}
/>
))}
{total > capacity && capacity > 0 && (
<div
className="absolute top-0 h-full w-0.5 bg-red-600"
style={{ left: `${capacityPct}%` }}
title={`Capacity: ${format(capacity)}`}
/>
)}
</div>
</div>
);
}
Loading
Loading