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
19 changes: 16 additions & 3 deletions core/core/api/access-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,25 @@ function createAccessHttp({
return true;
}

if (url.pathname === '/api/platform/runtime' && request.method === 'GET') {
if (url.pathname === '/api/platform/runtime' && ['GET', 'POST'].includes(request.method)) {
const current = session(request);
requireNoQuery(url);
access.requirePlatform(current, 'platform.installations.manage');
if (current.user.platformRole !== 'owner' || current.dspView) throw new AccessError('platform_forbidden', 403);
sendJson(response, 200, { ok: true, status: 'found', data: platformRuntime?.()
const validViewer = value => typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value);
let options;
if (request.method === 'POST') {
requireMutation(request, current, url);
const input = await readJson(request); exact(input, ['viewerId', 'action']);
if (!validViewer(input.viewerId) || input.action !== 'close') throw new AccessError('invalid_request', 400);
options = { closeViewer: `${current.user.id}:${input.viewerId}` };
} else {
const viewer = url.searchParams.get('viewer'), refresh = url.searchParams.get('refreshStorage');
if ([...url.searchParams.keys()].some(key => !['viewer', 'refreshStorage'].includes(key))
|| url.searchParams.getAll('viewer').length > 1 || url.searchParams.getAll('refreshStorage').length > 1
|| viewer !== null && !validViewer(viewer) || refresh !== null && (refresh !== '1' || !viewer)) throw new AccessError('invalid_request', 400);
options = { refreshStorage: refresh === '1', viewerKey: viewer ? `${current.user.id}:${viewer}` : null };
}
sendJson(response, 200, { ok: true, status: 'found', data: platformRuntime?.(options)
|| { enabled: false, storageAvailableBytes: null, runtimes: [] }, error: null });
return true;
}
Expand Down
39 changes: 34 additions & 5 deletions core/dashboard/frontend/src/pages/DspResources.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "@/lib/session";
import { request } from "@/lib/api";
import { ErrorNotice, Loading } from "@/components/shared";

Expand Down Expand Up @@ -43,14 +44,42 @@ function Metric({ label, value, children }: { label: string; value: string; chil
</div>;
}
export function DspResources() {
const { session } = useSession();
const [viewerId] = useState(() => crypto.randomUUID());
const [visible, setVisible] = useState(() => !document.hidden);
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 scanOnOpen = useRef(true);
useEffect(() => {
if (!visible) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [visible]);
useEffect(() => {
const leave = () => { void request("/api/platform/runtime", { method: "POST", keepalive: true,
headers: { "X-Dispatch-CSRF": session.csrfToken || "" }, body: JSON.stringify({ viewerId, action: "close" }),
}).catch(() => {}); };
const visibility = () => {
setVisible(!document.hidden);
if (document.hidden) leave(); else scanOnOpen.current = true;
};
document.addEventListener("visibilitychange", visibility);
window.addEventListener("pagehide", leave);
return () => {
document.removeEventListener("visibilitychange", visibility);
window.removeEventListener("pagehide", leave);
leave();
};
}, [viewerId, session.csrfToken]);
const runtime = useQuery({ queryKey: ["platform-runtime"], queryFn: ({ signal }) => {
const refresh = scanOnOpen.current;
scanOnOpen.current = false;
return request<RuntimeView>(`/api/platform/runtime?viewer=${viewerId}${refresh ? "&refreshStorage=1" : ""}`, { signal });
}, enabled: visible, refetchInterval: 2000, refetchOnMount: "always", refetchIntervalInBackground: false });
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>
<div><h2 className="text-lg font-semibold">DSP resources</h2><p className="text-sm text-muted-foreground">CPU and RAM refresh every 2 seconds while this page is visible. Storage and backups are checked when you open it.</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} />
Expand All @@ -60,7 +89,7 @@ export function DspResources() {
<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);
const storageStale = storage?.status === "stale";
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">
Expand Down
30 changes: 25 additions & 5 deletions core/dashboard/tests/browser/diagnostics-resources.spec.cjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const {test,expect}=require('@playwright/test');
const {createPreview}=require('../../examples/independent-updates-preview');
let app,view;
let app,view,opens=0,polls=0,closes=0;
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()})});
app=await createPreview({automatic:false,platformRuntime:(options={})=>{if(options.closeViewer){closes++;return {closed:true};}polls++;if(options.refreshStorage)opens++;return {...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)=>{
Expand All @@ -15,13 +15,27 @@ test('owner sees live DSP resources, backup breakdown and stale storage on deskt
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});
let initialOpens=opens;expect(initialOpens).toBeGreaterThan(0);
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');
await expect(card).toContainText('87.3%',{timeout:8000});await expect(card).toContainText('512.0 MiB');expect(opens).toBe(initialOpens);
const beforeHide=closes;
await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'));});
await expect.poll(()=>closes).toBeGreaterThan(beforeHide);
const paused=polls;await page.waitForTimeout(2500);expect(polls).toBe(paused);
await page.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'));});
await expect.poll(()=>opens).toBe(initialOpens+1);initialOpens++;
view.runtimes[0].storage.sampledAt=Date.now()-24*60*60*1000;
await expect(card).toContainText('1440m ago',{timeout:8000});await expect(card).not.toContainText('Stale measurement');
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}}));
const beforeClose=closes;
await page.goto(`${app.url}/#/platform`);
await expect.poll(()=>closes).toBeGreaterThan(beforeClose);
const stopped=polls;await page.waitForTimeout(2500);expect(polls).toBe(stopped);
await page.goto(`${app.url}/#/diagnostics`);await expect.poll(()=>opens).toBe(initialOpens+1);
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([]);
});

Expand All @@ -32,5 +46,11 @@ test('runtime endpoint rejects DSP members and the owner’s scoped DSP view',as
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);
const headers={Cookie:'dispatch_session='+app.owner.token};
expect((await request.get(endpoint,{headers})).status()).toBe(200);
expect((await request.get(endpoint+'?refreshStorage=1',{headers})).status()).toBe(400);
const viewerId='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
expect((await request.get(endpoint+'?viewer='+viewerId+'&refreshStorage=1&refreshStorage=1',{headers})).status()).toBe(400);
expect((await request.post(endpoint,{headers,data:{viewerId,action:'close'}})).status()).toBe(403);
expect((await request.post(endpoint,{headers:{...headers,'X-Dispatch-CSRF':app.owner.session.csrfToken},data:{viewerId,action:'close'}})).status()).toBe(200);
});
9 changes: 8 additions & 1 deletion core/host/capacity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ RAM, CPU and tasks include the DSP runtime plus isolated plugin and authenticati
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
Storage scans run asynchronously when Diagnostics opens (`refreshStorage=1`).
Normal CPU/RAM polling only reads cached storage; elapsed time never starts a scan.
Concurrent opens share an in-flight scan. Reopening the page starts a fresh scan.
Visible pages renew owner-scoped viewer leases on their two-second polls. Leaving,
hiding or closing the page sends a CSRF-protected close request. Scans stop at the
next metadata checkpoint when the final viewer leaves; lost viewers expire after
seven seconds. CPU/RAM have no background timer, and storage never restarts from
ordinary polls. The browser stops its queries when hidden or unmounted. Scans
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.
Expand Down
20 changes: 14 additions & 6 deletions core/host/capacity/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@ const sum=(values,key)=>values.some(value=>value[key]===null)?null:values.reduce
const zero={memoryBytes:0,memoryLimitBytes:null,tasks:0,cpuPercent:0};
function createDirectoryMonitor({store,manager,paths,execution=null,clock=Date.now,cgroupRoot='/sys/fs/cgroup/system.slice',
sampleResources=createResourceSampler(),readWorkers=workerGroups,storageSampler=createStorageSampler({paths}),disk=()=>fs.statfsSync(paths.dsps)}={}){
let cached=null;
return ()=>{
const now=clock();if(cached&&now-cached.sampledAt<2000)return cached;
let cached=null,storageAvailableBytes=null;
const viewers=new Map();
function hasViewers(){
for(const [key,expires] of viewers)if(expires<=clock())viewers.delete(key);
return viewers.size>0;
}
return ({refreshStorage=false,viewerKey=null,closeViewer=null}={})=>{
if(closeViewer){viewers.delete(closeViewer);return {closed:true};}
if(!hasViewers()&&viewerKey){sampleResources.reset?.();cached=null;}
if(viewerKey)viewers.set(viewerKey,clock()+7000);
const now=clock();if(!refreshStorage&&cached&&now-cached.sampledAt<2000)return cached;
const rows=store.db.prepare(`SELECT i.runtime_key,i.status installation_status,o.name FROM installations i JOIN organizations o ON o.id=i.organization_id
WHERE i.backend='directory_service_v1' AND i.status<>'decommissioned' ORDER BY o.created_at,o.id`).all();
const ids=rows.map(row=>row.runtime_key),workers=readWorkers(paths,ids),storage=storageSampler.read(ids);
const ids=rows.map(row=>row.runtime_key),workers=readWorkers(paths,ids),storage=storageSampler.read(ids,{refresh:refreshStorage,shouldContinue:hasViewers});
const groups=ids.flatMap(id=>[unitName(id),...(workers.groups.get(id)||[])]);
const resources=sampleResources(groups.map(name=>path.join(cgroupRoot,name)));
let storageAvailableBytes=null;try{const value=disk();storageAvailableBytes=value.bavail*value.bsize;}catch{}
cached={enabled:true,sampledAt:now,refreshIntervalMs:2000,storageRefreshIntervalMs:60000,storageAvailableBytes,
if(refreshStorage){try{const value=disk();storageAvailableBytes=value.bavail*value.bsize;}catch{storageAvailableBytes=null;}}
cached={enabled:true,sampledAt:now,refreshIntervalMs:2000,storageRefreshMode:'on_open',storageAvailableBytes,
resourceScope:'DSP runtime, isolated plugin jobs and browser workers. Shared Core services are excluded.',
runtimes:rows.map(row=>{
const id=row.runtime_key,record=manager.journal.record(id),runtime=resources.get(path.join(cgroupRoot,unitName(id)));
Expand Down
4 changes: 3 additions & 1 deletion core/host/capacity/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function readGroup(root){
// Each sampler is shared by all dashboard clients. CPU is a delta, not lifetime CPU time.
function createResourceSampler({read=readGroup,monotonic=()=>performance.now()}={}){
const previous=new Map();
return roots=>{
const sample=roots=>{
const now=monotonic(),values=new Map();
for(const root of new Set(roots)){
const value=read(root),before=previous.get(root);
Expand All @@ -28,6 +28,8 @@ function createResourceSampler({read=readGroup,monotonic=()=>performance.now()}=
for(const root of previous.keys())if(!values.has(root))previous.delete(root);
return values;
};
sample.reset=()=>previous.clear();
return sample;
}
// Use Core's worker registry, never a tenant-supplied list of another DSP's jobs.
function workerGroups(paths,ids){
Expand Down
Loading