Files
coolify/resources/js/environment-canvas.test.js
T

438 lines
20 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
CARD_WIDTH,
MAX_ROWS,
COLUMN_GAP,
ROW_GAP,
ZONE_HEADER,
ZONE_PADDING,
canConnect,
canLink,
dragRules,
filterGraph,
groupByUse,
filterOptions,
groupFrames,
layoutBlock,
estimateNodeHeight,
layoutGraph,
layoutServerView,
linkPath,
statusLabel,
statusTone,
zoneBoxes,
readView,
} from './environment-canvas.js';
import { freeSpot } from './canvas-core.js';
const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], volumes: [], ...extra });
const graph = (nodes, extra = {}) => ({
zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }],
nodes,
links: [],
positions: {},
...extra,
});
const column = n => n * (CARD_WIDTH + COLUMN_GAP);
test('places applications, services, and databases in their own columns', () => {
const p = layoutGraph(graph([node('db', 'database', 's1'), node('app', 'application', 's1'), node('svc', 'service', 's1')]));
assert.equal(p.app.x, column(0));
assert.equal(p.svc.x, column(1));
assert.equal(p.db.x, column(2));
const top = ZONE_HEADER + ZONE_PADDING;
assert.deepEqual([p.app.y, p.svc.y, p.db.y], [top, top, top]);
});
test('stacks rows of the same type inside a column', () => {
const first = node('a1', 'application', 's1', { urls: ['https://a.test'] });
const p = layoutGraph(graph([first, node('a2', 'application', 's1')]));
assert.equal(p.a2.x, p.a1.x);
assert.equal(p.a2.y, p.a1.y + estimateNodeHeight(first) + ROW_GAP);
});
test('wraps a long list into another column and shifts later types right', () => {
const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`a${i}`, 'application', 's1'));
const p = layoutGraph(graph([...apps, node('db', 'database', 's1')]));
assert.equal(p[`a${MAX_ROWS - 1}`].x, column(0));
assert.deepEqual(p[`a${MAX_ROWS}`], { x: column(1), y: ZONE_HEADER + ZONE_PADDING });
assert.equal(p.db.x, column(2));
});
test('stacks zones in graph order with the "No server" zone last', () => {
const p = layoutGraph(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]));
assert.ok(p.a.y < p.b.y, 'first server zone on top');
assert.ok(p.b.y < p.orphan.y, 'no-server zone at the bottom');
const boxes = zoneBoxes(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]), p);
assert.deepEqual(boxes.map(b => b.name), ['One', 'Two', 'No server']);
for (let i = 1; i < boxes.length; i++) assert.ok(boxes[i].y > boxes[i - 1].y + boxes[i - 1].height, 'zones do not overlap');
});
test('treats a zone id that is not in the zone list as no server', () => {
const g = graph([node('x', 'database', 'gone')]);
assert.deepEqual(zoneBoxes(g, layoutGraph(g)).map(b => b.id), [null]);
});
test('stored positions win over auto placement', () => {
const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: { app: { x: 500, y: -40 } } }));
assert.deepEqual(p.app, { x: 500, y: -40 });
});
test('treats an empty positions list from PHP as nothing stored', () => {
const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: [] }));
assert.equal(p.app.x, 0);
});
test('places new nodes in a wrapped grid below the stored cards of their zone', () => {
const stored = node('a1', 'application', 's1');
const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`n${i}`, 'application', 's1'));
const p = layoutGraph(graph([stored, ...apps, node('d1', 'database', 's1')], { positions: { a1: { x: 100, y: 200 } } }));
const top = 200 + estimateNodeHeight(stored) + ROW_GAP;
assert.deepEqual(p.n0, { x: 100, y: top });
assert.deepEqual(p[`n${MAX_ROWS}`], { x: 100 + column(1), y: top }, 'wraps after MAX_ROWS cards');
assert.deepEqual(p.d1, { x: 100 + column(2), y: top }, 'databases go right of the application columns');
});
test('auto-placed zones go below zones with stored cards', () => {
const p = layoutGraph(graph([node('a', 'application', 's1'), node('b', 'application', 's2')], { positions: { a: { x: 0, y: 1000 } } }));
assert.ok(p.b.y > 1000 + estimateNodeHeight(node('a', 'application', 's1')) + ZONE_PADDING + ZONE_HEADER);
});
test('estimates taller cards for urls, chips, and children', () => {
const plain = estimateNodeHeight(node('a', 'service', 's1'));
const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], volumes: ['v'], children: [{ name: 'c' }, { name: 'd' }] }));
assert.ok(rich > plain);
const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) }));
const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) }));
assert.equal(capped, six, 'children beyond the cap add one "+N more" row');
});
test('zone boxes wrap their cards with padding and header', () => {
const g = graph([node('a', 'application', 's1'), node('d', 'database', 's1')]);
const positions = { a: { x: 0, y: 0 }, d: { x: 400, y: 50 } };
const sizes = { a: { width: 260, height: 100 }, d: { width: 260, height: 80 } };
const [box] = zoneBoxes(g, positions, sizes);
assert.deepEqual(
{ id: box.id, x: box.x, y: box.y, width: box.width, height: box.height, nodes: box.nodes },
{ id: 's1', x: -ZONE_PADDING, y: -ZONE_PADDING - ZONE_HEADER, width: 660 + ZONE_PADDING * 2, height: 130 + ZONE_PADDING * 2 + ZONE_HEADER, nodes: ['a', 'd'] },
);
});
test('zone boxes skip zones without cards', () => {
assert.deepEqual(zoneBoxes(graph([node('a', 'application', 's2')]), { a: { x: 0, y: 0 } }).map(b => b.id), ['s2']);
});
const rect = (x, y) => ({ x, y, width: 260, height: 100 });
test('links go from the right edge to the left edge when the target is on the right', () => {
const path = linkPath(rect(0, 0), rect(400, 200));
assert.equal(path.side, 'right');
assert.deepEqual(path.from, { x: 260, y: 50 });
assert.deepEqual(path.to, { x: 400, y: 250 });
assert.match(path.d, /^M 260 50 C /);
});
test('links go from the left edge to the right edge when the target is on the left', () => {
const path = linkPath(rect(400, 0), rect(0, 0));
assert.equal(path.side, 'left');
assert.deepEqual(path.from, { x: 400, y: 50 });
assert.deepEqual(path.to, { x: 260, y: 50 });
});
test('links run vertically between stacked cards', () => {
const down = linkPath(rect(0, 0), rect(40, 300));
assert.equal(down.side, 'down');
assert.deepEqual([down.from, down.to], [{ x: 130, y: 100 }, { x: 170, y: 300 }]);
const up = linkPath(rect(0, 300), rect(0, 0));
assert.equal(up.side, 'up');
assert.deepEqual([up.from, up.to], [{ x: 130, y: 300 }, { x: 130, y: 100 }]);
});
test('link midpoint sits between both ends', () => {
const { mid } = linkPath(rect(0, 0), rect(600, 0));
assert.deepEqual(mid, { x: 430, y: 50 });
});
test('maps Coolify statuses to tones', () => {
const cases = {
'running:healthy': 'ok',
'running:unknown': 'ok',
running: 'ok',
'running (healthy)': 'ok',
'running:unhealthy': 'warn',
'degraded:unhealthy': 'warn',
'exited:unhealthy': 'bad',
exited: 'bad',
stopped: 'bad',
restarting: 'busy',
'starting:unknown': 'busy',
paused: 'idle',
'': 'idle',
null: 'idle',
};
for (const [status, tone] of Object.entries(cases)) {
assert.equal(statusTone(status === 'null' ? null : status), tone, status);
}
});
test('formats status labels', () => {
assert.equal(statusLabel('running:healthy'), 'Running (healthy)');
assert.equal(statusLabel('exited'), 'Exited');
assert.equal(statusLabel(''), 'Unknown');
});
test('refuses self links and duplicates in either direction', () => {
const links = [{ id: 'l1', source: 'a', target: 'b', label: null }];
assert.equal(canLink(links, 'a', 'a'), false);
assert.equal(canLink(links, 'a', 'b'), false);
assert.equal(canLink(links, 'b', 'a'), false);
assert.equal(canLink(links, 'a', 'c'), true);
});
test('a server view card has the same height as on the environment canvas', () => {
const card = { id: 'a', type: 'application' };
// The group frame names the project / environment, so the card has no extra line for it.
assert.equal(estimateNodeHeight({ ...card, context: 'Acme / production' }), estimateNodeHeight(card));
});
// ── Server view ──
const srv = (id, type, context, extra = {}) => ({ id, type, name: id, zone: 'z1', context, ...extra });
test('server view: a chain of linked cards runs left to right, source before target', () => {
const nodes = [srv('db', 'database', 'A / prod'), srv('web', 'application', 'A / prod'), srv('api', 'application', 'A / prod')];
const { positions } = layoutBlock(nodes, [{ source: 'web', target: 'api' }, { source: 'api', target: 'db' }]);
assert.equal(positions.web.y, positions.api.y);
assert.equal(positions.api.y, positions.db.y);
assert.ok(positions.web.x < positions.api.x && positions.api.x < positions.db.x);
assert.equal(positions.api.x - positions.web.x, CARD_WIDTH + COLUMN_GAP);
});
test('server view: cards without links go below the chains, in a grid', () => {
const nodes = [srv('a', 'application', 'g'), srv('b', 'application', 'g'), srv('c', 'service', 'g'), srv('d', 'database', 'g'), srv('e', 'database', 'g')];
const { positions } = layoutBlock(nodes, [{ source: 'a', target: 'd' }]);
assert.equal(positions.a.y, positions.d.y); // linked pair side by side
for (const id of ['b', 'c', 'e']) assert.ok(positions[id].y > positions.a.y, `${id} is below the chain`);
assert.equal(positions.b.y, positions.c.y); // one grid row
});
test('server view: a link cycle still gets every card a position', () => {
const nodes = [srv('x', 'application', 'g'), srv('y', 'application', 'g')];
const { positions } = layoutBlock(nodes, [{ source: 'x', target: 'y' }, { source: 'y', target: 'x' }]);
assert.ok(positions.x && positions.y);
assert.notDeepEqual(positions.x, positions.y);
});
test('server view: one frame per project / environment, and the frames do not overlap', () => {
const graph = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')],
links: [{ id: 'l', source: 'a', target: 'b' }],
positions: {},
};
const positions = layoutGraph(graph);
const frames = groupFrames(graph.nodes, positions);
assert.deepEqual(frames.map(f => f.name), ['Blog / prod', 'Shop / prod']);
const [f1, f2] = frames;
assert.ok(f1.x + f1.width <= f2.x || f2.x + f2.width <= f1.x || f1.y + f1.height <= f2.y || f2.y + f2.height <= f1.y);
assert.equal(positions.a.y, positions.b.y);
});
test('server view: a group keeps the arrangement saved on its environment canvas', () => {
const graph = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'service', 'Shop / prod')],
links: [],
positions: { a: { x: 900, y: 500 }, b: { x: 600, y: 800 } },
};
const positions = layoutGraph(graph);
assert.equal(positions.a.x - positions.b.x, 300);
assert.equal(positions.b.y - positions.a.y, 300);
assert.ok(positions.c.y >= positions.b.y + estimateNodeHeight(graph.nodes[1]), 'a card without a saved position goes below');
});
const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra });
test('server view: cards link only to cards of the same environment', () => {
const g = {
layout: 'server',
nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z2', 'prod'), envNode('c', 'application', 'z1', 'dev'), { id: 'container:1', type: 'container', zone: 'z1' }],
};
assert.equal(canConnect(g, 'a', 'b'), true);
assert.equal(canConnect(g, 'a', 'c'), false);
assert.equal(canConnect(g, 'a', 'a'), false);
assert.equal(canConnect(g, 'a', 'container:1'), false);
assert.equal(canConnect(g, 'container:1', 'a'), false);
});
test('server view: a moved group saved on its environment canvas keeps its arrangement', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }, { id: 'z2', name: 'db' }],
nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod'), envNode('c', 'service', 'z2', 'prod'), envNode('d', 'application', 'z1', 'dev')],
links: [{ id: 'l', source: 'a', target: 'b' }],
positions: {},
};
const offsets = {};
const positions = layoutServerView(g, offsets);
// The user drags b 100px down; the canvas saves its group (a, b) in environment canvas coordinates.
positions.b = { x: positions.b.x, y: positions.b.y + 100 };
const saved = Object.fromEntries(['a', 'b'].map(id => [id, { x: positions[id].x - offsets[id].x, y: positions[id].y - offsets[id].y }]));
const again = layoutServerView({ ...g, positions: saved });
assert.deepEqual(again.a, positions.a);
assert.deepEqual(again.b, positions.b);
// On the environment canvas the saved cards do not cover the card of the same environment on the other server.
const environment = layoutGraph({ zones: g.zones, nodes: g.nodes.filter(n => n.environment.uuid === 'prod'), positions: saved });
const bottom = Math.max(...['a', 'b'].map(id => environment[id].y + estimateNodeHeight(g.nodes.find(n => n.id === id))));
assert.ok(environment.c.y > bottom);
});
test('variables view: variables stack on the left by name, resources sit to their right', () => {
const g = {
layout: 'connect',
zones: [{ id: 'sources', name: 'Team variables', icon: 'variable' }, { id: 'resources', name: 'Resources', icon: 'service' }],
nodes: [
node('variable:2', 'variable', 'sources', { name: 'TOKEN' }),
node('variable:1', 'variable', 'sources', { name: 'API_URL' }),
node('app', 'application', 'resources', { context: 'Shop / prod' }),
],
links: [{ id: 'variable:1|app', source: 'variable:1', target: 'app' }],
positions: {},
};
const p = layoutGraph(g);
assert.equal(p['variable:1'].x, 0);
assert.equal(p['variable:2'].x, 0);
assert.ok(p['variable:1'].y < p['variable:2'].y);
assert.ok(p.app.x > CARD_WIDTH + ZONE_PADDING * 2);
const [variables, resources] = zoneBoxes(g, p);
assert.ok(variables.x + variables.width < resources.x, 'zones do not overlap');
});
test('variables view: links only go from an existing shared variable to a resource', () => {
const g = {
layout: 'connect',
nodes: [
node('variable:1', 'variable', 'sources'),
node('variable:2', 'variable', 'sources'),
node('missing:OLD', 'variable', 'sources', { missing: true }),
node('app', 'application', 'resources'),
],
};
assert.equal(canConnect(g, 'variable:1', 'app'), true);
assert.equal(canConnect(g, 'app', 'variable:1'), false);
assert.equal(canConnect(g, 'variable:1', 'variable:2'), false);
assert.equal(canConnect(g, 'missing:OLD', 'app'), false);
assert.equal(canConnect({ nodes: g.nodes }, 'app', 'variable:1'), true, 'other canvases link any two cards');
});
test('variables view: filter resources by project and environment, keep every variable', () => {
const shop = { uuid: 'p1', name: 'Shop' };
const blog = { uuid: 'p2', name: 'Blog' };
const g = {
layout: 'connect',
nodes: [
node('variable:1', 'variable', 'sources'),
node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }),
node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }),
node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }),
],
links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }],
};
assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]);
assert.equal(filterGraph(g, {}), g);
const shopOnly = filterGraph(g, { project: 'p1' });
assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']);
assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']);
assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']);
assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []);
});
test('variables view: resources that use a variable get their own zone, in one column', () => {
const ctx = { context: 'Shop / prod' };
const g = groupByUse({
layout: 'connect',
zones: [{ id: 'sources', icon: 'variable' }, { id: 'connected', icon: 'link' }, { id: 'resources', icon: 'service' }],
nodes: [
node('variable:1', 'variable', 'sources'),
node('app', 'application', 'resources', ctx),
node('db', 'database', 'resources', ctx),
node('idle', 'application', 'resources', ctx),
],
links: [{ id: 'l1', source: 'variable:1', target: 'app' }, { id: 'l2', source: 'variable:1', target: 'db' }],
});
assert.deepEqual(g.nodes.map(n => n.zone), ['sources', 'connected', 'connected', 'resources']);
const p = layoutGraph(g);
assert.equal(p.app.x, p.db.x, 'connected cards share one column, so no link runs behind a card');
assert.ok(p.db.y > p.app.y);
const boxes = zoneBoxes(g, p);
const connected = boxes.find(b => b.id === 'connected');
const others = boxes.find(b => b.id === 'resources');
assert.ok(connected.y + connected.height < others.y, 'other resources sit below the connected zone');
});
test('readView restores a saved zoom and pan, and ignores bad data', () => {
const store = values => ({ getItem: key => values[key] ?? null });
assert.deepEqual(readView('k', store({ k: '{"x":-120.5,"y":40,"z":0.6}' })), { x: -120.5, y: 40, z: 0.6 });
assert.equal(readView('k', store({})), null);
assert.equal(readView('k', store({ k: 'not json' })), null);
assert.equal(readView('k', store({ k: '{"x":1,"y":2}' })), null);
assert.equal(readView('k', store({ k: '{"x":1,"y":2,"z":9}' })), null);
});
test('server view: the unmanaged containers group comes first in its zone', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [
node('a', 'application', 'z1', { context: 'Alpha / prod' }),
node('container:1', 'container', 'z1', { context: 'Unmanaged containers' }),
],
links: [],
positions: {},
};
const positions = layoutServerView(g);
assert.ok(positions['container:1'].x < positions.a.x);
});
const apart = (a, b) => a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y;
// Drag card `id` to the top-left of `onto`, as the canvas does: stop at the nearest free spot.
const dragOnto = (g, positions, id, onto) => {
const { others, fits } = dragRules(g, positions, {}, id);
const card = g.nodes.find(n => n.id === id);
const start = { ...positions[id], w: CARD_WIDTH, h: estimateNodeHeight(card) };
assert.deepEqual(freeSpot(start, others, undefined, fits), start, 'the laid out card is already free, so it does not jump');
const spot = freeSpot({ ...start, ...positions[onto] }, others, undefined, fits);
return { ...positions, [id]: { x: spot.x, y: spot.y } };
};
test('a dragged card stops before it covers a card or another zone', () => {
const g = graph([node('a', 'application', 's1'), node('b', 'database', 's1'), node('c', 'application', 's2')]);
const before = layoutGraph(g);
for (const [id, onto] of [['a', 'b'], ['a', 'c'], ['c', 'a']]) {
const positions = dragOnto(g, before, id, onto);
const rects = Object.fromEntries(g.nodes.map(n => [n.id, ({ ...positions[n.id], width: CARD_WIDTH, height: estimateNodeHeight(n) })]));
for (const n of g.nodes) if (n.id !== id) assert.ok(apart(rects[id], rects[n.id]), `${id} does not cover ${n.id}`);
const [z1, z2] = zoneBoxes(g, positions);
assert.ok(apart(z1, z2), 'the zones do not overlap');
}
});
test('server view: a dragged card stops before its group covers another group', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')],
links: [],
positions: {},
};
const positions = dragOnto(g, layoutGraph(g), 'a', 'c');
const [f1, f2] = groupFrames(g.nodes, positions);
assert.ok(apart(f1, f2), 'the project / environment frames do not overlap');
});