diff --git a/.github/workflows/build-and-publish-pypi.yml b/.github/workflows/build-and-publish-pypi.yml index 6f5b73f..f69c38a 100644 --- a/.github/workflows/build-and-publish-pypi.yml +++ b/.github/workflows/build-and-publish-pypi.yml @@ -25,14 +25,14 @@ jobs: with: node-version: 18 cache: "yarn" - cache-dependency-path: "neetbox/frontend/yarn.lock" + cache-dependency-path: "frontend/yarn.lock" - name: setup yarn run: corepack enable - working-directory: neetbox/frontend + working-directory: frontend - run: yarn install --frozen-lockfile - working-directory: neetbox/frontend + working-directory: frontend - run: yarn build - working-directory: neetbox/frontend + working-directory: frontend - name: Build and publish to pypi uses: JRubics/poetry-publish@v1.17 with: diff --git a/.github/workflows/build-frontend.yml b/.github/workflows/build-frontend.yml index 4e0e908..5a2e06f 100644 --- a/.github/workflows/build-frontend.yml +++ b/.github/workflows/build-frontend.yml @@ -3,10 +3,10 @@ name: Frontend Build & Lint on: push: paths: - - 'neetbox/frontend/**' + - 'frontend/**' pull_request: paths: - - 'neetbox/frontend/**' + - 'frontend/**' jobs: @@ -14,14 +14,14 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: neetbox/frontend + working-directory: frontend steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 cache: 'yarn' - cache-dependency-path: 'neetbox/frontend/yarn.lock' + cache-dependency-path: 'frontend/yarn.lock' - name: setup yarn run: corepack enable - run: yarn install --frozen-lockfile @@ -30,3 +30,5 @@ jobs: if: '!cancelled()' - run: yarn lint if: '!cancelled()' + - run: yarn prettier-check + if: '!cancelled()' diff --git a/.github/workflows/maunal-build.yml b/.github/workflows/maunal-build.yml index 78f4946..b8ff37c 100644 --- a/.github/workflows/maunal-build.yml +++ b/.github/workflows/maunal-build.yml @@ -23,14 +23,14 @@ jobs: with: node-version: 18 cache: "yarn" - cache-dependency-path: "neetbox/frontend/yarn.lock" + cache-dependency-path: "frontend/yarn.lock" - name: setup yarn run: corepack enable - working-directory: neetbox/frontend + working-directory: frontend - run: yarn install --frozen-lockfile - working-directory: neetbox/frontend + working-directory: frontend - run: yarn build - working-directory: neetbox/frontend + working-directory: frontend - name: Build and publish to pypi uses: JRubics/poetry-publish@v1.17 with: diff --git a/.github/workflows/poetry-pytest.yml b/.github/workflows/poetry-pytest.yml index 9113f66..1b6c763 100644 --- a/.github/workflows/poetry-pytest.yml +++ b/.github/workflows/poetry-pytest.yml @@ -4,11 +4,15 @@ on: ["pull_request","push"] jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: max-parallel: 3 matrix: python-version: ["3.9","3.10","3.11"] + os: [ubuntu-latest, windows-latest] + defaults: + run: + shell: bash steps: #---------------------------------------------- # check-out repo and set-up python @@ -45,6 +49,7 @@ jobs: - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: poetry install --no-interaction --no-root + #---------------------------------------------- # install your root project, if required #---------------------------------------------- @@ -55,5 +60,5 @@ jobs: #---------------------------------------------- - name: Run tests run: | - source .venv/bin/activate + source $VENV pytest tests/ diff --git a/.gitignore b/.gitignore index b39b47e..58c05fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,26 @@ +# IDE files .vscode .pytest_cache +.ignore + +# Python files __pycache__/ dist/ -frontend_dist/ poetry.lock + +# Frontend files +node_modules +frontend_dist/ + +# test files test/optional build_and_reinstall_wheel.cmd # log files log for_debug.txt +.neethistory/ +.neetory +# others other/ diff --git a/README.md b/README.md index 291a272..7f89fb0 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,57 @@ # NEETBOX -[![wakatime](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c.svg)](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c) +![](./docs/static/img/readme.png) -![](./doc/static/img/readme.png) +Python API, backend server, frontend, ALL IN ONE. A tool box for Logging/Debugging/Tracing/Managing/Facilitating long running python projects, especially a replacement of tensorboard for deep learning projects. -## docs & quick start +[![wakatime](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c.svg)](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c) [![pytest](https://github.com/visualDust/neetbox/actions/workflows/poetry-pytest.yml/badge.svg)](https://github.com/visualDust/neetbox/actions/workflows/poetry-pytest.yml) ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/visualdust/neetbox/build-and-publish-pypi.yml) ![PyPI - Version](https://img.shields.io/pypi/v/neetbox) + ![PyPI - Downloads](https://img.shields.io/pypi/dw/neetbox) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -Logging/Debugging/Tracing/Managing/Facilitating your deep learning projects. A small part of the documentation at [neetbox.550w.host](https://neetbox.550w.host). (We are not ready for the doc yet) +## screenshot -## installation +![](./docs/static/img/screenshot.jpg) +## dev + +NEETBOX is under heavy development. + +- [x] monit multi project on one dashboard +- [x] local/remote logging +- [x] command line cli tools +- [x] system monitoring +- [x] post images to frontend +- [ ] plotting(scatters, line chart...) +- [x] run python code by clicking on frontend buttons +- [x] history automatically saved by backend +- [ ] attach remote logging in command line cli +- [ ] distinguish different runs + + + +## docs + +[neetbox.550w.host](https://neetbox.550w.host). (APIs are ready but we are not ready for the doc yet) + +## quick start + +install neetbox: ```bash pip install neetbox ``` -## use neetbox in your project - -in your project folder: +in any python code folder: ``` neet init ``` neetbox cli generates a config file for your project named `neetbox.toml` -in your code: +then in your code: ```python import neetbox ``` +run your code and visit https://localhost:20202 to see your dashboard. + ## usage examples [how to guides](todo) provides easy examples of basic neetbox funcionalities. diff --git a/docs/docs/guide/configure/index.md b/docs/docs/guide/configure/index.md index 772518d..a3efa64 100644 --- a/docs/docs/guide/configure/index.md +++ b/docs/docs/guide/configure/index.md @@ -9,5 +9,5 @@ NEETBOX read workspace configure from `./neetbox.toml` whenever you import NEETB In the configuration file, there are 4 default feild: - __logging__ for configuring `neetbox.logging` - __pipeline__ for configuring `neetbox.pipeline` -- __integrations__ for configuring `neetbox.integrations` +- __extension__ for configuring `neetbox.extension` - __daemon__ for configuring `neetbox.daemon` diff --git a/docs/docs/guide/index.md b/docs/docs/guide/index.md index c9d544f..6b1bf3e 100644 --- a/docs/docs/guide/index.md +++ b/docs/docs/guide/index.md @@ -21,7 +21,7 @@ pip install --index-url https://pypi.org/simple/ neetbox --force-reinstall --no- - [x] [Simple Logging Utility](/docs/guide/logging/) provides simple logging utility that interacts with other parts of NEETBOX. - [ ] [The CLI](/docs/guide/neetcli/) lets you easily monitor and manage deep learning projects. - [x] [Pipeline](/docs/guide/pipeline/) provides tools that monitor and facilitate your training and inferencing code. -- [ ] [Integrations and other helpful tools](/docs/guide/integrations/) okay I know there are not many things here, but we are managing to do it. +- [ ] [extension and other helpful tools](/docs/guide/extension/) okay I know there are not many things here, but we are managing to do it. - [x] [Basic PyTorch Code Snippets](/docs/guide/torch-snippets/) useless pytorch code snippets. - [x] [Dev APIs](/docs/develop/) join us. diff --git a/docs/docs/guide/integrations/index.md b/docs/docs/guide/integrations/index.md index fdf476d..000d49f 100644 --- a/docs/docs/guide/integrations/index.md +++ b/docs/docs/guide/integrations/index.md @@ -2,4 +2,4 @@ sidebar_position: 6 --- -# Integrations +# Extension diff --git a/docs/docs/guide/pipeline/watch_and_listen.md b/docs/docs/guide/pipeline/watch_and_listen.md index e1f7427..660d0cc 100644 --- a/docs/docs/guide/pipeline/watch_and_listen.md +++ b/docs/docs/guide/pipeline/watch_and_listen.md @@ -13,7 +13,7 @@ neet init create and edit `test.py`. In `test.py`: ```python from neetbox.pipeline import watch,listen -from neetbox.integrations.environment import hardware +from neetbox.extension.environment import hardware from neetbox.logging import logger import time diff --git a/docs/static/img/screenshot.jpg b/docs/static/img/screenshot.jpg new file mode 100644 index 0000000..9b53d45 Binary files /dev/null and b/docs/static/img/screenshot.jpg differ diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..29e4cff --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "import/order": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "warn", + }, +}; diff --git a/neetbox/frontend/.gitignore b/frontend/.gitignore similarity index 98% rename from neetbox/frontend/.gitignore rename to frontend/.gitignore index a547bf3..019e1dc 100644 --- a/neetbox/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,5 +1,4 @@ # Logs -logs *.log npm-debug.log* yarn-debug.log* diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..83db8b2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# Neet Center Frontend + +## Dev + +```shell +yarn # install dev deps +yarn dev +``` + +It uses `http://127.0.0.1:5000` as backend server. See `vite.config.ts` to change. + +## Build + +``` +yarn build +``` diff --git a/neetbox/frontend/index.html b/frontend/index.html similarity index 100% rename from neetbox/frontend/index.html rename to frontend/index.html diff --git a/neetbox/frontend/package.json b/frontend/package.json similarity index 65% rename from neetbox/frontend/package.json rename to frontend/package.json index 1ae3d76..d44d37d 100644 --- a/neetbox/frontend/package.json +++ b/frontend/package.json @@ -4,14 +4,19 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite dev", + "dev:rs": "rsbuild dev", "build": "vite build", + "build:rs": "rsbuild build", "tsc": "tsc", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 20", - "preview": "vite preview" + "preview": "vite preview", + "prettier-check": "prettier -c .", + "prettier": "prettier -w ." }, "dependencies": { - "@douyinfe/semi-ui": "^2.47.0", + "@douyinfe/semi-icons": "^2.47.1", + "@douyinfe/semi-ui": "^2.47.1", "@semi-bot/semi-theme-nyx-c": "^1.0.8", "echarts": "^5.4.3", "jotai": "^2.5.1", @@ -24,6 +29,10 @@ "vite-plugin-semi-theme": "^0.5.0" }, "devDependencies": { + "@douyinfe/semi-rspack-plugin": "^2.48.0", + "@douyinfe/semi-webpack-plugin": "^2.48.0", + "@rsbuild/core": "^0.1.8", + "@rsbuild/plugin-react": "^0.1.8", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@typescript-eslint/eslint-plugin": "^6.10.0", @@ -33,7 +42,12 @@ "eslint-plugin-import": "^2.29.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", + "prettier": "^3.1.0", "typescript": "^5.2.2", "vite": "^5.0.0" + }, + "prettier": { + "trailingComma": "all", + "printWidth": 110 } } diff --git a/neetbox/frontend/public/logo.svg b/frontend/public/logo.svg similarity index 88% rename from neetbox/frontend/public/logo.svg rename to frontend/public/logo.svg index ae1f8b1..553d6b8 100644 --- a/neetbox/frontend/public/logo.svg +++ b/frontend/public/logo.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/neetbox/frontend/public/vite.svg b/frontend/public/vite.svg similarity index 98% rename from neetbox/frontend/public/vite.svg rename to frontend/public/vite.svg index e7b8dfb..ee9fada 100644 --- a/neetbox/frontend/public/vite.svg +++ b/frontend/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/rsbuild.config.ts b/frontend/rsbuild.config.ts new file mode 100644 index 0000000..101815c --- /dev/null +++ b/frontend/rsbuild.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { SemiRspackPlugin } from "@douyinfe/semi-rspack-plugin"; + +const server = new URL("http://127.0.0.1:5000"); + +export default defineConfig({ + plugins: [pluginReact()], + // tools: { + // rspack: (config) => { + // config.plugins!.push( + // new SemiRspackPlugin({ + // theme: "@semi-bot/semi-theme-nyx-c", + // }), + // ); + // }, + // }, + source: { + entry: { index: "./src/main.tsx" }, + }, + html: { + template: "./index.html", + }, + server: { + port: 5173, + proxy: { + "/web/": { + target: server.href, + }, + "/ws/": { + target: `ws://${server.host}:${+server.port + 1}`, + pathRewrite: { "/ws/": "" }, + }, + }, + }, + output: { + distPath: { + root: "../neetbox/frontend_dist", + }, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..018b389 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,17 @@ +import { Layout } from "@douyinfe/semi-ui"; +import { Outlet } from "react-router-dom"; +import { useReportGlobalError } from "./hooks/useReportError"; +import AppHeader from "./components/layout/AppHeader"; +import "./styles/global.css"; + +export default function App() { + useReportGlobalError(); + return ( + + + + + + + ); +} diff --git a/frontend/src/components/dashboard/project/actions.tsx b/frontend/src/components/dashboard/project/actions.tsx new file mode 100644 index 0000000..45e9088 --- /dev/null +++ b/frontend/src/components/dashboard/project/actions.tsx @@ -0,0 +1,130 @@ +import { Button, Checkbox, Col, Input, Popover, Row, Space, Typography } from "@douyinfe/semi-ui"; +import { memo, useContext, useState } from "react"; +import { IconChevronDown, IconPlay } from "@douyinfe/semi-icons"; +import { getProject } from "../../../services/projects"; +import { useMemoJSON } from "../../../hooks/useMemoJSON"; +import { ProjectStatus } from "../../../services/types"; +import { useCurrentProject } from "../../../hooks/useProject"; + +interface Props { + actions: ProjectStatus["__action"]; +} + +export function Actions({ actions }: Props) { + const [blocking, setBlocking] = useState(false); + const actionList = Object.entries(useMemoJSON(actions?.value ?? {})); + return ( + + {actionList.length ? ( + actionList.map(([actionName, actionOptions]) => ( + + )) + ) : ( + + No actions ( + + docs + + ) + + )} + + ); +} + +interface ActionItemProps { + name: string; + actionOptions: ProjectStatus["__action"]["value"][string]; + blocking: boolean; + setBlocking: (blocking: boolean) => void; +} + +export const ActionItem = memo(({ name, actionOptions: options, blocking, setBlocking }: ActionItemProps) => { + const [args, setArgs] = useState>(() => + Object.fromEntries( + Object.entries(options.args).map(([name, type]) => [ + name, + type == "str" ? '""' : type == "bool" ? "False" : "", + ]), + ), + ); + const [running, setCurrentBlocking] = useState(false); + const [result, setResult] = useState(null); + const { projectId } = useCurrentProject()!; + const handleRun = () => { + if (options.blocking) setBlocking(true); + setCurrentBlocking(true); + getProject(projectId).sendAction(name, args, ({ error: err, result: res }) => { + if (options.blocking) setBlocking(false); + setCurrentBlocking(false); + setResult(err ? `error:\n${err}` : `result:\n${JSON.stringify(res)}`); + }); + }; + const renderContent = () => ( + + {name} + {options.description &&
{options.description}
} + {Object.entries(options.args).map(([argName, argType]) => ( + + + {argName} + + + {argType == "bool" ? ( + + setArgs({ + ...args, + [argName]: args[argName] == "True" ? "False" : "True", + }) + } + > + {args[argName]} + + ) : ( + setArgs({ ...args, [argName]: val })} + /> + )} + + + ({argType}) + + + ))} + + {result &&
{result}
} +
+ ); + return ( + + + + ); +}); diff --git a/frontend/src/components/dashboard/project/hardware/cpugraph.tsx b/frontend/src/components/dashboard/project/hardware/cpugraph.tsx new file mode 100644 index 0000000..a3da983 --- /dev/null +++ b/frontend/src/components/dashboard/project/hardware/cpugraph.tsx @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import { ECharts } from "../../../echarts"; +import { ProjectStatus } from "../../../../services/types"; +import { getTimeAxisOptions } from "./utils"; + +export const CPUGraph = ({ hardwareData }: { hardwareData: Array }) => { + const cpus = hardwareData[0].value.cpus; + console.info({ hardwareData }); + const initialOption = () => { + return { + backgroundColor: "transparent", + animation: false, + tooltip: { + trigger: "axis", + }, + grid: { + top: 30, + bottom: 30, + }, + title: { + text: `CPU (${cpus.length} threads)`, + textStyle: { + fontSize: 12, + }, + }, + // legend: { + // data: cpus.map((cpu) => `CPU${cpu.id}`), + // }, + xAxis: { + type: "time", + }, + yAxis: { + type: "value", + max: cpus.length * 100, + axisLabel: { + formatter: (x) => x + " %", + }, + }, + series: [], + } as echarts.EChartsOption; + }; + + const updatingOption = useMemo(() => { + const newOption = { + series: cpus.map((cpu) => ({ + name: `CPU${cpu.id}`, + type: "line", + stack: "cpu", + areaStyle: {}, + symbol: null, + data: hardwareData.map((x) => [new Date(x.timestamp), x.value.cpus[cpu.id].percent]), + })), + xAxis: getTimeAxisOptions(hardwareData), + } as echarts.EChartsOption; + return newOption; + }, [cpus, hardwareData]); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/project/hardware/gpugraph.tsx b/frontend/src/components/dashboard/project/hardware/gpugraph.tsx new file mode 100644 index 0000000..9488fd5 --- /dev/null +++ b/frontend/src/components/dashboard/project/hardware/gpugraph.tsx @@ -0,0 +1,86 @@ +import { useMemo } from "react"; +import { ECharts } from "../../../echarts"; +import { ProjectStatus } from "../../../../services/types"; +import { getTimeAxisOptions } from "./utils"; + +export const GPUGraph = ({ + hardwareData, + gpuId, +}: { + hardwareData: Array; + gpuId: number; +}) => { + const gpus = hardwareData[0].value.gpus; + const initialOption = () => { + return { + backgroundColor: "transparent", + animation: false, + tooltip: { + trigger: "axis", + }, + grid: { + top: 30, + bottom: 30, + }, + title: { + text: `GPU${gpuId}: ${gpus[gpuId].name}`, + textStyle: { + fontSize: 12, + }, + }, + legend: { + data: [`Load`, `Memory`], + }, + xAxis: { + type: "time", + }, + yAxis: [ + { + type: "value", + max: 100, + axisLabel: { + formatter: (x) => x + " %", + }, + }, + { + type: "value", + position: "right", + splitLine: null, + axisLabel: { + formatter: (x) => x.toFixed(1) + " GB", + }, + max: gpus[gpuId].memoryTotal / 1024, + }, + ], + series: [], + } as echarts.EChartsOption; + }; + + const updatingOption = useMemo(() => { + const newOption = { + series: [ + { + name: `Load`, + type: "line", + areaStyle: null, + symbol: null, + data: hardwareData.map((x) => [new Date(x.timestamp), x.value.gpus[gpuId].load * 100]), + }, + { + name: `Memory`, + type: "line", + areaStyle: {}, + symbol: null, + yAxisIndex: 1, + data: hardwareData.map((x) => [new Date(x.timestamp), x.value.gpus[gpuId].memoryUsed / 1024]), + }, + ], + xAxis: getTimeAxisOptions(hardwareData), + } as echarts.EChartsOption; + return newOption; + }, [gpuId, hardwareData]); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/project/hardware/index.tsx b/frontend/src/components/dashboard/project/hardware/index.tsx new file mode 100644 index 0000000..273fc28 --- /dev/null +++ b/frontend/src/components/dashboard/project/hardware/index.tsx @@ -0,0 +1,35 @@ +import { Typography } from "@douyinfe/semi-ui"; +import { ProjectStatus } from "../../../../services/types"; +import { CPUGraph } from "./cpugraph"; +import { GPUGraph } from "./gpugraph"; +import { RAMGraph } from "./ramgraph"; + +export function Hardware({ hardwareData }: { hardwareData: Array }) { + return ( +
+ {hardwareData.every((x) => x.value.gpus.length) ? ( + hardwareData[0].value.gpus.map((_, i) => ) + ) : ( + + )} + {hardwareData.every((x) => x.value.cpus.length) ? ( + + ) : ( + + )} + {hardwareData.every((x) => x.value.ram) ? ( + + ) : ( + + )} +
+ ); +} + +function NoInfoLabel({ text }: { text: string }) { + return ( + + {text} + + ); +} diff --git a/frontend/src/components/dashboard/project/hardware/ramgraph.tsx b/frontend/src/components/dashboard/project/hardware/ramgraph.tsx new file mode 100644 index 0000000..5469ac9 --- /dev/null +++ b/frontend/src/components/dashboard/project/hardware/ramgraph.tsx @@ -0,0 +1,63 @@ +import { useMemo } from "react"; +import { ECharts } from "../../../echarts"; +import { ProjectStatus } from "../../../../services/types"; +import { getTimeAxisOptions } from "./utils"; + +export const RAMGraph = ({ hardwareData }: { hardwareData: Array }) => { + const initialOption = () => { + return { + backgroundColor: "transparent", + animation: false, + tooltip: { + trigger: "axis", + }, + title: { + text: `RAM`, + textStyle: { + fontSize: 12, + }, + }, + grid: { + top: 30, + bottom: 30, + }, + legend: { + data: [`RAM Used`], + }, + xAxis: { + type: "time", + }, + yAxis: [ + { + type: "value", + position: "right", + axisLabel: { + formatter: (x) => x.toFixed(1) + " GB", + }, + max: hardwareData[0].value.ram.total, + }, + ], + series: [], + } as echarts.EChartsOption; + }; + + const updatingOption = useMemo(() => { + const newOption = { + series: [ + { + name: `RAM Used`, + type: "line", + areaStyle: {}, + symbol: null, + data: hardwareData.map((x) => [new Date(x.timestamp), x.value.ram.used]), + }, + ], + xAxis: getTimeAxisOptions(hardwareData), + } as echarts.EChartsOption; + return newOption; + }, [hardwareData]); + + return ( + + ); +}; diff --git a/frontend/src/components/dashboard/project/hardware/utils.ts b/frontend/src/components/dashboard/project/hardware/utils.ts new file mode 100644 index 0000000..f697bdd --- /dev/null +++ b/frontend/src/components/dashboard/project/hardware/utils.ts @@ -0,0 +1,9 @@ +import { ProjectStatus } from "../../../../services/types"; + +export function getTimeAxisOptions(hardwareData: Array) { + const latestTime = new Date(hardwareData[hardwareData.length - 1].timestamp).getTime(); + return { + min: latestTime - 60 * 1000, + max: latestTime, + }; +} diff --git a/frontend/src/components/dashboard/project/images.tsx b/frontend/src/components/dashboard/project/images.tsx new file mode 100644 index 0000000..eb9bfe2 --- /dev/null +++ b/frontend/src/components/dashboard/project/images.tsx @@ -0,0 +1,17 @@ +import { memo } from "react"; +import { Popover, Space } from "@douyinfe/semi-ui"; +import { useCurrentProject, useProjectImages } from "../../../hooks/useProject"; + +export const Images = memo(() => { + const { projectId } = useCurrentProject()!; + const images = useProjectImages(projectId); + return ( + + {images.map((img) => ( + {JSON.stringify(img.metadata, null, 2)}}> + + + ))} + + ); +}); diff --git a/frontend/src/components/dashboard/project/logs/logs.css b/frontend/src/components/dashboard/project/logs/logs.css new file mode 100644 index 0000000..1e6d251 --- /dev/null +++ b/frontend/src/components/dashboard/project/logs/logs.css @@ -0,0 +1,37 @@ +.log-item { + margin-bottom: 5px; + font-family: "Courier New", Courier, monospace; + white-space: pre-wrap; + font-size: 13px; + + .log-tag { + display: inline-block; + --log-tag-bg-hs: 0, 0%; + --log-tag-bg-l: 80%; + background-color: hsl(var(--log-tag-bg-hs), var(--log-tag-bg-l)); + padding: 0 3px; + border-radius: 5px; + } + .log-prefix-info { + --log-tag-bg-hs: 222, 80%; + } + .log-prefix-mention { + --log-tag-bg-hs: 190, 80%; + } + .log-prefix-ok { + --log-tag-bg-hs: 128, 80%; + } + .log-prefix-warning { + --log-tag-bg-hs: 55, 80%; + } + .log-prefix-debug { + --log-tag-bg-hs: 277, 80%; + } + .log-prefix-error { + --log-tag-bg-hs: 0, 80%; + } +} + +[theme-mode="dark"] .log-item .log-tag { + --log-tag-bg-l: 30%; +} diff --git a/frontend/src/components/dashboard/project/logs/logs.tsx b/frontend/src/components/dashboard/project/logs/logs.tsx new file mode 100644 index 0000000..2e8c179 --- /dev/null +++ b/frontend/src/components/dashboard/project/logs/logs.tsx @@ -0,0 +1,85 @@ +import React, { memo, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Button } from "@douyinfe/semi-ui"; +import { IconAlignBottom } from "@douyinfe/semi-icons"; +import { LogData } from "../../../../services/types"; +import "./logs.css"; +import { useCurrentProject, useProjectLogs } from "../../../../hooks/useProject"; +interface Props { + projectId: string; +} + +function AutoScrolling({ style, children }: React.PropsWithChildren<{ style: React.CSSProperties }>) { + const containerRef = useRef(null!); + const scrollerRef = useRef(null!); + const [following, setFollowing] = useState(true); + const [renderingElement, setRenderingElement] = useState(children); + const [height, setHeight] = useState(0); + useLayoutEffect(() => { + const observer = new ResizeObserver(() => { + setHeight(containerRef.current!.clientHeight); + }); + observer.observe(containerRef.current!, { box: "border-box" }); + return () => observer.disconnect(); + }, []); + useEffect(() => { + const dom = scrollerRef.current; + if (dom) { + setFollowing(Math.abs(dom.scrollHeight - dom.clientHeight - dom.scrollTop) < 10); + } + setRenderingElement(children); + }, [children]); + useLayoutEffect(() => { + const dom = scrollerRef.current; + if (following) { + dom.scroll({ top: dom.scrollHeight }); + } + }, [renderingElement, following, height]); + return ( +
+
+ {renderingElement} +
+ {!following && ( + + )} +
+ ); +} + +export const Logs = React.memo(() => { + const { projectId } = useCurrentProject()!; + const logs = useProjectLogs(projectId); + return } />; +}); + +const LogItems = memo(({ logs }: { logs: LogData[] }) => { + return logs.map((x) => ); +}); + +function getColorFromWhom(whom: string) { + const hue = + 50 + ((whom.split("").reduce((prev, char) => ((prev * 11) % 360) + char.charCodeAt(0), 0) * 233) % 200); + return `hsl(${hue}, 70%, var(--log-tag-bg-l))`; +} + +const LogItem = React.memo(({ data }: { data: LogData }) => { + let { series: prefix } = data; + if (!prefix) prefix = "log"; + return ( +
+ {data.datetime}{" "} + {prefix}{" "} + + {data.whom} + {" "} + {data.msg} +
+ ); +}); diff --git a/frontend/src/components/dashboard/project/platformProps.tsx b/frontend/src/components/dashboard/project/platformProps.tsx new file mode 100644 index 0000000..c0092b8 --- /dev/null +++ b/frontend/src/components/dashboard/project/platformProps.tsx @@ -0,0 +1,74 @@ +import React, { memo } from "react"; +import { Toast, Button, Card, CardGroup, Typography } from "@douyinfe/semi-ui"; +import { IconCopy } from "@douyinfe/semi-icons"; +import { useMemoJSON } from "../../../hooks/useMemoJSON"; +import { ProjectStatus } from "../../../services/types"; + +const PropCard = memo( + ({ propName, propValue }: { propName: string; propValue: ProjectStatus["platform"]["value"][string] }) => { + const { Text } = Typography; + const content = Array.isArray(propValue) ? propValue.join(" ") : propValue; + const nameMapping = { + username: "Launched by", + machine: "Machine type", + processor: "Processor name", + os_name: "System/OS name", + os_release: "Sys release ver", + architecture: "Python arch.", + python_build: "Python build", + python_version: "Python version", + }; + return ( + } + style={{ marginRight: 10 }} + size="small" + onClick={() => { + navigator.clipboard.writeText(content).then( + () => { + // copy success + Toast.info("Copied to clipboard"); + }, + () => { + // copy failed + Toast.error("Failed to copy"); + }, + ); + }} + > + } + > + {content} + + ); + }, +); + +export default function PlatformProps({ data }: { data: ProjectStatus["platform"] }): React.JSX.Element { + const memoData = useMemoJSON(data?.value); + return ( +
+ + {(memoData && + Object.entries(memoData).map(([key, value]) => ( + + ))) || No Platform Info} + +
+ ); +} diff --git a/neetbox/frontend/src/components/echarts.tsx b/frontend/src/components/echarts.tsx similarity index 68% rename from neetbox/frontend/src/components/echarts.tsx rename to frontend/src/components/echarts.tsx index 99a4423..acfec0d 100644 --- a/neetbox/frontend/src/components/echarts.tsx +++ b/frontend/src/components/echarts.tsx @@ -1,5 +1,6 @@ import { useRef, useEffect, HTMLAttributes, useState } from "react"; import type * as echarts from "echarts"; +import { useTheme } from "../hooks/useTheme"; import Loading from "./loading"; export interface EChartsProps { @@ -11,9 +12,8 @@ export interface EChartsProps { export const ECharts = (props: EChartsProps) => { const chartContainerRef = useRef(null); const chartRef = useRef(null!); - const [echartsModule, setEchartsModule] = useState( - null - ); + const [echartsModule, setEchartsModule] = useState(null); + const { darkMode } = useTheme(); useEffect(() => { import("echarts").then((mod) => setEchartsModule(mod)); @@ -21,23 +21,29 @@ export const ECharts = (props: EChartsProps) => { useEffect(() => { if (echartsModule) { - const chart = echartsModule.init(chartContainerRef.current); - - chart.setOption(props.initialOption()); + const chart = echartsModule.init( + chartContainerRef.current, + darkMode ? "dark" : null, + // { renderer: "svg" }, + ); + + chart.setOption(props.initialOption(), false, true); + chart.setOption(props.updatingOption); chartRef.current = chart; const handleResize = () => { chart?.resize(); }; - window.addEventListener("resize", handleResize); + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(chartContainerRef.current!); return () => { - window.removeEventListener("resize", handleResize); + resizeObserver.disconnect(); chart?.dispose(); }; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [echartsModule]); + }, [echartsModule, darkMode]); useEffect(() => { if (chartRef.current) { diff --git a/frontend/src/components/layout/AppFooter.tsx b/frontend/src/components/layout/AppFooter.tsx new file mode 100644 index 0000000..3fa45fc --- /dev/null +++ b/frontend/src/components/layout/AppFooter.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Divider, Layout, Typography } from "@douyinfe/semi-ui"; +import Logo from "../logo"; + +export default function AppFooter(): React.JSX.Element { + return ( + + + + + © 2023 - {new Date().getFullYear()} Neet Design. All rights reserved. + + + ); +} diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..71a72bb --- /dev/null +++ b/frontend/src/components/layout/AppHeader.tsx @@ -0,0 +1,28 @@ +import { Typography, Space, Button, Layout } from "@douyinfe/semi-ui"; +import { Link } from "react-router-dom"; +import SwitchColorMode from "../themeSwitcher"; + +export default function AppHeader() { + return ( + + + NEET Center + +
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/components/layout/ConsoleLayout.tsx b/frontend/src/components/layout/ConsoleLayout.tsx new file mode 100644 index 0000000..debcd9f --- /dev/null +++ b/frontend/src/components/layout/ConsoleLayout.tsx @@ -0,0 +1,19 @@ +import { Layout } from "@douyinfe/semi-ui"; +import { Outlet } from "react-router-dom"; +import ConsoleNavBar from "../../pages/console/sidebar"; +import AppFooter from "./AppFooter"; + +export default function ConsoleLayout() { + const { Sider, Content } = Layout; + return ( + + + + + + + + + + ); +} diff --git a/neetbox/frontend/src/components/loading.tsx b/frontend/src/components/loading.tsx similarity index 73% rename from neetbox/frontend/src/components/loading.tsx rename to frontend/src/components/loading.tsx index 897feb7..0e3f547 100644 --- a/neetbox/frontend/src/components/loading.tsx +++ b/frontend/src/components/loading.tsx @@ -1,11 +1,14 @@ import { Spin } from "@douyinfe/semi-ui"; +import { SpinSize } from "@douyinfe/semi-ui/lib/es/spin"; export default function Loading({ width = "", height = "100px", + size = "middle", }: { width?: string; height?: string; + size?: SpinSize; }) { return (
- +
); } diff --git a/neetbox/frontend/src/components/logo.module.css b/frontend/src/components/logo.module.css similarity index 68% rename from neetbox/frontend/src/components/logo.module.css rename to frontend/src/components/logo.module.css index 81e3bad..bc036c0 100644 --- a/neetbox/frontend/src/components/logo.module.css +++ b/frontend/src/components/logo.module.css @@ -7,9 +7,13 @@ body[theme-mode="dark"] { } .neet-logo-glow { - transition: filter 0.3s ease-in-out; + transition: + filter 0.3s ease-in-out, + opacity 0.3s ease-in-out; + opacity: 0.7; } .neet-logo-glow:hover { filter: drop-shadow(0 0 0.55rem var(--logo-glow-color)); + opacity: 1; } diff --git a/neetbox/frontend/src/components/logo.tsx b/frontend/src/components/logo.tsx similarity index 100% rename from neetbox/frontend/src/components/logo.tsx rename to frontend/src/components/logo.tsx diff --git a/frontend/src/components/sectionTitle.tsx b/frontend/src/components/sectionTitle.tsx new file mode 100644 index 0000000..11f1766 --- /dev/null +++ b/frontend/src/components/sectionTitle.tsx @@ -0,0 +1,15 @@ +import { Typography } from "@douyinfe/semi-ui"; + +interface Props { + title: string; +} + +export function SectionTitle(props: Props) { + return ( +
+ + {props.title} + +
+ ); +} diff --git a/frontend/src/components/themeSwitcher.tsx b/frontend/src/components/themeSwitcher.tsx new file mode 100644 index 0000000..47b9c04 --- /dev/null +++ b/frontend/src/components/themeSwitcher.tsx @@ -0,0 +1,43 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Switch } from "@douyinfe/semi-ui"; +import { ThemeContext, useTheme } from "../hooks/useTheme"; + +export default function SwitchColorMode(): React.JSX.Element { + const { darkMode, setDarkMode } = useTheme(); + const switchMode = () => { + setDarkMode(!darkMode); + }; + return ( + + ); +} + +export function ThemeContextProvider(props: React.PropsWithChildren) { + const [darkMode, setDarkModeState] = useState(false); + + const setDarkMode = useCallback((val) => { + setDarkModeState(val); + localStorage.setItem("neetbox-theme", val ? "dark" : ""); + }, []); + + useEffect(() => { + setDarkModeState(localStorage.getItem("neetbox-theme") != "light"); + }, []); + + useEffect(() => { + const body = document.body; + if (darkMode) { + body.setAttribute("theme-mode", "dark"); + } else { + body.removeAttribute("theme-mode"); + } + }, [darkMode]); + + return {props.children}; +} diff --git a/frontend/src/hooks/useMemoJSON.ts b/frontend/src/hooks/useMemoJSON.ts new file mode 100644 index 0000000..650694a --- /dev/null +++ b/frontend/src/hooks/useMemoJSON.ts @@ -0,0 +1,7 @@ +import { useMemo } from "react"; + +/** Return the same ref if the data is not changed */ +export function useMemoJSON(data: T): T { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => data, [JSON.stringify(data)]); +} diff --git a/frontend/src/hooks/useProject.ts b/frontend/src/hooks/useProject.ts new file mode 100644 index 0000000..2a22b76 --- /dev/null +++ b/frontend/src/hooks/useProject.ts @@ -0,0 +1,27 @@ +import { useAtom } from "jotai"; +import { createContext, useContext } from "react"; +import { getProject } from "../services/projects"; + +export const ProjectContext = createContext<{ projectId: string; projectName?: string } | null>(null); + +export function useCurrentProject() { + return useContext(ProjectContext); +} + +export function useProjectStatus(id: string) { + const project = getProject(id); + const [data] = useAtom(project.status.atom); + return data; +} + +export function useProjectLogs(id: string) { + const project = getProject(id); + const [data] = useAtom(project?.logs.atom); + return data; +} + +export function useProjectImages(id: string) { + const project = getProject(id); + const [data] = useAtom(project?.images.atom); + return data; +} diff --git a/frontend/src/hooks/useReportError.tsx b/frontend/src/hooks/useReportError.tsx new file mode 100644 index 0000000..8e670c8 --- /dev/null +++ b/frontend/src/hooks/useReportError.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { Notification, Typography } from "@douyinfe/semi-ui"; + +export function useReportGlobalError() { + useEffect(() => { + const handleError = (e: WindowEventMap["error"]) => { + showError(e.message); + }; + const handleRejection = (e: WindowEventMap["unhandledrejection"]) => { + showError(String(e.reason)); + }; + window.addEventListener("error", handleError); + window.addEventListener("unhandledrejection", handleRejection); + return () => { + window.removeEventListener("error", handleError); + window.removeEventListener("unhandledrejection", handleRejection); + }; + }, []); +} + +function showError(errorText: string) { + Notification.error({ + content: ( +
+ Frontend App Error +
+ {errorText} +
+
+ ), + duration: 10, + }); +} diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx new file mode 100644 index 0000000..0d1dac9 --- /dev/null +++ b/frontend/src/hooks/useTheme.tsx @@ -0,0 +1,10 @@ +import { createContext, useContext } from "react"; + +export const ThemeContext = createContext<{ + darkMode: boolean; + setDarkMode: (val: boolean) => void; +}>(null!); + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/neetbox/frontend/src/index.css b/frontend/src/index.css similarity index 100% rename from neetbox/frontend/src/index.css rename to frontend/src/index.css diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..7ce9b3a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import { LocaleProvider } from "@douyinfe/semi-ui"; +import en_US from "@douyinfe/semi-ui/lib/es/locale/source/en_US"; +import LoginPage from "./pages/login"; +import "./index.css"; +import { consoleRoutes } from "./pages/console"; +import { ThemeContextProvider } from "./components/themeSwitcher"; +import { ServiceProvider } from "./services/serviceProvider"; +import ConsoleLayout from "./components/layout/ConsoleLayout"; +import App from "./App"; + +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { + path: "", + // element: , + element: , + }, + consoleRoutes(), + { + path: "/login", + element: , + }, + ], + }, +]); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + + + , +); diff --git a/frontend/src/pages/console/index.tsx b/frontend/src/pages/console/index.tsx new file mode 100644 index 0000000..15cde46 --- /dev/null +++ b/frontend/src/pages/console/index.tsx @@ -0,0 +1,19 @@ +import { RouteObject } from "react-router-dom"; +import ConsoleLayout from "../../components/layout/ConsoleLayout"; +import Dashboard from "./projectDashboard"; +import Overview from "./overview"; + +export function consoleRoutes(): RouteObject { + return { + path: "console", + element: , + children: [ + { + path: "project/:projectId", + element: , + errorElement:
Error
, + }, + { path: "overview", element: }, + ], + }; +} diff --git a/frontend/src/pages/console/overview.tsx b/frontend/src/pages/console/overview.tsx new file mode 100644 index 0000000..382bb42 --- /dev/null +++ b/frontend/src/pages/console/overview.tsx @@ -0,0 +1,18 @@ +import { Typography } from "@douyinfe/semi-ui"; + +export default function Overview() { + return ( +
+ Overview WIP + Please select a project from the sidebar. +
+ ); +} diff --git a/frontend/src/pages/console/projectDashboard.tsx b/frontend/src/pages/console/projectDashboard.tsx new file mode 100644 index 0000000..070bd23 --- /dev/null +++ b/frontend/src/pages/console/projectDashboard.tsx @@ -0,0 +1,63 @@ +import { useParams } from "react-router-dom"; +import { useMemo } from "react"; +import { Divider, Typography } from "@douyinfe/semi-ui"; +import PlatformProps from "../../components/dashboard/project/platformProps"; +import { ProjectContext, useProjectStatus } from "../../hooks/useProject"; +import { Logs } from "../../components/dashboard/project/logs/logs"; +import { Actions } from "../../components/dashboard/project/actions"; +import Loading from "../../components/loading"; +import { Hardware } from "../../components/dashboard/project/hardware"; +import { SectionTitle } from "../../components/sectionTitle"; +import { Images } from "../../components/dashboard/project/images"; + +export default function ProjectDashboardButRecreateOnRouteChange() { + const { projectId } = useParams(); + return ; +} + +function ProjectDashboard() { + const { projectId } = useParams(); + if (!projectId) throw new Error("projectId required"); + + const data = useProjectStatus(projectId); + // console.info("project", { projectId, data }); + + const projectName = data.current?.config.value.name; + + const projectContextData = useMemo( + () => ({ + projectId, + projectName, + }), + [projectId, projectName], + ); + + return ( + +
+ + Project "{projectName ?? projectId}" + + + + + + + {data.current ? ( + <> + + + + + x.hardware)} /> + + + + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/pages/console/sidebar.tsx b/frontend/src/pages/console/sidebar.tsx new file mode 100644 index 0000000..9707440 --- /dev/null +++ b/frontend/src/pages/console/sidebar.tsx @@ -0,0 +1,53 @@ +import { Nav, Tag, Typography } from "@douyinfe/semi-ui"; +import { IconHome, IconListView } from "@douyinfe/semi-icons"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAPI } from "../../services/api"; +import Loading from "../../components/loading"; + +export default function ConsoleNavBar() { + const location = useLocation(); + const navigate = useNavigate(); + const { data } = useAPI("/list", { refreshInterval: 5000 }); + return ( +