feat(FN-3112): tokenize NodesView border-radius and motion styles
Fixes FN-3112: Updates NodesView CSS to use tokenized border-radius and transition values from the design system instead of hardcoded pixel values, ensuring consistency with the dashboard's established token variables. Fusion-Task-Id: FN-3112
This commit is contained in:
@@ -9,6 +9,8 @@ const service = {
|
||||
listContexts: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
recreateContainer: vi.fn(),
|
||||
getContainerInfo: vi.fn(),
|
||||
getContainerLogs: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCentralInstance = {
|
||||
@@ -99,6 +101,98 @@ describe("registerDockerNodeRoutes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).available).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /api/docker/nodes returns enriched list", async () => {
|
||||
mockCentralInstance.listManagedDockerNodes = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "mdn-1",
|
||||
nodeId: "node-1",
|
||||
name: "Docker One",
|
||||
status: "running",
|
||||
hostConfig: {},
|
||||
envVars: {},
|
||||
imageName: "runfusion/fusion",
|
||||
imageTag: "latest",
|
||||
volumeMounts: [],
|
||||
persistentStorage: true,
|
||||
resourceSizing: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
mockCentralInstance.getNode.mockResolvedValue({ id: "node-1", name: "Linked", type: "remote", status: "online", maxConcurrent: 2, createdAt: "x", updatedAt: "x" });
|
||||
const res = await request(app(), "GET", "/api/docker/nodes");
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any)[0].linkedNode.id).toBe("node-1");
|
||||
});
|
||||
|
||||
it("GET /api/docker/nodes/:id/container-status handles success and errors", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||
service.getContainerInfo.mockResolvedValueOnce({ state: { running: true }, status: "running" });
|
||||
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
|
||||
expect(ok.status).toBe(200);
|
||||
expect((ok.body as any).status).toBe("running");
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
|
||||
const notFound = await request(app(), "GET", "/api/docker/nodes/missing/container-status");
|
||||
expect(notFound.status).toBe(404);
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "creating", hostConfig: {}, containerId: null });
|
||||
const bad = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||
service.getContainerInfo.mockRejectedValueOnce(new Error("down"));
|
||||
const unavailable = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
|
||||
expect(unavailable.status).toBe(503);
|
||||
});
|
||||
|
||||
it("GET /api/docker/nodes/:id/logs clamps tail and handles errors", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||
service.getContainerLogs.mockResolvedValueOnce("hello");
|
||||
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs?tail=5000");
|
||||
expect(ok.status).toBe(200);
|
||||
expect(service.getContainerLogs).toHaveBeenCalledWith("c1", {}, { tail: 1000 });
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
|
||||
const notFound = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
|
||||
expect(notFound.status).toBe(404);
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "creating", hostConfig: {}, containerId: null });
|
||||
const noContainer = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
|
||||
expect(noContainer.status).toBe(400);
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||
service.getContainerLogs.mockRejectedValueOnce(new Error("down"));
|
||||
const bad = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
|
||||
expect(bad.status).toBe(503);
|
||||
});
|
||||
|
||||
it("GET /api/docker/nodes/:id returns enriched node and 404", async () => {
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({
|
||||
id: "mdn-1",
|
||||
nodeId: "node-1",
|
||||
name: "Docker One",
|
||||
status: "running",
|
||||
hostConfig: {},
|
||||
envVars: {},
|
||||
imageName: "runfusion/fusion",
|
||||
imageTag: "latest",
|
||||
volumeMounts: [],
|
||||
persistentStorage: true,
|
||||
resourceSizing: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
mockCentralInstance.getNode.mockResolvedValueOnce({ id: "node-1", name: "Linked", type: "remote", status: "online", maxConcurrent: 2, createdAt: "x", updatedAt: "x" });
|
||||
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1");
|
||||
expect(ok.status).toBe(200);
|
||||
expect((ok.body as any).linkedNode.id).toBe("node-1");
|
||||
|
||||
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
|
||||
const missing = await request(app(), "GET", "/api/docker/nodes/missing");
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mesh config routes", () => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { DockerExtraCli, DockerHostConfig, DockerVolumeMount, ManagedDockerNodeInput, FullProvisioningInput } from "@fusion/core";
|
||||
import type {
|
||||
DockerExtraCli,
|
||||
DockerHostConfig,
|
||||
DockerVolumeMount,
|
||||
FullProvisioningInput,
|
||||
ManagedDockerNode,
|
||||
ManagedDockerNodeInput,
|
||||
NodeConfig,
|
||||
} from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
@@ -55,6 +63,33 @@ function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
|
||||
return input as DockerExtraCli[];
|
||||
}
|
||||
|
||||
function toManagedDockerNodeInfo(managedNode: ManagedDockerNode, linkedNode?: NodeConfig) {
|
||||
return {
|
||||
...managedNode,
|
||||
hostConfig: {
|
||||
type: managedNode.hostConfig.host || managedNode.hostConfig.context ? "remote" : "local",
|
||||
host: managedNode.hostConfig.host,
|
||||
context: managedNode.hostConfig.context,
|
||||
tlsOptions: {
|
||||
tlsVerify: managedNode.hostConfig.tlsVerify,
|
||||
tlsCaPath: managedNode.hostConfig.tlsCaPath,
|
||||
tlsCertPath: managedNode.hostConfig.tlsCertPath,
|
||||
tlsKeyPath: managedNode.hostConfig.tlsKeyPath,
|
||||
},
|
||||
},
|
||||
volumeMounts: managedNode.volumeMounts.map((mount) => ({
|
||||
hostPath: mount.hostPath,
|
||||
containerPath: mount.containerPath,
|
||||
readOnly: mount.mode === "ro" ? true : undefined,
|
||||
})),
|
||||
resourceSizing: {
|
||||
cpuLimit: managedNode.resourceSizing?.cpus !== undefined ? String(managedNode.resourceSizing.cpus) : undefined,
|
||||
memoryLimit: managedNode.resourceSizing?.memoryMB !== undefined ? `${managedNode.resourceSizing.memoryMB}MB` : undefined,
|
||||
},
|
||||
linkedNode: linkedNode ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
|
||||
@@ -102,6 +137,132 @@ export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/docker/nodes", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
const nodes = await central.listManagedDockerNodes();
|
||||
const enriched = await Promise.all(nodes.map(async (managedNode) => {
|
||||
const linkedNode = managedNode.nodeId ? await central.getNode(managedNode.nodeId) : undefined;
|
||||
return toManagedDockerNodeInfo(managedNode, linkedNode);
|
||||
}));
|
||||
enriched.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
||||
res.json(enriched);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/docker/nodes/:managedId/container-status", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore, DockerClientService } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
const managedNode = await central.getManagedDockerNode(req.params.managedId);
|
||||
if (!managedNode) {
|
||||
throw notFound("Managed Docker node not found");
|
||||
}
|
||||
if (!managedNode.containerId) {
|
||||
throw badRequest(`Node has no container yet (status: ${managedNode.status})`);
|
||||
}
|
||||
try {
|
||||
const dockerService = new DockerClientService(managedNode.hostConfig);
|
||||
const info = await dockerService.getContainerInfo(managedNode.containerId, managedNode.hostConfig);
|
||||
if (!info) {
|
||||
throw notFound("Container not found");
|
||||
}
|
||||
res.json({
|
||||
running: info.state.running,
|
||||
status: info.status,
|
||||
startedAt: info.state.startedAt,
|
||||
finishedAt: info.state.finishedAt,
|
||||
exitCode: info.state.exitCode,
|
||||
error: info.state.error,
|
||||
ports: info.ports,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(503).json({ error: `Docker unreachable: ${message}` });
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/docker/nodes/:managedId/logs", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore, DockerClientService } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
const managedNode = await central.getManagedDockerNode(req.params.managedId);
|
||||
if (!managedNode) {
|
||||
throw notFound("Managed Docker node not found");
|
||||
}
|
||||
if (!managedNode.containerId) {
|
||||
throw badRequest(`Node has no container yet (status: ${managedNode.status})`);
|
||||
}
|
||||
|
||||
const tailValue = Number(req.query.tail ?? 100);
|
||||
const tail = Number.isFinite(tailValue) ? Math.max(1, Math.min(1000, Math.floor(tailValue))) : 100;
|
||||
|
||||
try {
|
||||
const dockerService = new DockerClientService(managedNode.hostConfig);
|
||||
const logs = await dockerService.getContainerLogs(managedNode.containerId, managedNode.hostConfig, { tail });
|
||||
res.json({ logs });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
res.status(503).json({ error: `Docker unreachable: ${message}` });
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/docker/nodes/:managedId", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
const node = await central.getManagedDockerNode(req.params.managedId);
|
||||
if (!node) {
|
||||
throw notFound("Managed Docker node not found");
|
||||
}
|
||||
const linkedNode = node.nodeId ? await central.getNode(node.nodeId) : undefined;
|
||||
res.json(toManagedDockerNodeInfo(node, linkedNode));
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/docker-nodes", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
Reference in New Issue
Block a user