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
68 changes: 55 additions & 13 deletions src/main/services/node-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,20 @@ import { getNode, getSSH, updateNode } from './node-manager';
* Memo schema v1 (compact JSON, ≤ 240 bytes after `specs:v1:`):
* { cpu, c, cr, r, rr }
* cpu – CPU model string, truncated to 64 chars
* c – total logical cores
* cr – cores reserved for the dvpn-node container
* r – total RAM (MiB)
* rr – RAM reserved for the dvpn-node container (MiB)
* c – total logical cores on the host
* cr – cores AVAILABLE to the dvpn-node container (Docker/WSL2 VM)
* r – total host RAM (MiB)
* rr – RAM AVAILABLE to the dvpn-node container (Docker/WSL2 VM, MiB)
*
* NOTE on `cr`/`rr` semantics: the node container is launched with no CPU or
* memory cap (see docker.ts HostConfig — no Memory/NanoCpus), so nothing is
* "reserved". On Windows the container runs inside the WSL2 VM, which the
* Docker engine sizes BELOW the host (default RAM = min(50% host, 8 GB)).
* `docker info` NCPU/MemTotal report that VM allocation — the real ceiling a
* container can draw from — which is why `cr`/`rr` mean "available to the
* container", not "reserved for it". On a single-purpose Linux VPS the VM ==
* host, so cr==c and rr==r there. If we ever set real HostConfig limits, these
* become true reservations and the schema should bump to v2.
*/

const MEMO_PREFIX = 'specs:v1:';
Expand Down Expand Up @@ -77,9 +87,14 @@ export async function captureLocalSpecs(): Promise<NodeSpecsSnapshot> {
return {
cpu: truncateCpu(report.cpuModel),
c: report.cpuCores,
// dockerOverview().ncpu is what the Docker daemon advertises as available
// to containers — the closest "reservation" signal we have without
// inspecting the running container directly.
// `cr`/`rr` = resources AVAILABLE to the container, not reserved for it.
// The node container runs with no CPU/memory cap, so its real ceiling is
// whatever the Docker engine exposes. On Windows that's the WSL2 VM
// allocation (docker info NCPU/MemTotal), which sits below the host total
// (default RAM = min(50% host, 8 GB)) — so `rr` < `r` there and the two
// fields carry distinct, meaningful info. Fall back to host totals only if
// the docker info probe failed (engine down), since on a single-purpose
// box the VM == host anyway.
cr: dockerNcpu ?? report.cpuCores,
r: report.memoryMb,
rr: dockerMemMb ?? report.memoryMb,
Expand Down Expand Up @@ -112,16 +127,43 @@ export async function captureRemoteSpecs(creds: SSHCredentials): Promise<NodeSpe
throw new Error('remote /proc/meminfo missing MemTotal');
}
const r = Math.round(parseInt(memTotalKb, 10) / 1024);

// Probe the remote Docker engine for what it actually exposes to
// containers (NCPU / MemTotal), mirroring what local does via
// dockerOverview(). `docker info` emits stable Go-template keys, so we
// ask for exactly the two values and parse them line-by-line. This is
// bail-safe: if Docker isn't reachable over SSH (not installed, perms,
// daemon down) we fall back to host totals — on a single-purpose VPS the
// engine == host, so cr==c / rr==r is the right default there anyway.
let dockerNcpu: number | undefined;
let dockerMemMb: number | undefined;
try {
// {{.NCPU}} = logical CPUs the engine sees; {{.MemTotal}} = bytes.
const info = await sshOne(
client,
'docker info --format "{{.NCPU}}|{{.MemTotal}}"',
);
const [ncpuStr, memBytesStr] = info.trim().split('|');
const ncpu = parseInt(ncpuStr, 10);
const memBytes = parseInt(memBytesStr, 10);
if (Number.isFinite(ncpu) && ncpu > 0) dockerNcpu = ncpu;
if (Number.isFinite(memBytes) && memBytes > 0) {
dockerMemMb = Math.round(memBytes / (1024 * 1024));
}
} catch (err) {
log.debug('remote docker info probe failed during specs capture', {
err: String(err),
});
}

return {
cpu: truncateCpu(cpuModel),
c,
// No remote Docker reservation probe yet — assume the dvpn-node
// container can use the whole host on a single-purpose VPS, which
// is how operators actually deploy. Refine in v2 if reservations
// become a thing on remote hosts.
cr: c,
// `cr`/`rr` = resources AVAILABLE to the container (Docker engine view),
// not reserved. Falls back to host totals when the engine probe fails.
cr: dockerNcpu ?? c,
r,
rr: r,
rr: dockerMemMb ?? r,
};
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/screens/DeployLocal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,8 @@ function OnChainSpecsCard() {
operator address with a <code>specs:v1</code> memo.
</Bullet>
<Bullet>
The memo carries CPU model, total cores, RAM, and the slice reserved
for the dvpn-node container.
The memo carries CPU model, total host cores and RAM, and the cores
and RAM available to the dvpn-node container (Docker/WSL2 VM).
</Bullet>
<Bullet>
Operator-reported &mdash; not consensus-validated. Surfaced in
Expand Down
22 changes: 12 additions & 10 deletions src/renderer/src/screens/ManageDocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -688,11 +688,13 @@ function SystemCard({
// skeleton with placeholder rows so the right column is the same height
// it'll be after data arrives.
if (loaded && !overview?.reachable) return null;
const ramReserved =
// Docker engine pool = resources available to containers (on Windows this is
// the WSL2 VM allocation, below the host total). Not a per-node reservation.
const ramAvailable =
overview?.totalMemoryMb
? `${fmtAmount(overview.totalMemoryMb / 1024, 1)} GB`
: '—';
const coresReserved = overview?.ncpu ? String(overview.ncpu) : '—';
const coresAvailable = overview?.ncpu ? String(overview.ncpu) : '—';
const isLinux = window.api.platform === 'linux';
const onOpenSettings = async () => {
const r = await window.api.docker.openSettings();
Expand All @@ -715,24 +717,24 @@ function SystemCard({
<div className="card-header">
<div className="card-title flex items-center gap-2">
<MIcon name="tune" size={14} />
Resource reservations
Resources available to containers
</div>
</div>
<div className="card-body flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
<Stat
label="RAM reserved"
value={ramReserved}
help="Memory Docker Desktop has set aside for containers. Change this in Docker Desktop → Settings → Resources."
label="RAM available"
value={ramAvailable}
help="Memory the Docker engine makes available to containers (on Windows this is the WSL2 VM allocation, typically below the host total). Change this in Docker Desktop → Settings → Resources."
/>
<Stat
label="Cores reserved"
value={coresReserved}
help="Logical CPU cores Docker Desktop has set aside for containers. Change this in Docker Desktop → Settings → Resources."
label="Cores available"
value={coresAvailable}
help="Logical CPU cores the Docker engine makes available to containers (on Windows this is the WSL2 VM allocation). Change this in Docker Desktop → Settings → Resources."
/>
</div>
<div className="text-[11px] leading-snug" style={{ color: 'var(--text-dim)' }}>
Bigger reservations let one node serve more concurrent users.
A bigger pool lets one node serve more concurrent users.
{isLinux
? ' On Linux, edit /etc/docker/daemon.json and restart the daemon.'
: ' Edit these in Docker Desktop → Settings → Resources.'}
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/screens/NodeDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,7 @@ function SpecsReportingPanel({
value={<Mono>{`${Math.round(specs.r / 1024)} GiB`}</Mono>}
/>
<KV
label="RAM reserved"
label="RAM available"
value={<Mono>{`${Math.round(specs.rr / 1024)} GiB`}</Mono>}
/>
</>
Expand Down
40 changes: 21 additions & 19 deletions src/renderer/src/screens/OnChainSpecs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function OnChainSpecs() {
className="btn btn-secondary"
onClick={() => void loadSystem()}
disabled={refreshing}
title="Re-read CPU, RAM and Docker reservation"
title="Re-read CPU, RAM and Docker resource availability"
>
<MIcon name="refresh" size={14} />
{refreshing ? 'Refreshing…' : 'Refresh'}
Expand Down Expand Up @@ -318,9 +318,9 @@ function ExplainerCard() {
<ExplainerRow icon="memory" text="CPU model (truncated to 64 chars)." />
<ExplainerRow
icon="developer_board"
text="Total logical cores and the cores reserved for the dvpn-node container."
text="Total logical host cores and the cores available to the dvpn-node container (Docker/WSL2 VM)."
/>
<ExplainerRow icon="storage" text="Total RAM (MiB) and the RAM reserved for the container." />
<ExplainerRow icon="storage" text="Total host RAM (MiB) and the RAM available to the container (Docker/WSL2 VM)." />
<ExplainerRow
icon="receipt_long"
text="Detection rule: fromAddress === toAddress + specs:v1: memo prefix."
Expand Down Expand Up @@ -513,20 +513,22 @@ function SpecsSnapshotCard({
const totalCores = Number.isFinite(report.cpuCores) ? report.cpuCores : 0;
const totalRamMb = Number.isFinite(report.memoryMb) ? report.memoryMb : 0;
const dockerOk = !!docker && docker.reachable;
const reservedCores =
// Resources AVAILABLE to the container (Docker engine / WSL2 VM view), not
// reserved — the node container runs uncapped, so this is its real ceiling.
const availCores =
dockerOk && Number.isFinite(docker!.ncpu) && docker!.ncpu! > 0
? docker!.ncpu!
: totalCores;
const reservedRamMb =
const availRamMb =
dockerOk &&
Number.isFinite(docker!.totalMemoryMb) &&
docker!.totalMemoryMb! > 0
? docker!.totalMemoryMb!
: totalRamMb;
const coreRatio = totalCores > 0 ? reservedCores / totalCores : 0;
const ramRatio = totalRamMb > 0 ? reservedRamMb / totalRamMb : 0;
const coreRatio = totalCores > 0 ? availCores / totalCores : 0;
const ramRatio = totalRamMb > 0 ? availRamMb / totalRamMb : 0;
const totalRamGb = totalRamMb / 1024;
const reservedRamGb = reservedRamMb / 1024;
const availRamGb = availRamMb / 1024;
const fmtRam = (mb: number, gb: number) =>
mb > 0 ? `${gb.toFixed(1)} GB` : '—';
return (
Expand Down Expand Up @@ -576,19 +578,19 @@ function SpecsSnapshotCard({
</div>
</div>

{/* Reserved / total meters */}
{/* Available-to-container / host-total meters */}
<div className="flex flex-col gap-2.5">
<SpecsMeter
icon="developer_board"
label="Cores"
reserved={`${reservedCores}`}
available={`${availCores}`}
total={`${totalCores}`}
ratio={coreRatio}
/>
<SpecsMeter
icon="storage"
label="RAM"
reserved={fmtRam(reservedRamMb, reservedRamGb)}
available={fmtRam(availRamMb, availRamGb)}
total={fmtRam(totalRamMb, totalRamGb)}
ratio={ramRatio}
/>
Expand All @@ -601,13 +603,13 @@ function SpecsSnapshotCard({
function SpecsMeter({
icon,
label,
reserved,
available,
total,
ratio,
}: {
icon: string;
label: string;
reserved: string;
available: string;
total: string;
ratio: number;
}) {
Expand All @@ -629,14 +631,14 @@ function SpecsMeter({
className="text-[8.5px] uppercase tracking-[0.12em]"
style={{ color: 'var(--text-dim)' }}
>
Reserved / Total
Available / Total
</span>
<span
className="text-[13.3px] tabular-nums"
style={{ color: 'var(--text-muted)' }}
>
<span className="font-semibold" style={{ color: 'var(--text)' }}>
{reserved}
{available}
</span>
<span className="opacity-60 mx-1.5">/</span>
<span>{total}</span>
Expand Down Expand Up @@ -726,17 +728,17 @@ function SpecsMemoCard({
style={{ color: 'var(--text-muted)' }}
>
<FieldRow k="cpu" v={snapshot.cpu} desc="CPU model (≤ 64 chars)" />
<FieldRow k="c" v={String(snapshot.c)} desc="Total logical cores" />
<FieldRow k="c" v={String(snapshot.c)} desc="Total logical host cores" />
<FieldRow
k="cr"
v={String(snapshot.cr)}
desc="Cores reserved for container"
desc="Cores available to container (Docker/WSL2 VM)"
/>
<FieldRow k="r" v={String(snapshot.r)} desc="Total RAM (MiB)" />
<FieldRow k="r" v={String(snapshot.r)} desc="Total host RAM (MiB)" />
<FieldRow
k="rr"
v={String(snapshot.rr)}
desc="RAM reserved for container (MiB)"
desc="RAM available to container (Docker/WSL2 VM, MiB)"
/>
</div>
</div>
Expand Down
29 changes: 16 additions & 13 deletions src/renderer/src/screens/System.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,12 @@ function DockerLimitsRow({
report: LocalSystemReport;
}) {
const reachable = !!docker && docker.reachable;
const reservedRamMb =
// Docker engine pool = resources available to containers (on Windows this is
// the WSL2 VM allocation, which sits below the host total). Not "reserved" —
// the node container itself runs uncapped.
const availRamMb =
reachable && Number.isFinite(docker!.totalMemoryMb) ? docker!.totalMemoryMb! : 0;
const reservedCores =
const availCores =
reachable && Number.isFinite(docker!.ncpu) ? docker!.ncpu! : 0;
const totalRamMb = Number.isFinite(report.memoryMb) ? report.memoryMb : 0;
const totalCores = Number.isFinite(report.cpuCores) ? report.cpuCores : 0;
Expand All @@ -505,21 +508,21 @@ function DockerLimitsRow({
</div>
<div className="grid grid-cols-2" style={{ gap: 0 }}>
<DockerLimitCell
label="RAM reserved"
reserved={reservedRamMb / 1024}
label="RAM available"
value={availRamMb / 1024}
total={totalRamMb / 1024}
unit="GB"
decimals={1}
help="Memory Docker Desktop has set aside for containers. Edit in Docker Desktop → Settings → Resources."
help="Memory the Docker engine makes available to containers (on Windows this is the WSL2 VM allocation, typically below the host total). Edit in Docker Desktop → Settings → Resources."
dim={!reachable}
/>
<DockerLimitCell
label="Cores reserved"
reserved={reservedCores}
label="Cores available"
value={availCores}
total={totalCores}
unit="cores"
decimals={0}
help="Logical CPU cores Docker Desktop has set aside for containers. Edit in Docker Desktop → Settings → Resources."
help="Logical CPU cores the Docker engine makes available to containers (on Windows this is the WSL2 VM allocation). Edit in Docker Desktop → Settings → Resources."
dim={!reachable}
divider
/>
Expand All @@ -530,7 +533,7 @@ function DockerLimitsRow({

function DockerLimitCell({
label,
reserved,
value,
total,
unit,
decimals,
Expand All @@ -539,7 +542,7 @@ function DockerLimitCell({
divider,
}: {
label: string;
reserved: number;
value: number;
total: number;
unit: string;
decimals: number;
Expand All @@ -550,8 +553,8 @@ function DockerLimitCell({
const fmt = (n: number) =>
Number.isFinite(n) && n > 0 ? n.toFixed(decimals) : '—';
const pct =
total > 0 && reserved > 0
? Math.max(0, Math.min(100, (reserved / total) * 100))
total > 0 && value > 0
? Math.max(0, Math.min(100, (value / total) * 100))
: 0;
return (
<div
Expand All @@ -567,7 +570,7 @@ function DockerLimitCell({
className="text-sm font-semibold tabular-nums"
style={{ color: dim ? 'var(--text-dim)' : 'var(--text)' }}
>
{fmt(reserved)}
{fmt(value)}
<span
className="text-[11px] font-normal"
style={{ color: 'var(--text-dim)' }}
Expand Down
13 changes: 9 additions & 4 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ export type PriceMode = 'flat' | 'oracle';
*
* Compact field names so the JSON memo stays under Cosmos's 256-byte cap.
* cpu – cpu model (truncated to 64 chars)
* c – total logical cores
* cr – cores reserved for the dvpn-node container
* r – total RAM (MiB)
* rr – RAM reserved for the dvpn-node container (MiB)
* c – total logical host cores
* cr – cores available to the dvpn-node container (Docker/WSL2 VM view)
* r – total host RAM (MiB)
* rr – RAM available to the dvpn-node container (Docker/WSL2 VM, MiB)
*
* `cr`/`rr` are "available to the container", not "reserved": the node
* container runs uncapped (no HostConfig Memory/NanoCpus), so these report the
* Docker engine's ceiling (on Windows = the WSL2 VM allocation, which is below
* the host total). See node-specs.ts for the full rationale.
*/
export interface NodeSpecsSnapshot {
cpu: string;
Expand Down
Loading
Loading