Merge pull request #6 from orus-dev/node-management

Node management
This commit is contained in:
Klesti Selimaj
2025-12-28 04:56:29 +01:00
committed by GitHub
27 changed files with 1570 additions and 536 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ["ssh2", "docker-modem", "dockerode"],
};
export default nextConfig;
+300
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -23,9 +24,11 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"os-name": "^6.1.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^2.15.4",
"systeminformation": "^5.28.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
@@ -1246,6 +1249,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -2483,6 +2509,20 @@
"node": ">=10.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2804,6 +2844,29 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
@@ -2919,6 +2982,18 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2983,6 +3058,15 @@
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT"
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3027,6 +3111,24 @@
"node": ">=8"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3352,6 +3454,18 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/macos-release": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-3.4.0.tgz",
"integrity": "sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3371,6 +3485,12 @@
"node": ">= 0.4"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3392,6 +3512,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -3510,6 +3642,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -3528,6 +3687,46 @@
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/os-name": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/os-name/-/os-name-6.1.0.tgz",
"integrity": "sha512-zBd1G8HkewNd2A8oQ8c6BN/f/c9EId7rSUueOLGu28govmUctXmM+3765GwsByv9nYUdrLqHphXlYIc86saYsg==",
"license": "MIT",
"dependencies": {
"macos-release": "^3.3.0",
"windows-release": "^6.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3892,6 +4091,39 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3959,6 +4191,18 @@
"node": ">=8"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -3982,6 +4226,32 @@
}
}
},
"node_modules/systeminformation": {
"version": "5.28.2",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.28.2.tgz",
"integrity": "sha512-AWrPnmbIcfjLX7wiQMd7r6bl3PDaGoqvrfn8pCLJbk9CiB+vQXTk8RVxNs2vQRTezYlhb9skxEIyAPTm1If5bQ==",
"license": "MIT",
"os": [
"darwin",
"linux",
"win32",
"freebsd",
"openbsd",
"netbsd",
"sunos",
"android"
],
"bin": {
"systeminformation": "lib/cli.js"
},
"engines": {
"node": ">=8.0.0"
},
"funding": {
"type": "Buy me a coffee",
"url": "https://www.buymeacoffee.com/systeminfo"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
@@ -4173,6 +4443,36 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/windows-release": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/windows-release/-/windows-release-6.1.0.tgz",
"integrity": "sha512-1lOb3qdzw6OFmOzoY0nauhLG72TpWtb5qgYPiSh/62rjc1XidBSDio2qw0pwHh17VINF217ebIkZJdFLZFn9SA==",
"license": "MIT",
"dependencies": {
"execa": "^8.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+3
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -23,9 +24,11 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"os-name": "^6.1.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^2.15.4",
"systeminformation": "^5.28.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
+8 -1
View File
@@ -2,9 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { version } from "@/../package.json";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import { read } from "@/app/api/file";
import { NodeJsonTemplate } from "../types";
import osName from "os-name";
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = testAuth(req);
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
@@ -13,9 +16,13 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
);
}
const { node_id } = await read(NodeJsonTemplate, "data", "node.json");
return NextResponse.json({
message: "ok",
app: "dockship",
version,
node_id,
os: osName(),
});
}
+13 -1
View File
@@ -13,9 +13,21 @@ export async function read<T extends object>(
await fs.access(filePath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(template, null, 2), "utf-8");
await fs.writeFile(filePath, JSON.stringify(template(), null, 2), "utf-8");
}
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content);
}
export async function write<T extends object>(
val: T,
fp: string,
...filePaths: string[]
) {
const filePath = path.join(process.cwd(), fp, ...filePaths);
const dirPath = path.dirname(filePath);
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(val, null, 2), "utf-8");
}
+7 -2
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import { getDocker } from "@/core/docker";
import { Docker } from "@/lib/types";
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
@@ -12,8 +14,11 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
);
}
const docker = await getDocker();
return NextResponse.json({
message: "ok",
containers: [],
});
version: docker.version.Version,
containers: docker.containers,
} as Docker);
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import si from "systeminformation";
import { average } from "@/lib/format";
type Usage = {
cpu: number;
memory: number;
in: number;
out: number;
};
const usageSecs: Usage[] = [];
const usageMins: (Usage & { time: string })[] = [];
const usageHours: (Usage & { time: string })[] = new Array(12).fill({
time: "00:00",
cpu: 0,
memory: 0,
in: 0,
out: 0,
});
var hr = 0;
function getTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
setInterval(async () => {
const [mem, load, network] = await Promise.all([
si.mem(),
si.currentLoad(),
si.networkStats(),
]);
const sec = {
cpu: load.currentLoad,
memory: ((mem.total - mem.available) / mem.total) * 100,
in: average(network, (n) => n.rx_bytes),
out: average(network, (n) => n.tx_bytes),
};
usageSecs.push(sec);
if (usageSecs.length === 10) {
usageHours[hr] = { time: getTime(), ...sec };
} else if (usageSecs.length >= 60) {
usageMins.push({
time: getTime(),
cpu: average(usageSecs, (s) => s.cpu),
memory: average(usageSecs, (s) => s.memory),
in: average(usageSecs, (s) => s.in),
out: average(usageSecs, (s) => s.out),
});
if (usageMins.length >= 60) {
const time = getTime();
if (hr >= 12) {
usageHours.shift();
usageHours.push({
time,
cpu: average(usageMins, (s) => s.cpu),
memory: average(usageMins, (s) => s.memory),
in: average(usageMins, (s) => s.in),
out: average(usageMins, (s) => s.out),
});
} else {
usageHours[hr] = {
time,
cpu: average(usageMins, (s) => s.cpu),
memory: average(usageMins, (s) => s.memory),
in: average(usageMins, (s) => s.in),
out: average(usageMins, (s) => s.out),
};
hr++;
}
usageMins.splice(0, usageSecs.length);
}
usageSecs.splice(0, usageSecs.length);
}
}, 1000);
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
return NextResponse.json({
message: "ok",
metrics: usageHours,
});
}
+1 -5
View File
@@ -1,10 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { version } from "@/../package.json";
import { read } from "@/app/api/file";
import { NodeJsonTemplate } from "@/app/api/types";
export async function GET(req: NextRequest): Promise<NextResponse> {
const { node_id } = await read(NodeJsonTemplate, "data", "node.json");
return NextResponse.json({ app: "dockship", version, node_id });
return NextResponse.json({ app: "dockship", version });
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import si from "systeminformation";
import { NodeLiveData } from "@/lib/types";
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
const [cpu, mem, load, disk, network] = await Promise.all([
si.cpu(),
si.mem(),
si.currentLoad(),
si.fsSize(),
si.networkStats(),
]);
const disks = await si.fsSize();
const root = disks.find((d) => d.mount === "/" || d.fs.includes("disk3"));
return NextResponse.json({
message: "ok",
liveData: {
cpu: {
manufacturer: cpu.manufacturer,
brand: cpu.brand,
cores: cpu.cores,
usage: load.currentLoad,
},
memory: {
total: mem.total,
used: mem.used,
free: mem.free,
usage: ((mem.total - mem.available) / mem.total) * 100,
},
disk: {
used: (root?.size || 0) - (root?.available || 0),
size: root?.size || 0,
available: root?.available || 0,
},
disks: disk.map((d) => ({
fs: d.fs,
size: d.size,
used: d.used,
usage: d.use,
})),
network: network.map((n) => ({
iface: n.iface,
rx: n.rx_bytes,
tx: n.tx_bytes,
})),
},
} as NodeLiveData);
}
+154 -86
View File
@@ -45,96 +45,164 @@
}
:root {
--background: oklch(0.97 0.02 225.66);
--foreground: oklch(0.15 0.02 269.18);
--card: oklch(0.98 0.01 228.79);
--card-foreground: oklch(0.15 0.02 269.18);
--popover: oklch(0.98 0.01 238.45);
--popover-foreground: oklch(0.15 0.02 269.18);
--primary: oklch(0.71 0.15 239.15);
--primary-foreground: oklch(0.94 0.03 232.39);
--secondary: oklch(0.91 0.03 229.20);
--secondary-foreground: oklch(0.15 0.02 269.18);
--muted: oklch(0.89 0.02 225.69);
--muted-foreground: oklch(0.36 0.03 231.55);
--accent: oklch(0.88 0.02 235.72);
--accent-foreground: oklch(0.34 0.05 229.20);
--destructive: oklch(0.61 0.24 20.96);
--border: oklch(0.82 0.02 240.77);
--input: oklch(0.82 0.02 240.77);
--ring: oklch(0.55 0.10 235.72);
--chart-1: oklch(0.57 0.11 228.92);
--chart-2: oklch(0.45 0.10 270.08);
--chart-3: oklch(0.65 0.15 159.03);
--chart-4: oklch(0.75 0.10 100.01);
--chart-5: oklch(0.55 0.15 299.88);
--sidebar: oklch(0.93 0.01 238.46);
--sidebar-foreground: oklch(0.15 0.02 269.18);
--sidebar-primary: oklch(0.57 0.11 228.92);
--sidebar-primary-foreground: oklch(0.99 0.01 203.97);
--sidebar-accent: oklch(0.88 0.02 235.72);
--sidebar-accent-foreground: oklch(0.15 0.02 269.18);
--sidebar-border: oklch(0.82 0.02 240.77);
--sidebar-ring: oklch(0.57 0.11 228.92);
--font-sans: 'Source Code Pro', 'Geist', 'Geist Fallback', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Source Serif 4', 'Geist', 'Geist Fallback', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: 'Source Code Pro', 'Geist Mono', 'Geist Mono Fallback', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.03);
--shadow-xs: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.03);
--shadow-sm: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.06), 0px 1px 2px -1px oklch(0.49 0.09 235.00 / 0.06);
--shadow: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.06), 0px 1px 2px -1px oklch(0.49 0.09 235.00 / 0.06);
--shadow-md: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.06), 0px 2px 4px -1px oklch(0.49 0.09 235.00 / 0.06);
--shadow-lg: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.06), 0px 4px 6px -1px oklch(0.49 0.09 235.00 / 0.06);
--shadow-xl: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.06), 0px 8px 10px -1px oklch(0.49 0.09 235.00 / 0.06);
--shadow-2xl: 0px 1px 2.5px 0px oklch(0.49 0.09 235.00 / 0.15);
--background: oklch(0.9842 0.0010 264.5478);
--foreground: oklch(0.2115 0.0072 264.4099);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2115 0.0072 264.4099);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2115 0.0072 264.4099);
--primary: oklch(0.5096 0.1483 253.4619);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9210 0.0088 247.9219);
--secondary-foreground: oklch(0.4114 0.0323 248.6180);
--muted: oklch(0.9607 0.0044 247.8691);
--muted-foreground: oklch(0.5921 0.0247 248.2437);
--accent: oklch(0.9607 0.0044 247.8691);
--accent-foreground: oklch(0.4144 0.0162 248.2179);
--destructive: oklch(0.5875 0.2303 28.2622);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9050 0.0107 247.9443);
--input: oklch(0.9210 0.0088 247.9219);
--ring: oklch(0.5096 0.1483 253.4619);
--chart-1: oklch(0.5096 0.1483 253.4619);
--chart-2: oklch(0.6610 0.1560 154.8128);
--chart-3: oklch(0.7139 0.1737 55.1486);
--chart-4: oklch(0.5103 0.2201 301.6038);
--chart-5: oklch(0.5875 0.2303 28.2622);
--sidebar: oklch(0.9605 0.0024 264.5432);
--sidebar-foreground: oklch(0.2639 0.0102 264.3844);
--sidebar-primary: oklch(0.5546 0.1629 253.5932);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9210 0.0088 247.9219);
--sidebar-accent-foreground: oklch(0.4144 0.0162 248.2179);
--sidebar-border: oklch(0.8809 0.0134 247.9797);
--sidebar-ring: oklch(0.5096 0.1483 253.4619);
--font-sans: 'Inter', sans-serif;
--font-serif: 'Lora', serif;
--font-mono: 'Fira Code', monospace;
--radius: 0.5rem;
--shadow-x: 0rem;
--shadow-y: 0.25rem;
--shadow-blur: 0.5rem;
--shadow-spread: 0rem;
--shadow-opacity: 0.1;
--shadow-color: 0 0 0;
--shadow-2xs: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.05);
--shadow-xs: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.05);
--shadow-sm: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.10), 0rem 1px 2px -1px hsl(0 0 0 / 0.10);
--shadow: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.10), 0rem 1px 2px -1px hsl(0 0 0 / 0.10);
--shadow-md: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.10), 0rem 2px 4px -1px hsl(0 0 0 / 0.10);
--shadow-lg: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.10), 0rem 4px 6px -1px hsl(0 0 0 / 0.10);
--shadow-xl: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.10), 0rem 8px 10px -1px hsl(0 0 0 / 0.10);
--shadow-2xl: 0rem 0.25rem 0.5rem 0rem hsl(0 0 0 / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.18 0.02 271.27);
--foreground: oklch(0.90 0.01 238.47);
--card: oklch(0.22 0.02 271.67);
--card-foreground: oklch(0.90 0.01 238.47);
--popover: oklch(0.22 0.02 271.67);
--popover-foreground: oklch(0.90 0.01 238.47);
--primary: oklch(52.5% 0.105 236);
--primary-foreground: oklch(95.1% 0.00932 232.789);
--secondary: oklch(0.28 0.03 270.91);
--secondary-foreground: oklch(0.90 0.01 238.47);
--muted: oklch(0.28 0.03 270.91);
--muted-foreground: oklch(0.60 0.03 269.46);
--accent: oklch(0.28 0.03 270.91);
--accent-foreground: oklch(0.90 0.01 238.47);
--destructive: oklch(0.64 0.25 19.69);
--border: oklch(0.90 0.01 238.47 / 15%);
--input: oklch(0.90 0.01 238.47 / 20%);
--ring: oklch(0.66 0.13 227.70);
--chart-1: oklch(0.66 0.13 227.70);
--chart-2: oklch(0.60 0.10 269.83);
--chart-3: oklch(0.70 0.15 159.83);
--chart-4: oklch(0.80 0.10 100.65);
--chart-5: oklch(0.60 0.15 300.14);
--sidebar: oklch(0.22 0.02 271.67);
--sidebar-foreground: oklch(0.90 0.01 238.47);
--sidebar-primary: oklch(0.66 0.13 227.70);
--sidebar-primary-foreground: oklch(0.18 0.02 271.27);
--sidebar-accent: oklch(0.28 0.03 270.91);
--sidebar-accent-foreground: oklch(0.90 0.01 238.47);
--sidebar-border: oklch(0.90 0.01 238.47 / 15%);
--sidebar-ring: oklch(0.66 0.13 227.70);
--background: oklch(0.1984 0.0154 256.8228);
--foreground: oklch(0.8809 0.0134 247.9797);
--card: oklch(0.2292 0.0222 256.8398);
--card-foreground: oklch(0.8809 0.0134 247.9797);
--popover: oklch(0.2292 0.0222 256.8398);
--popover-foreground: oklch(0.8809 0.0134 247.9797);
--primary: oklch(0.5096 0.1483 253.4619);
--primary-foreground: oklch(0.9843 0.0017 247.8393);
--secondary: oklch(0.3095 0.0306 256.8416);
--secondary-foreground: oklch(0.8809 0.0134 247.9797);
--muted: oklch(0.2605 0.0240 256.8358);
--muted-foreground: oklch(0.6780 0.0191 248.1098);
--accent: oklch(0.2605 0.0240 256.8358);
--accent-foreground: oklch(0.8809 0.0134 247.9797);
--destructive: oklch(0.5875 0.2303 28.2622);
--destructive-foreground: oklch(0.9843 0.0017 247.8393);
--border: oklch(0.3567 0.0369 256.8455);
--input: oklch(0.3095 0.0306 256.8416);
--ring: oklch(0.5096 0.1483 253.4619);
--chart-1: oklch(0.5096 0.1483 253.4619);
--chart-2: oklch(0.6610 0.1560 154.8128);
--chart-3: oklch(0.7139 0.1737 55.1486);
--chart-4: oklch(0.5103 0.2201 301.6038);
--chart-5: oklch(0.5875 0.2303 28.2622);
--sidebar: oklch(0.2292 0.0222 256.8398);
--sidebar-foreground: oklch(0.9210 0.0088 247.9219);
--sidebar-primary: oklch(0.5096 0.1483 253.4619);
--sidebar-primary-foreground: oklch(0.9843 0.0017 247.8393);
--sidebar-accent: oklch(0.2605 0.0240 256.8358);
--sidebar-accent-foreground: oklch(0.8809 0.0134 247.9797);
--sidebar-border: oklch(0.3567 0.0369 256.8455);
--sidebar-ring: oklch(0.5096 0.1483 253.4619);
--font-sans: 'Roboto Mono', monospace;
--font-serif: 'Roboto Slab', serif;
--font-mono: 'Fira Code', monospace;
--radius: 0.25rem;
--shadow-x: 0rem;
--shadow-y: 0.5rem;
--shadow-blur: 0.75rem;
--shadow-spread: 0rem;
--shadow-opacity: 0.3;
--shadow-color: 0 0 0;
--shadow-2xs: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.15);
--shadow-xs: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.15);
--shadow-sm: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.30), 0rem 1px 2px -1px hsl(0 0 0 / 0.30);
--shadow: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.30), 0rem 1px 2px -1px hsl(0 0 0 / 0.30);
--shadow-md: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.30), 0rem 2px 4px -1px hsl(0 0 0 / 0.30);
--shadow-lg: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.30), 0rem 4px 6px -1px hsl(0 0 0 / 0.30);
--shadow-xl: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.30), 0rem 8px 10px -1px hsl(0 0 0 / 0.30);
--shadow-2xl: 0rem 0.5rem 0.75rem 0rem hsl(0 0 0 / 0.75);
}
--shadow-2xs: 0px 1px 2px 0px oklch(0 0 0 / 0.01);
--shadow-xs: 0px 1px 2px 0px oklch(0 0 0 / 0.01);
--shadow-sm: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 1px 2px -1px oklch(0 0 0 / 0.01);
--shadow: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 1px 2px -1px oklch(0 0 0 / 0.01);
--shadow-md: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 2px 4px -1px oklch(0 0 0 / 0.01);
--shadow-lg: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 4px 6px -1px oklch(0 0 0 / 0.01);
--shadow-xl: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 8px 10px -1px oklch(0 0 0 / 0.01);
--shadow-2xl: 0px 1px 2px 0px oklch(0 0 0 / 0.03);
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@theme inline {
+138 -288
View File
@@ -1,69 +1,17 @@
"use client";
import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig } from "@/components/ui/chart";
import GradientAreaChart from "@/components/GradientAreaChart";
import { getMetrics } from "@/core/client/metrics";
import { useEffect, useState } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import RadialChart from "@/components/RadialChart";
import { ArrowDownLeft, ArrowUpRight, Cpu, MemoryStick } from "lucide-react";
const cpuData = [
{ time: "00:00", value: 45 },
{ time: "01:00", value: 52 },
{ time: "02:00", value: 38 },
{ time: "03:00", value: 42 },
{ time: "04:00", value: 35 },
{ time: "05:00", value: 48 },
{ time: "06:00", value: 55 },
{ time: "07:00", value: 62 },
{ time: "08:00", value: 78 },
{ time: "09:00", value: 85 },
{ time: "10:00", value: 72 },
{ time: "11:00", value: 68 },
{ time: "12:00", value: 75 },
];
const memoryData = [
{ time: "00:00", value: 62 },
{ time: "01:00", value: 64 },
{ time: "02:00", value: 58 },
{ time: "03:00", value: 61 },
{ time: "04:00", value: 59 },
{ time: "05:00", value: 63 },
{ time: "06:00", value: 67 },
{ time: "07:00", value: 72 },
{ time: "08:00", value: 78 },
{ time: "09:00", value: 82 },
{ time: "10:00", value: 76 },
{ time: "11:00", value: 74 },
{ time: "12:00", value: 71 },
];
const networkData = [
{ time: "00:00", in: 12, out: 8 },
{ time: "01:00", in: 15, out: 10 },
{ time: "02:00", in: 8, out: 5 },
{ time: "03:00", in: 10, out: 7 },
{ time: "04:00", in: 6, out: 4 },
{ time: "05:00", in: 14, out: 9 },
{ time: "06:00", in: 22, out: 15 },
{ time: "07:00", in: 35, out: 28 },
{ time: "08:00", in: 48, out: 38 },
{ time: "09:00", in: 52, out: 42 },
{ time: "10:00", in: 45, out: 35 },
{ time: "11:00", in: 42, out: 32 },
{ time: "12:00", in: 38, out: 28 },
];
import { Node, NodeLiveData } from "@/lib/types";
import { getLiveNodes } from "@/core/client/node";
import { average } from "@/lib/format";
const containerMetrics = [
{ name: "api-gateway-1", cpu: 45, memory: 62, network: "12.4 MB/s" },
@@ -76,244 +24,146 @@ const containerMetrics = [
{ name: "worker-queue-1", cpu: 55, memory: 48, network: "5.6 MB/s" },
];
const cpuChartConfig = {
cpu: { label: "CPU Usage", color: "var(--chart-1)" },
} satisfies ChartConfig;
const memoryChartConfig = {
memory: { label: "RAM Usage", color: "var(--chart-3)" },
} satisfies ChartConfig;
const networkChartConfig = {
in: { label: "Inbound", color: "var(--chart-2)" },
out: { label: "Outbound", color: "var(--chart-4)" },
} satisfies ChartConfig;
export default function MonitoringPage() {
const [data, setData] = useState<
{
time: string;
cpu: number;
memory: number;
in: number;
out: number;
}[]
>([]);
const isMobile = useIsMobile();
const [liveData, setLiveData] = useState<{
cpuUsage: number;
ramUsage: number;
}>();
useEffect(() => {
const fetchNodes = async () => {
const nodes = await getLiveNodes();
setLiveData({
cpuUsage: average(nodes, (n) => n.liveData.cpu.usage),
ramUsage: average(nodes, (n) => n.liveData.memory.usage),
});
};
fetchNodes();
const interval = setInterval(fetchNodes, 3000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
async function fetchMetrics() {
setData(await getMetrics());
}
fetchMetrics();
const i = setInterval(fetchMetrics, 30000);
return () => clearInterval(i);
}, []);
return (
<DashboardLayout
title="Monitoring"
subtitle="Resource utilization and metrics"
>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Cluster CPU
</div>
<div className="stat-value">68%</div>
<Progress value={68} className="w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Cluster Memory
</div>
<div className="stat-value">71%</div>
<Progress value={71} className="w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Network In
</div>
<div className="stat-value">38 MB/s</div>
<div className="text-xs text-success mt-1">↑ 12% from avg</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Network Out
</div>
<div className="stat-value">28 MB/s</div>
<div className="text-xs text-muted-foreground mt-1">
↓ 5% from avg
</div>
</CardContent>
</Card>
</div>
{isMobile ? (
<div className="grid grid-cols-2 gap-4 mb-6">
<Card>
<CardContent className="px-4">
<RadialChart value={liveData?.cpuUsage || 0}>CPU</RadialChart>
</CardContent>
</Card>
<Card>
<CardContent className="px-4">
<RadialChart value={liveData?.ramUsage || 0}>RAM</RadialChart>
</CardContent>
</Card>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Cluster CPU
</div>
<div className="stat-value">68%</div>
<Progress value={68} className="w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Cluster Memory
</div>
<div className="stat-value">71%</div>
<Progress value={71} className="w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Network In
</div>
<div className="stat-value">38 MB/s</div>
<div className="text-xs text-success mt-1">↑ 12% from avg</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Network Out
</div>
<div className="stat-value">28 MB/s</div>
<div className="text-xs text-muted-foreground mt-1">
↓ 5% from avg
</div>
</CardContent>
</Card>
</div>
)}
{/* Charts */}
{/* CPU & Memory Charts */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between flex-wrap gap-2">
CPU Usage <Badge variant="outline">Last 12 hours</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={cpuData}>
<defs>
<linearGradient
id="cpuGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="oklch(0.66 0.13 227.70)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="oklch(0.66 0.13 227.70)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(0, 0%, 55%)", fontSize: 10 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(0, 0%, 55%)", fontSize: 10 }}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(0,0%,7%)",
border: "1px solid hsl(0,0%,18%)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="oklch(0.66 0.13 227.70)"
strokeWidth={2}
fill="url(#cpuGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between flex-wrap gap-2">
Memory Usage <Badge variant="outline">Last 12 hours</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={memoryData}>
<defs>
<linearGradient
id="memoryGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="oklch(0.70 0.15 159.83)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="oklch(0.70 0.15 159.83)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(0, 0%, 55%)", fontSize: 10 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(0, 0%, 55%)", fontSize: 10 }}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(0,0%,7%)",
border: "1px solid hsl(0,0%,18%)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="oklch(0.70 0.15 159.83)"
strokeWidth={2}
fill="url(#memoryGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<GradientAreaChart
title="CPU Usage"
data={data}
config={cpuChartConfig}
max={100}
/>
<GradientAreaChart
title="Memory Usage"
data={data}
config={memoryChartConfig}
max={100}
/>
</div>
{/* Network Chart */}
<Card className="mb-6">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between flex-wrap gap-2">
Network I/O <Badge variant="outline">Last 12 hours</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={networkData}>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(0, 0%, 55%)", fontSize: 10 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(0, 0%, 55%)", fontSize: 10 }}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(0,0%,7%)",
border: "1px solid hsl(0,0%,18%)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Line
type="monotone"
dataKey="in"
stroke="oklch(0.80 0.10 100.65)"
strokeWidth={2}
dot={false}
name="Inbound"
/>
<Line
type="monotone"
dataKey="out"
stroke="oklch(0.60 0.15 300.14)"
strokeWidth={2}
dot={false}
name="Outbound"
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-6 mt-2">
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-primary" />
<span className="text-xs text-muted-foreground">Inbound</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-warning" />
<span className="text-xs text-muted-foreground">Outbound</span>
</div>
</div>
</CardContent>
</Card>
{/* Network Metrics */}
<div className="mb-6">
<GradientAreaChart
title="Network I/O"
data={data}
config={networkChartConfig}
/>
</div>
{/* Container Metrics */}
<Card>
+94 -73
View File
@@ -4,71 +4,70 @@ import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusIndicator } from "@/components/dashboard/StatusIndicator";
import { Server, Plus, Settings, Trash2 } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { Docker, Node, NodeLiveData } from "@/lib/types";
import { useEffect, useState } from "react";
import { getDockerNode, getLiveNodes } from "@/core/client/node";
import { formatBytes } from "@/lib/format";
import { setNodes as updateNodes } from "@/core/client/node";
import AddNode from "@/components/dialogs/AddNode";
const nodes = [
{
id: "node-01",
name: "node-01",
hostname: "dockship-node-01.local",
ip: "10.0.1.10",
os: "Ubuntu 22.04 LTS",
docker: "24.0.7",
cpu: { cores: 8, usage: 72 },
memory: { total: "32 GB", used: "21.8 GB", percentage: 68 },
disk: { total: "500 GB", used: "245 GB", percentage: 49 },
containers: 8,
status: "running" as const,
labels: ["production", "primary"],
},
{
id: "node-02",
name: "node-02",
hostname: "dockship-node-02.local",
ip: "10.0.1.11",
os: "Ubuntu 22.04 LTS",
docker: "24.0.7",
cpu: { cores: 8, usage: 45 },
memory: { total: "32 GB", used: "16.6 GB", percentage: 52 },
disk: { total: "500 GB", used: "178 GB", percentage: 36 },
containers: 6,
status: "running" as const,
labels: ["production"],
},
{
id: "node-03",
name: "node-03",
hostname: "dockship-node-03.local",
ip: "10.0.1.12",
os: "Ubuntu 22.04 LTS",
docker: "24.0.7",
cpu: { cores: 16, usage: 89 },
memory: { total: "64 GB", used: "54.4 GB", percentage: 85 },
disk: { total: "1 TB", used: "612 GB", percentage: 61 },
containers: 12,
status: "running" as const,
labels: ["production", "high-memory"],
},
];
function DiskUsage({ liveData }: NodeLiveData) {
const usage = (liveData.disk.used / liveData.disk.size) * 100;
return (
<div>
<div className="text-xs text-muted-foreground mb-2">Disk</div>
<div className="stat-value mb-2">{usage.toFixed(0)}%</div>
<Progress value={usage} className="w-full" />
<div className="text-[10px] text-muted-foreground mt-1 font-mono">
{formatBytes(liveData.disk.used)} / {formatBytes(liveData.disk.size)}
</div>
</div>
);
}
export default function NodesPage() {
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
const [dockerNodes, setDockerNodes] = useState<Docker[]>([]);
useEffect(() => {
const fetchNodes = async () => {
const nodes = await getLiveNodes();
setNodes(nodes);
};
fetchNodes();
const interval = setInterval(fetchNodes, 3000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const fetchDocker = async () => {
setDockerNodes(await Promise.all(nodes.map(getDockerNode)));
};
fetchDocker();
}, [nodes]);
return (
<DashboardLayout title="Nodes" subtitle="Cluster node management">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge>{nodes.length} nodes</Badge>
<Badge>{nodes.filter((n) => n.status === "running").length} online</Badge>
<Badge>
{/* {nodes.filter((n) => n.status === "running").length} online */}
</Badge>
</div>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" /> Add Node
</Button>
<AddNode>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" /> Add Node
</Button>
</AddNode>
</div>
<div className="flex flex-col gap-4">
{nodes.map((node) => (
<Card key={node.id}>
{nodes.map((node, i) => (
<Card key={i}>
<CardHeader className="flex flex-col sm:flex-row sm:items-start sm:justify-between pb-2 gap-2 sm:gap-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="w-10 h-10 bg-secondary flex items-center justify-center">
@@ -76,11 +75,11 @@ export default function NodesPage() {
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<StatusIndicator status={node.status} />
{/* <StatusIndicator status={node.status} /> */}
<CardTitle className="font-mono">{node.name}</CardTitle>
</div>
<div className="text-xs text-muted-foreground font-mono">
{node.hostname} • {node.ip}
{node.ip} • {node.ip}
</div>
</div>
</div>
@@ -93,7 +92,15 @@ export default function NodesPage() {
<Button variant="ghost" size="icon-sm">
<Settings className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon-sm">
<Button
variant="ghost"
size="icon-sm"
onClick={async () => {
const newNodes = nodes.filter((_, index) => index !== i);
setNodes(newNodes);
await updateNodes(newNodes);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
@@ -102,7 +109,9 @@ export default function NodesPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-6">
{/* System Info */}
<div className="border-b sm:border-b-0 sm:border-r border-border pb-2 sm:pb-0 pr-0 sm:pr-6">
<div className="text-xs text-muted-foreground mb-2">System</div>
<div className="text-xs text-muted-foreground mb-2">
System
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">OS</span>
@@ -110,11 +119,15 @@ export default function NodesPage() {
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Docker</span>
<span className="font-mono">{node.docker}</span>
<span className="font-mono">
{dockerNodes[i]?.version}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Containers</span>
<span className="font-mono">{node.containers}</span>
<span className="font-mono">
{dockerNodes[i]?.containers.length}
</span>
</div>
</div>
</div>
@@ -122,35 +135,43 @@ export default function NodesPage() {
{/* CPU */}
<div>
<div className="text-xs text-muted-foreground mb-2">
CPU ({node.cpu.cores} cores)
CPU ({node.liveData.cpu.cores} cores)
</div>
<div className="stat-value mb-2">{node.cpu.usage}%</div>
<Progress value={node.cpu.usage} className="w-full" />
<div className="stat-value mb-2">
{node.liveData.cpu.usage.toFixed(0)}%
</div>
<Progress
value={node.liveData.cpu.usage}
className="w-full"
/>
</div>
{/* Memory */}
<div>
<div className="text-xs text-muted-foreground mb-2">Memory</div>
<div className="stat-value mb-2">{node.memory.percentage}%</div>
<Progress value={node.memory.percentage} className="w-full" />
<div className="text-xs text-muted-foreground mb-2">
Memory
</div>
<div className="stat-value mb-2">
{node.liveData.memory.usage.toFixed(0)}%
</div>
<Progress
value={node.liveData.memory.usage}
className="w-full"
/>
<div className="text-[10px] text-muted-foreground mt-1 font-mono">
{node.memory.used} / {node.memory.total}
{formatBytes(node.liveData.memory.used)} /{" "}
{formatBytes(node.liveData.memory.total)}
</div>
</div>
{/* Disk */}
<div>
<div className="text-xs text-muted-foreground mb-2">Disk</div>
<div className="stat-value mb-2">{node.disk.percentage}%</div>
<Progress value={node.disk.percentage} className="w-full" />
<div className="text-[10px] text-muted-foreground mt-1 font-mono">
{node.disk.used} / {node.disk.total}
</div>
</div>
<DiskUsage liveData={node.liveData} />
{/* Quick Actions */}
<div>
<div className="text-xs text-muted-foreground mb-2">Quick Actions</div>
<div className="text-xs text-muted-foreground mb-2">
Quick Actions
</div>
<div className="space-y-1">
<Button size="sm" className="w-full justify-start">
SSH Connect
+34 -42
View File
@@ -16,6 +16,9 @@ import {
} from "lucide-react";
import { Progress } from "@/components/ui/progress";
import Link from "next/link";
import { getLiveNodes } from "@/core/client/node";
import { useEffect, useState } from "react";
import { Node, NodeLiveData } from "@/lib/types";
const recentDeployments = [
{
@@ -84,36 +87,21 @@ const applications = [
memory: 48,
status: "pending" as const,
},
];
const nodes = [
{
name: "node-01",
ip: "10.0.1.10",
cpu: 72,
memory: 68,
containers: 8,
status: "running" as const,
},
{
name: "node-02",
ip: "10.0.1.11",
cpu: 45,
memory: 52,
containers: 6,
status: "running" as const,
},
{
name: "node-03",
ip: "10.0.1.12",
cpu: 89,
memory: 85,
containers: 12,
status: "running" as const,
},
];
].slice(-5);
export default function OverviewPage() {
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
useEffect(() => {
const fetchNodes = async () => {
const nodes = await getLiveNodes();
setNodes(nodes);
};
fetchNodes();
const interval = setInterval(fetchNodes, 3000);
return () => clearInterval(interval);
}, []);
return (
<DashboardLayout title="Overview" subtitle="System status and metrics">
{/* Stats Grid */}
@@ -239,14 +227,11 @@ export default function OverviewPage() {
</CardHeader>
<CardContent className="overflow-x-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 min-w-75">
{nodes.map((node) => (
<div
key={node.name}
className="border border-border p-4 bg-secondary/30"
>
{nodes.map((node, i) => (
<div key={i} className="border border-border p-4 bg-secondary/30">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
<div className="flex items-center gap-2">
<StatusIndicator status={node.status} />
<StatusIndicator status={"running"} />
<span className="font-mono text-sm">{node.name}</span>
</div>
<span className="text-xs text-muted-foreground font-mono">
@@ -256,20 +241,27 @@ export default function OverviewPage() {
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">CPU</span>
<span className="font-mono">{node.cpu}%</span>
</div>
<Progress value={node.cpu} className="[&>div]:bg-chart-1" />
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Memory</span>
<span className="font-mono">{node.memory}%</span>
<span className="font-mono">
{node.liveData.cpu.usage.toFixed(0)}%
</span>
</div>
<Progress
value={node.memory}
value={node.liveData.cpu.usage}
className="[&>div]:bg-chart-1"
/>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Memory</span>
<span className="font-mono">
{node.liveData.memory.usage.toFixed(0)}%
</span>
</div>
<Progress
value={node.liveData.memory.usage}
className="w-full [&>div]:bg-chart-3"
/>
<div className="flex items-center justify-between text-xs pt-2 border-t border-border mt-2">
<span className="text-muted-foreground">Containers</span>
<span className="font-mono">{node.containers}</span>
<span className="font-mono">{6}</span>
</div>
</div>
</div>
+1 -13
View File
@@ -1,17 +1,5 @@
"use client";
import { getDocker } from "@/core/client/docker";
export default function Home() {
return (
<div>
<button
onClick={async () => {
console.log(await getDocker("localhost", "oD7tMrnkxIvuFrR+b6QZh66E4PU="));
}}
>
Get docker
</button>
</div>
);
return "ok";
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { AreaChart, Area, XAxis, CartesianGrid, YAxis } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
export default function GradientAreaChart({
title,
data,
config,
max,
}: {
title: string;
data: any[];
config: ChartConfig;
max?: number;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>Last 12 hours</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={config} className="h-60 w-full">
<AreaChart data={data} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tickMargin={8}
/>
{max && (
<YAxis
domain={[0, max]}
width={16}
axisLine={false}
tickLine={false}
/>
)}
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<defs>
{Object.entries(config).map(([key, config]) => (
<linearGradient
key={key}
id={`fill-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={config.color}
stopOpacity={0.7}
/>
<stop
offset="95%"
stopColor={config.color}
stopOpacity={0.1}
/>
</linearGradient>
))}
</defs>
{Object.entries(config).map(([key, config]) => (
<Area
key={key}
type="natural"
dataKey={key}
stroke={config.color}
fill={`url(#fill-${key})`}
fillOpacity={0.4}
stackId="a"
/>
))}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}
+7 -7
View File
@@ -25,19 +25,19 @@ export default function RadialChart({
const chartData = [{ value, fill: "var(--color-chart-1)" }];
return (
<ChartContainer config={chartConfig} className="size-30">
<ChartContainer config={chartConfig} className="w-full aspect-square">
<RadialBarChart
data={chartData}
startAngle={-50}
endAngle={(value / 100) * 230}
innerRadius="65%"
outerRadius="100%"
startAngle={230 - 360 * (value / 100)}
endAngle={230}
innerRadius={80}
outerRadius={110}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className=""
className="first:fill-muted last:fill-background"
polarRadius={[86, 74]}
/>
<RadialBar dataKey="value" background cornerRadius={10} />
@@ -57,7 +57,7 @@ export default function RadialChart({
y={viewBox.cy}
className="fill-foreground text-xl font-bold"
>
{chartData[0].value.toLocaleString()}
{chartData[0].value.toFixed(1)}%
</tspan>
<tspan
x={viewBox.cx}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "../ui/textarea";
import {
authNode,
getNodes,
setNodes as updateNodes,
} from "@/core/client/node";
export default function AddNode({ children }: { children: ReactNode }) {
const [step, setStep] = useState<1 | 2>(1);
const [loading, setLoading] = useState(false);
const [ip, setIp] = useState("");
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [auth, setAuth] = useState<{ node_id: string; os: string }>();
const authenticateNode = async () => {
setLoading(true);
try {
setAuth(await authNode(ip.trim(), key.trim()));
setStep(2);
} catch (e) {
console.log(e);
} finally {
setLoading(false);
}
};
const addNode = async () => {
if (!auth) return;
const nodes = await getNodes();
await updateNodes([
...nodes,
{
node_id: auth.node_id,
key,
name,
description,
ip,
os: auth.os,
labels: [],
},
]);
setStep(1);
setAuth(undefined);
setIp("");
setKey("");
setName("");
setDescription("");
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>
{step === 1 && "Add Node"}
{step === 2 && "Node Details"}
</DialogTitle>
</DialogHeader>
{/* STEP 1: IP & Node Key */}
{step === 1 && (
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="ip">
IP address <span className="text-destructive">*</span>
</Label>
<Input
id="ip"
value={ip}
onChange={(e) => setIp(e.target.value)}
placeholder="10.7.1.21"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="key">
Node Key <span className="text-destructive">*</span>
</Label>
<Input
id="key"
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="xxxxxxxxxxxxxxxx"
/>
</div>
</div>
)}
{/* STEP 3: Node Name & Description */}
{step === 2 && (
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name">
Node Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Captain's node"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="description">
Node Description{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A node for the Captain's main operations"
/>
</div>
</div>
)}
<DialogFooter>
{step === 1 && (
<>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={authenticateNode} disabled={loading}>
Next
</Button>
</>
)}
{step === 2 && (
<>
<Button variant="outline" onClick={() => setStep(1)}>
Back
</Button>
<DialogClose asChild>
<Button onClick={addNode}>Add Node</Button>
</DialogClose>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+12
View File
@@ -0,0 +1,12 @@
"use server";
import axios from "axios";
import { Node } from "@/lib/types";
export async function getDocker(node: Node): Promise<{ version: string }> {
return await (
await axios.get(`http://${node.ip}:3000/api/get-docker`, {
headers: { Authorization: `ApiKey ${node.key}` },
})
).data;
}
-14
View File
@@ -1,14 +0,0 @@
"use server";
import axios from "axios";
export async function getDocker(
node_ip: string,
key: string
): Promise<{ version: string }> {
return await (
await axios.get(`http://${node_ip}:3000/api/get-docker`, {
headers: { Authorization: `ApiKey ${key}` },
})
).data;
}
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { Node } from "@/lib/types";
import axios from "axios";
import { getNodes } from "./node";
export async function getMetrics(): Promise<
{
time: string;
cpu: number;
memory: number;
in: number;
out: number;
}[]
> {
const nodes = await getNodes();
const nodeMetrics = await Promise.all(nodes.map(getNodeMetrics));
if (nodeMetrics.length === 0) return [];
const numNodes = nodeMetrics.length;
const numPoints = nodeMetrics[0].length;
const averaged: (typeof nodeMetrics)[0] = [];
for (let i = 0; i < numPoints; i++) {
const sum = nodeMetrics.reduce(
(acc, metrics) => {
const m = metrics[i];
acc.cpu += m.cpu;
acc.memory += m.memory;
acc.in += m.in;
acc.out += m.out;
return acc;
},
{ cpu: 0, memory: 0, in: 0, out: 0 }
);
averaged.push({
time: nodeMetrics[0][i].time,
cpu: sum.cpu / numNodes,
memory: sum.memory / numNodes,
in: sum.in / numNodes,
out: sum.out / numNodes,
});
}
return averaged;
}
export async function getNodeMetrics(node: Node): Promise<
{
time: string;
cpu: number;
memory: number;
in: number;
out: number;
}[]
> {
const data = await (
await axios.get(`http://${node.ip}:3000/api/metrics`, {
headers: { Authorization: `ApiKey ${node.key}` },
})
).data;
return data.metrics;
}
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { read, write } from "@/app/api/file";
import { Docker, Node, NodeLiveData } from "@/lib/types";
import axios from "axios";
export async function getNodes(): Promise<Node[]> {
return await read(() => [], "data", "nodes.json");
}
export async function setNodes(nodes: Node[]) {
await write(nodes, "data", "nodes.json");
}
export async function getNode(node_id: string): Promise<Node | undefined> {
const nodes = await read<Node[]>(() => [], "data", "nodes.json");
return nodes.find((v) => v.node_id === node_id);
}
export async function getLiveNode(node: Node): Promise<NodeLiveData & Node> {
const data = await (
await axios.get(`http://${node.ip}:3000/api/status`, {
headers: { Authorization: `ApiKey ${node.key}` },
})
).data;
return { ...node, liveData: data.liveData };
}
export async function getLiveNodes(): Promise<(NodeLiveData & Node)[]> {
const nodes = await read(() => [], "data", "nodes.json");
return await Promise.all(nodes.map(getLiveNode));
}
export async function getDockerNode(node: Node): Promise<Docker> {
const data = await (
await axios.get(`http://${node.ip}:3000/api/get-docker`, {
headers: { Authorization: `ApiKey ${node.key}` },
})
).data;
return { ...node, containers: data.containers, version: data.version };
}
export async function authNode(ip: string, key: string) {
const data = await (
await axios.get(`http://${ip}:3000/api/auth`, {
headers: { Authorization: `ApiKey ${key}` },
})
).data;
return { version: data.version, node_id: data.node_id, os: data.os };
}
+2 -2
View File
@@ -6,6 +6,6 @@ const docker = new Docker();
export async function getDocker() {
const version = await docker.version();
console.log("Docker version:", version);
return version;
const containers = await docker.listContainers();
return { version, containers };
}
+17
View File
@@ -0,0 +1,17 @@
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${
sizes[i]
}`;
}
export function average<T>(values: Array<T>, p: (v: T) => number) {
return values.length > 0
? values.reduce((sum, value) => sum + p(value), 0) / values.length
: 0;
}
+49
View File
@@ -0,0 +1,49 @@
import { ContainerInfo } from "dockerode";
export interface Node {
node_id: string;
key: string;
ip: string;
os: string;
name: string;
description: string;
labels: string[];
}
export interface NodeLiveData {
liveData: {
cpu: {
manufacturer: string;
brand: string;
cores: number;
usage: number;
};
memory: {
total: number;
used: number;
free: number;
usage: number;
};
disk: {
size: number;
available: number;
used: number;
};
disks: {
fs: string;
size: number;
used: number;
usage: number;
}[];
network: {
iface: string;
rx: number;
tx: number;
}[];
};
}
export interface Docker {
version: string;
containers: ContainerInfo[];
}