Skip to content
Merged
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
2 changes: 1 addition & 1 deletion core/core/api/access-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ function createAccessHttp({
const current = session(request);
requireNoQuery(url);
access.requirePlatform(current, 'platform.installations.manage');
if (current.user.platformRole !== 'owner') throw new AccessError('platform_forbidden', 403);
if (current.user.platformRole !== 'owner' || current.dspView) throw new AccessError('platform_forbidden', 403);
sendJson(response, 200, { ok: true, status: 'found', data: platformRuntime?.()
|| { enabled: false, storageAvailableBytes: null, runtimes: [] }, error: null });
return true;
Expand Down
4 changes: 2 additions & 2 deletions core/dashboard/examples/independent-updates-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { UpdateWorker } = require('../../core/updates/worker');
const { createUpdatesService } = require('../../core/updates/service');
const { hash, inventory } = require('../../shared/releases/package');
const { createDashboardServer } = require('../server/server');
async function createPreview({ port = 0, automatic = true, versionedDashboards = false } = {}) {
async function createPreview({ port = 0, automatic = true, versionedDashboards = false, platformRuntime = null } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-independent-updates-'));
const store = new AccessStore({ databaseRoot: path.join(root, 'access'), database: path.join(root, 'access/control.sqlite3') });
const access = new AccessControlService(store, { installationOperatorEnabled: true, installationBackend: 'directory_service_v1' });
Expand Down Expand Up @@ -80,7 +80,7 @@ async function createPreview({ port = 0, automatic = true, versionedDashboards =
await worker.initialize();
const updates = createUpdatesService({ releases, commands, store, devDspId: dsps[0] });
const unavailable = async () => ({ ok: false, status: 'installation_not_ready', data: null, error: { code: 'installation_not_ready' } });
const server = createDashboardServer({ access, updates, ...(versionedDashboards ? {dashboards:require('../../core/updates/dashboard').dashboardProvider({paths:{local:path.join(root,'local')},store})} : {}), plugins: { catalog: () => ({ items: [] }) },
const server = createDashboardServer({ access, updates, platformRuntime, ...(versionedDashboards ? {dashboards:require('../../core/updates/dashboard').dashboardProvider({paths:{local:path.join(root,'local')},store})} : {}), plugins: { catalog: () => ({ items: [] }) },
client: { workforce: { day: unavailable }, sync: { status: unavailable, runNow: unavailable }, system: { status: unavailable } } });
const original = server.listeners('request')[0]; server.removeAllListeners('request');
server.on('request', async (request, response) => {
Expand Down
33 changes: 5 additions & 28 deletions core/dashboard/frontend/src/pages/Diagnostics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { idempotent, queryClient, request } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { PageHeading, ErrorNotice, Loading, Notice } from "@/components/shared";

import { DspResources } from "./DspResources";

type DiagnosticsView = {
enabled: boolean;
dsps: {
Expand All @@ -23,14 +25,6 @@ export function Diagnostics() {
queryFn: () => request<DiagnosticsView>("/api/platform/diagnostics"),
refetchInterval: 5000,
});
const runtime = useQuery({
queryKey: ["platform-runtime"],
queryFn: () => request<{ enabled: boolean; storageAvailableBytes: number | null;
runtimes: { reference: string; name: string; status: string; memoryBytes: number | null; memoryLimitBytes: number | null; tasks: number | null;
storage: { limited: boolean | null; capacityBytes: number | null; availableBytes: number | null } }[]
}>("/api/platform/runtime"),
refetchInterval: 5000,
});
async function deploy() {
if (busy) return;
setBusy(true);
Expand All @@ -53,27 +47,10 @@ export function Diagnostics() {
<>
<PageHeading
title="Diagnostics"
description="Check runtime health and create test DSPs."
description="Monitor DSP resources and create test DSPs."
/>
<ErrorNotice error={error || data.error || runtime.error} />
{runtime.data?.enabled && (
<section aria-label="Runtime health" className="rounded-xl border bg-card p-6 mb-6 space-y-3">
<h2 className="text-lg font-semibold">Runtime health</h2>
<p className="text-sm text-muted-foreground">
Available storage: {((runtime.data.storageAvailableBytes ?? 0) / 1024 ** 3).toFixed(1)} GiB
</p>
{runtime.data.runtimes.map((item) => (
<div key={item.reference} className="flex flex-wrap justify-between gap-2 border-t pt-3 text-sm">
<span>{item.name}</span>
<span>{item.status} · {item.memoryBytes === null ? "—" : `${Math.round(item.memoryBytes / 1024 ** 2)} MiB`} · {item.tasks ?? 0} tasks
{item.storage?.limited ? ` · ${((item.storage.availableBytes ?? 0) / 1024 ** 3).toFixed(1)} GiB storage free`
: item.storage?.limited === false ? " · Storage limit pending migration" : " · Storage unavailable"}
</span>
</div>
))}
{!runtime.data.runtimes.length && <p className="text-sm text-muted-foreground">No DSP runtimes yet.</p>}
</section>
)}
<ErrorNotice error={error || data.error} />
<DspResources />
{data.isPending ? (
<Loading />
) : data.data ? (
Expand Down
86 changes: 86 additions & 0 deletions core/dashboard/frontend/src/pages/DspResources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { request } from "@/lib/api";
import { ErrorNotice, Loading } from "@/components/shared";

type Storage = {
status: "measuring" | "ready" | "stale" | "unavailable";
sampledAt?: number | null;
refreshing?: boolean;
limited?: boolean;
usedBytes?: number;
capacityBytes?: number | null;
availableBytes?: number | null;
runtimeBytes?: number;
dataBytes?: number;
pluginBytes?: number;
logBytes?: number;
localBackupBytes?: number;
backups?: { available: boolean; count: number; bytes: number; manual: number; updates: number; plugins: number; lastAt: string | null };
};
type RuntimeView = {
enabled: boolean;
sampledAt: number;
storageAvailableBytes: number | null;
runtimes: { reference: string; name: string; status: string; cpuPercent: number | null; memoryBytes: number | null;
tasks: number | null; activeWorkers: number | null; storage: Storage }[];
};
function bytes(value?: number | null) {
if (value == null) return "Unavailable";
if (value < 1024) return `${value} B`;
const power = Math.min(4, Math.floor(Math.log(value) / Math.log(1024)));
return `${(value / 1024 ** power).toFixed(1)} ${["B", "KiB", "MiB", "GiB", "TiB"][power]}`;
}
function age(at: number | null | undefined, now: number) {
if (at == null) return "Waiting for measurement";
const seconds = Math.max(0, Math.floor((now - at) / 1000));
return seconds < 60 ? `${seconds}s ago` : `${Math.floor(seconds / 60)}m ago`;
}
function Metric({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return <div className="min-w-0 space-y-1"><dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="text-xl font-semibold tabular-nums">{value}</dd>
{children && <dd className="text-xs text-muted-foreground space-y-1">{children}</dd>}
</div>;
}
export function DspResources() {
const [now, setNow] = useState(Date.now);
useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []);
const runtime = useQuery({ queryKey: ["platform-runtime"], queryFn: () => request<RuntimeView>("/api/platform/runtime"), refetchInterval: 2000 });
const data = runtime.data;
const stale = Boolean(runtime.error || (data && now - data.sampledAt > 10000));
return <section aria-label="DSP resources" className="space-y-4 mb-8">
<div className="flex flex-wrap justify-between items-start gap-3">
<div><h2 className="text-lg font-semibold">DSP resources</h2><p className="text-sm text-muted-foreground">CPU and RAM refresh every 2 seconds. Storage and backups refresh every minute.</p></div>
<p role="status" className="flex items-center gap-2 text-sm"><span aria-hidden="true" className={`h-2 w-2 rounded-full ${stale ? "bg-amber-400" : data?.enabled ? "bg-emerald-400" : "bg-muted-foreground"}`} />{stale ? "Live updates interrupted" : data?.enabled ? "Live" : "Connecting"}</p>
</div>
<ErrorNotice error={runtime.error} />
{runtime.isPending && <Loading />}
{data?.enabled === false && <p className="text-sm text-muted-foreground">Resource monitoring is unavailable on this installation.</p>}
{data?.enabled && <>
<p className="text-xs text-muted-foreground">Measured {age(data.sampledAt, now)} · Host storage available: {bytes(data.storageAvailableBytes)}{stale ? " · Showing last received measurements" : ""}</p>
{data.runtimes.map(item => {
const storage = item.storage, backup = storage?.backups;
const storageStale = storage?.status === "stale" || (storage?.sampledAt != null && now - storage.sampledAt > 120000);
return <article key={item.reference} aria-label={`${item.name} resources`} className="rounded-xl border bg-card p-5 space-y-5">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-semibold break-words min-w-0">{item.name}</h3><span className="text-xs rounded-full bg-muted px-2.5 py-1 capitalize">{item.status}</span></div>
<dl className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
<Metric label="CPU" value={item.cpuPercent == null ? "Measuring / unavailable" : `${item.cpuPercent.toFixed(1)}%`}><p>100% equals one CPU core</p></Metric>
<Metric label="RAM" value={bytes(item.memoryBytes)}><p>{item.tasks ?? "—"} tasks · {item.activeWorkers ?? "—"} plugin / browser workers</p></Metric>
<Metric label="DSP storage" value={bytes(storage?.usedBytes)}>
{storage?.limited && <p>{bytes(storage.capacityBytes)} capacity · {bytes(storage.availableBytes)} free</p>}
<p>Data {bytes(storage?.dataBytes)} · Plugins {bytes(storage?.pluginBytes)}</p>
<p>Logs {bytes(storage?.logBytes)} · Local backups {bytes(storage?.localBackupBytes)}</p>
<p>Runtime code: {bytes(storage?.runtimeBytes)} separately</p>
</Metric>
<Metric label="Backups" value={backup?.available ? String(backup.count) : "Unavailable"}>
{backup?.available && <><p>{bytes(backup.bytes)} of backup data</p><p>{backup.manual} manual · {backup.updates} update · {backup.plugins} plugin rollback</p><p>{backup.lastAt ? `Latest: ${new Date(backup.lastAt).toLocaleString()}` : "No completed backups"}</p></>}
</Metric>
</dl>
<p className="border-t pt-3 text-xs text-muted-foreground">Storage and backups: {storage?.status === "measuring" ? "Measuring…" : storage?.status === "unavailable" ? "Measurement unavailable" : `${age(storage?.sampledAt, now)}${storageStale ? " · Stale measurement" : ""}${storage?.refreshing ? " · Refreshing…" : ""}`}</p>
</article>;
})}
{!data.runtimes.length && <p className="text-sm text-muted-foreground">No DSP runtimes yet.</p>}
<p className="text-xs text-muted-foreground">CPU and RAM include each DSP’s runtime and isolated plugin and browser workers. Shared Core services and centrally stored dashboards are excluded. Backup totals include manual backups, update snapshots and plugin rollback copies; these can overlap local storage usage.</p>
</>}
</section>;
}
2 changes: 1 addition & 1 deletion core/dashboard/playwright.updates.config.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/browser', testMatch: ['independent-updates.spec.cjs', 'update-progress.spec.cjs', 'updates-workspace.spec.cjs', 'dashboard-rollout.spec.cjs'],
testDir: './tests/browser', testMatch: ['diagnostics-resources.spec.cjs', 'independent-updates.spec.cjs', 'update-progress.spec.cjs', 'updates-workspace.spec.cjs', 'dashboard-rollout.spec.cjs'],
workers: 1, timeout: 60000,
outputDir: process.env.DISPATCH_UI_ARTIFACTS || '/tmp/dispatch-updates-browser',
use: { viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce',
Expand Down
36 changes: 36 additions & 0 deletions core/dashboard/tests/browser/diagnostics-resources.spec.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const {test,expect}=require('@playwright/test');
const {createPreview}=require('../../examples/independent-updates-preview');
let app,view;
test.beforeAll(async()=>{
view={enabled:true,storageAvailableBytes:100*1024**3,runtimes:[{reference:'synthetic-dev',name:'Dev DSP',status:'connected',cpuPercent:12.5,memoryBytes:256*1024**2,tasks:12,activeWorkers:1,
storage:{status:'ready',sampledAt:Date.now(),limited:true,usedBytes:2*1024**3,capacityBytes:10*1024**3,availableBytes:8*1024**3,runtimeBytes:20*1024**2,dataBytes:1024**3,pluginBytes:40*1024**2,logBytes:1024,localBackupBytes:1024**3,
backups:{available:true,count:6,bytes:1024**3,manual:2,updates:3,plugins:1,lastAt:'2026-01-01T00:00:00Z'}}}]};
app=await createPreview({automatic:false,platformRuntime:()=>({...view,sampledAt:Date.now()})});
});
test.afterAll(async()=>{await app?.close();});
test('owner sees live DSP resources, backup breakdown and stale storage on desktop and mobile',async({page},info)=>{
const errors=[];page.on('pageerror',error=>errors.push(error.message));page.on('console',message=>{if(message.type()==='error')errors.push(message.text());});
await page.goto(`${app.url}/#/diagnostics`);await page.getByLabel('Email address').fill('platform@example.test');await page.getByLabel('Password',{exact:true}).fill('synthetic preview password');await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.locator('.desktop-sidebar').getByRole('link',{name:'Diagnostics',exact:true}).click();
const card=page.getByRole('article',{name:'Dev DSP resources'});
await expect(card).toContainText('12.5%');await expect(card).toContainText('256.0 MiB');await expect(card).toContainText('2 manual · 3 update · 1 plugin rollback');
await page.screenshot({path:info.outputPath('resources-desktop.png'),fullPage:true});
view.runtimes[0].cpuPercent=87.3;view.runtimes[0].memoryBytes=512*1024**2;
await expect(card).toContainText('87.3%',{timeout:8000});await expect(card).toContainText('512.0 MiB');
view.runtimes[0].storage.status='stale';view.runtimes[0].cpuPercent=null;view.runtimes[0].memoryBytes=null;
await expect(card).toContainText('Stale measurement',{timeout:8000});await expect(card).toContainText('Measuring / unavailable');await expect(card).toContainText('Unavailable');
await page.setViewportSize({width:390,height:844});await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
await page.screenshot({path:info.outputPath('resources-mobile.png'),fullPage:true});
await page.route('**/api/platform/runtime',route=>route.fulfill({json:{ok:true,status:'found',data:{...view,sampledAt:Date.now()-20000},error:null}}));
await expect(page.getByRole('status').filter({hasText:'Live updates interrupted'})).toBeVisible({timeout:8000});expect(errors).toEqual([]);
});

test('runtime endpoint rejects DSP members and the owner’s scoped DSP view',async({request})=>{
const endpoint=app.url+'/api/platform/runtime';
expect((await request.get(endpoint)).status()).toBe(401);
expect((await request.get(endpoint,{headers:{Cookie:'dispatch_session='+app.owners[0].token}})).status()).toBe(403);
const organization=app.access.platformOrganizations(app.owner.session)[0];
const scoped=app.access.beginDspView(app.owner.session,{controlRef:organization.controlRef});
expect((await request.get(endpoint,{headers:{Cookie:'dispatch_session='+app.owner.token,'X-Dispatch-DSP-View':scoped.dspView.viewRef}})).status()).toBe(403);
expect((await request.get(endpoint,{headers:{Cookie:'dispatch_session='+app.owner.token}})).status()).toBe(200);
});
20 changes: 20 additions & 0 deletions core/host/capacity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# DSP resource diagnostics

The owner-only `/api/platform/runtime` endpoint caches one fleet sample for two
seconds across dashboard clients. CPU is a monotonic cgroup CPU-time delta:
100% means one logical CPU core. The first sample after startup or a cgroup
restart has no CPU delta. Missing counters remain unavailable.

RAM, CPU and tasks include the DSP runtime plus isolated plugin and authentication
browser workers identified by Core's registries. Shared Core processes and shared
dashboard assets are excluded; worker memory is not compared to the runtime limit.

Storage scans run asynchronously at most once per minute while requested. They
read file metadata and completed backup manifests, skip symlinks, and have a shared
entry/time budget. Managed volume usage comes from statfs; legacy layouts use
allocated file bytes. Runtime code outside the volume is reported separately.
Backup counts distinguish manual backups, DSP update snapshots and plugin rollback
copies. Backup bytes represent logical backup data, not additional volume usage
or a restore-integrity guarantee. Shared platform backup overhead is excluded.
Failed scans retain timestamped stale values, or report unavailable without a
prior successful sample. No host paths or runtime identifiers enter the response.
Loading