├── .devcontainer ├── Dockerfile ├── devcontainer.json └── setup.sh ├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ └── npmbuild.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .stylelintignore ├── .stylelintrc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ ├── __mocks__ │ ├── ldrs.ts │ └── next-auth.ts ├── e2e │ ├── api │ │ ├── ping │ │ │ └── route.test.ts │ │ └── v1 │ │ │ ├── devices │ │ │ ├── [device] │ │ │ │ ├── clients │ │ │ │ │ └── route.test.ts │ │ │ │ ├── command │ │ │ │ │ └── route.test.ts │ │ │ │ ├── commands │ │ │ │ │ └── route.test.ts │ │ │ │ ├── description │ │ │ │ │ └── route.test.ts │ │ │ │ ├── route.test.ts │ │ │ │ ├── var │ │ │ │ │ ├── description │ │ │ │ │ │ └── route.test.ts │ │ │ │ │ ├── route.test.ts │ │ │ │ │ └── type │ │ │ │ │ │ └── route.test.ts │ │ │ │ └── vars │ │ │ │ │ └── route.test.ts │ │ │ └── route.test.ts │ │ │ ├── metrics │ │ │ └── route.test.ts │ │ │ ├── netversion │ │ │ └── route.test.ts │ │ │ └── version │ │ │ └── route.test.ts │ └── app │ │ ├── api │ │ └── docs │ │ │ └── page.test.tsx │ │ ├── login │ │ └── page.test.tsx │ │ ├── page.test.tsx │ │ └── settings │ │ └── page.test.tsx └── unit │ ├── app │ ├── actions.test.ts │ ├── page.test.tsx │ └── settings │ │ └── page.test.tsx │ ├── client │ ├── components │ │ ├── add-influx.test.tsx │ │ ├── add-server.test.tsx │ │ ├── daynight.test.tsx │ │ ├── device-wrapper.test.tsx │ │ ├── footer.test.tsx │ │ ├── gauge.test.tsx │ │ ├── grid.test.tsx │ │ ├── kpi.test.tsx │ │ ├── line-charts │ │ │ ├── volt-amps-chart.test.tsx │ │ │ ├── volts-chart.test.tsx │ │ │ └── watts-chart.test.tsx │ │ ├── login-form.test.tsx │ │ ├── navbar-controls.test.tsx │ │ ├── runtime.test.tsx │ │ ├── settings-wrapper.test.tsx │ │ └── wrapper.test.tsx │ └── context │ │ └── theme-provider.test.tsx │ └── server │ ├── auth-config.test.ts │ ├── influxdb.test.ts │ ├── nut.test.ts │ ├── promise-socket.test.ts │ ├── scheduler.test.ts │ └── settings.test.ts ├── components.json ├── config ├── .setup ├── dummy.seq ├── dummy2.dev ├── dummy3.dev ├── dummy4.dev ├── nut.conf ├── ups.conf ├── upsd.conf ├── upsd.users └── upsmon.conf ├── crowdin.yml ├── docker-compose.yml ├── eslint.config.mjs ├── examples ├── basic │ └── docker-compose.yml ├── influxdb │ ├── docker-compose.yml │ └── grafana │ │ └── provisioning │ │ ├── dashboards │ │ ├── PeaNUT.json │ │ └── dashboard.yml │ │ └── datasources │ │ └── datasource.yml └── prometheus │ ├── docker-compose.yml │ ├── grafana │ └── provisioning │ │ ├── dashboards │ │ ├── PeaNUT.json │ │ └── dashboard.yml │ │ └── datasources │ │ └── datasource.yml │ └── prometheus.yml ├── images ├── charts.png └── table.png ├── jest.config.ts ├── jest.setup.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── app │ ├── actions.ts │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── docs │ │ │ ├── custom.css │ │ │ ├── page.tsx │ │ │ └── react-swagger.tsx │ │ ├── ping │ │ │ └── route.ts │ │ ├── swagger.ts │ │ ├── utils.ts │ │ ├── v1 │ │ │ ├── devices │ │ │ │ ├── [device] │ │ │ │ │ ├── clients │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── command │ │ │ │ │ │ └── [param] │ │ │ │ │ │ │ ├── description │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── commands │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── description │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ ├── rwvars │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── var │ │ │ │ │ │ └── [param] │ │ │ │ │ │ │ ├── description │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── enum │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── range │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── type │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── vars │ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── metrics │ │ │ │ └── route.ts │ │ │ ├── netversion │ │ │ │ └── route.ts │ │ │ └── version │ │ │ │ └── route.ts │ │ └── ws │ │ │ └── route.ts │ ├── device │ │ └── [device] │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── icon.svg │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ ├── page.tsx │ ├── robots.txt │ └── settings │ │ ├── layout.tsx │ │ └── page.tsx ├── auth.config.ts ├── auth.ts ├── client │ ├── components │ │ ├── actions.tsx │ │ ├── add-influx.tsx │ │ ├── add-server.tsx │ │ ├── daynight.tsx │ │ ├── device-wrapper.tsx │ │ ├── footer.tsx │ │ ├── gauge.css │ │ ├── gauge.tsx │ │ ├── grid.tsx │ │ ├── kpi.tsx │ │ ├── language-switcher.tsx │ │ ├── line-charts │ │ │ ├── charts-container.tsx │ │ │ ├── charts.css │ │ │ ├── hooks │ │ │ │ └── useChartData.ts │ │ │ ├── line-chart-base.tsx │ │ │ ├── types │ │ │ │ └── chart-types.ts │ │ │ ├── volt-amps-chart.tsx │ │ │ ├── volts-chart.tsx │ │ │ └── watts-chart.tsx │ │ ├── loader.tsx │ │ ├── login-form.tsx │ │ ├── navbar-controls.tsx │ │ ├── navbar.tsx │ │ ├── refresh.tsx │ │ ├── runtime.tsx │ │ ├── settings-wrapper.tsx │ │ ├── terminal.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── chart.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── select.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── context │ │ ├── language.tsx │ │ ├── query-client.tsx │ │ └── theme-provider.tsx │ └── i18n │ │ ├── index.ts │ │ └── locales │ │ ├── de │ │ └── translation.json │ │ ├── en │ │ └── translation.json │ │ ├── es │ │ └── translation.json │ │ ├── fr │ │ └── translation.json │ │ ├── it │ │ └── translation.json │ │ └── ro │ │ └── translation.json ├── common │ ├── constants.ts │ └── types.ts ├── instrumentation.ts ├── lib │ └── utils.ts ├── middleware.ts └── server │ ├── auth-config.ts │ ├── influxdb.ts │ ├── nut.ts │ ├── promise-socket.ts │ ├── scheduler.ts │ └── settings.ts ├── tsconfig.json └── types └── swagger.d.ts /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT="20" 2 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:${VARIANT} 3 | 4 | RUN apt update && apt install nut -y 5 | 6 | RUN npm install -g pnpm 7 | 8 | ENV PATH="${PATH}:./node_modules/.bin" -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peanut", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "args": { 6 | "VARIANT": "20" 7 | } 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": [ 12 | "streetsidesoftware.code-spell-checker", 13 | "codeandstuff.package-json-upgrade", 14 | "esbenp.prettier-vscode", 15 | "bradlc.vscode-tailwindcss" 16 | ] 17 | } 18 | }, 19 | "postCreateCommand": ".devcontainer/setup.sh", 20 | "forwardPorts": [3000] 21 | } 22 | -------------------------------------------------------------------------------- /.devcontainer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Install Node packages 4 | pnpm config set store-dir ~/pnpm 5 | pnpm install 6 | 7 | echo "Adding skeleton config" 8 | sudo cp -r config/* /etc/nut 9 | 10 | echo 0 > upsd.pid 11 | echo 0 > upsmon.pid 12 | 13 | sudo mv upsd.pid /run/nut 14 | sudo mv upsmon.pid /run/nut 15 | 16 | sudo /usr/sbin/upsdrvctl -u root start 17 | sudo /usr/sbin/upsd -u nut 18 | sudo /usr/sbin/upsmon -DB -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | .swc 4 | .env 5 | .husky 6 | .gitignore 7 | .DS_Store 8 | docker-compose* 9 | Dockerfile* 10 | .dockerignore 11 | .github 12 | .git 13 | .vscode 14 | .devcontainer 15 | .editorconfig 16 | __tests__ 17 | config 18 | examples 19 | images 20 | test_results 21 | coverage 22 | scripts 23 | README.md 24 | CONTRIBUTING.md 25 | CODE_OF_CONDUCT.md 26 | npm-debug.log 27 | crowdin.yml 28 | jest.config.js 29 | jest.setup.js 30 | playwright.config.js 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = true -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: brandawg93 2 | custom: ['https://paypal.me/brandawg93'] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report an issue with this project. 3 | title: '[Bug]: ' 4 | labels: bug 5 | body: 6 | - type: textarea 7 | id: describe-the-bug 8 | attributes: 9 | label: Describe the bug 10 | description: Also describe what you expected to happen 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: debug-output 15 | attributes: 16 | label: Debug Output 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: to-reproduce 21 | attributes: 22 | label: Steps to reproduce 23 | description: Steps to reproduce the behavior 24 | value: '1. ' 25 | validations: 26 | required: true 27 | - type: input 28 | id: device-type 29 | attributes: 30 | label: Device Type 31 | description: On what device are you running the project? 32 | placeholder: ex. raspberry pi 33 | validations: 34 | required: true 35 | - type: input 36 | id: ups-device 37 | attributes: 38 | label: UPS Device 39 | description: What is your UPS type and driver used? 40 | validations: 41 | required: true 42 | - type: input 43 | id: last-working-version 44 | attributes: 45 | label: Last Working Version 46 | description: If applicable, what was the last version that worked properly? 47 | validations: 48 | required: false 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: '[Feature Request]: ' 4 | labels: enhancement 5 | body: 6 | - type: textarea 7 | id: enhancement-of-issue 8 | attributes: 9 | label: Enhancement of issue 10 | description: Is your feature request related to a problem? Please describe. 11 | placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: solution 16 | attributes: 17 | label: Solution 18 | description: Describe the solution you'd like. 19 | placeholder: A clear and concise description of what you want to happen. 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: alternatives 24 | attributes: 25 | label: Alternatives 26 | description: Describe alternatives you've considered. 27 | placeholder: A clear and concise description of any alternative solutions or features you've considered. 28 | validations: 29 | required: false 30 | - type: textarea 31 | id: context 32 | attributes: 33 | label: Additional Context 34 | description: Add any other context or screenshots about the feature request here. 35 | validations: 36 | required: false 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'github-actions' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.github/workflows/npmbuild.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4.2.2 14 | # with: 15 | # fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 16 | 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | version: 9 20 | run_install: false 21 | 22 | - uses: actions/setup-node@v4.4.0 23 | with: 24 | node-version: 22 25 | cache: 'pnpm' 26 | 27 | - uses: actions/cache@v4 28 | name: Setup nextjs cache 29 | with: 30 | path: ${{ github.workspace }}/.next/cache 31 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} 32 | restore-keys: | 33 | ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}- 34 | 35 | - name: Cache Docker images. 36 | uses: ScribeMD/docker-cache@0.5.0 37 | with: 38 | key: docker-${{ runner.os }}-${{ hashFiles('docker-compose.yml') }} 39 | 40 | - name: Install dependencies 41 | run: pnpm i --frozen-lockfile 42 | 43 | - name: Get installed Playwright version 44 | id: playwright-version 45 | run: echo "version=$(pnpm why --json @playwright/test | jq --raw-output '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT 46 | 47 | - name: Cache Playwright 48 | uses: actions/cache@v4 49 | id: playwright-cache 50 | with: 51 | path: '~/.cache/ms-playwright' 52 | key: '${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}' 53 | restore-keys: | 54 | ${{ runner.os }}-playwright- 55 | 56 | - name: Install Playwright with dependencies 57 | if: steps.playwright-cache.outputs.cache-hit != 'true' 58 | run: npx playwright install --with-deps 59 | 60 | - name: Install Playwright's dependencies 61 | if: steps.playwright-cache.outputs.cache-hit == 'true' 62 | run: npx playwright install-deps 63 | 64 | - name: Build and test 65 | run: | 66 | export AUTH_SECRET=$(npx --yes auth secret) 67 | pnpm run type-check 68 | pnpm run lint 69 | pnpm run test:all 70 | pnpm run build 71 | env: 72 | USERNAME: admin 73 | PASSWORD: nut_test 74 | NUT_HOST: localhost 75 | NUT_PORT: 3493 76 | WEB_PORT: 8080 77 | # - name: SonarQube Scan 78 | # uses: SonarSource/sonarqube-scan-action@v4 79 | # env: 80 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .pnpm-store 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | /dist 16 | 17 | # misc 18 | .DS_Store 19 | .env 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | .vscode 30 | Thumbs.db 31 | 32 | scripts 33 | 34 | dev 35 | 36 | settings.yml 37 | 38 | .next 39 | .swc 40 | test-results 41 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm run precheck 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .devcontainer 3 | .husky 4 | .pnpm-store 5 | *.png 6 | config 7 | pnpm-lock.yaml 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "semi": false, 6 | "tabWidth": 2, 7 | "jsxSingleQuote": true, 8 | "plugins": ["prettier-plugin-tailwindcss"] 9 | } 10 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"], 3 | "rules": { 4 | "at-rule-no-unknown": [ 5 | true, 6 | { 7 | "ignoreAtRules": ["tailwind", "plugin", "custom-variant", "theme"] 8 | } 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Peanut 2 | 3 | Thank you for considering contributing to Peanut! We welcome contributions from everyone. By participating in this project, you agree to abide by our code of conduct. 4 | 5 | ## How to Contribute 6 | 7 | ### Reporting Bugs 8 | 9 | 1. Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/brandawg93/peanut/issues). 10 | 2. If you're unable to find an open issue addressing the problem, open a new one. Be sure to include: 11 | 12 | - A descriptive title and summary. 13 | - Steps to reproduce the issue. 14 | - Any relevant logs or screenshots. 15 | 16 | ### Suggesting Enhancements 17 | 18 | 1. Check if the enhancement was already suggested by searching on GitHub under [Issues](https://github.com/brandawg93/peanut/issues). 19 | 2. If not, open a new issue and provide: 20 | 21 | - A clear and descriptive title and summary. 22 | - A detailed description of the proposed enhancement. 23 | - Any relevant examples or use cases. 24 | 25 | ### Submitting Pull Requests 26 | 27 | 1. Fork the repository. 28 | 2. Create a new branch from `main` for your feature or bugfix. 29 | 3. Make your changes. 30 | 4. Ensure your code follows the project's coding standards. 31 | 5. Commit your changes with a descriptive commit message. 32 | 6. Push your branch to your forked repository. 33 | 7. Open a pull request against the `main` branch of the original repository. 34 | 35 | ### Code Style 36 | 37 | - Follow the coding style used in the project. 38 | - Write clear, concise, and meaningful commit messages. 39 | 40 | ### Code of Conduct 41 | 42 | Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md). 43 | 44 | ## Getting Help 45 | 46 | If you need help, feel free to ask questions by opening an issue or reaching out to the community. 47 | 48 | Thank you for your contributions! 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-slim AS deps 2 | 3 | WORKDIR /app 4 | 5 | ENV PNPM_HOME="/pnpm" 6 | ENV PATH="$PNPM_HOME:$PATH" 7 | ENV NEXT_TELEMETRY_DISABLED=1 8 | RUN npm i -g corepack && \ 9 | corepack enable pnpm 10 | COPY --link package.json pnpm-lock.yaml* /app/ 11 | 12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch | \ 13 | grep -v "cross-device link not permitted\|Falling back to copying packages from store" 14 | 15 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm i --frozen-lockfile --ignore-scripts 16 | 17 | FROM node:lts-slim AS build 18 | 19 | WORKDIR /app 20 | ENV NEXT_TELEMETRY_DISABLED=1 21 | 22 | COPY --link --from=deps /app/node_modules ./node_modules/ 23 | COPY . /app 24 | 25 | RUN npm -g i corepack && \ 26 | corepack enable pnpm && \ 27 | pnpm run prepare && \ 28 | if [ "$(uname -m)" = "armv7l" ]; then \ 29 | echo "Building for ARMv7 architecture" && \ 30 | pnpm run build; \ 31 | else \ 32 | echo "Building for default architecture" && \ 33 | pnpm run build:turbo; \ 34 | fi && \ 35 | rm -rf .next/standalone/.next/cache 36 | 37 | FROM node:lts-alpine AS runner 38 | 39 | LABEL org.opencontainers.image.title="PeaNUT" 40 | LABEL org.opencontainers.image.description="A tiny dashboard for Network UPS Tools" 41 | LABEL org.opencontainers.image.url="https://github.com/Brandawg93/PeaNUT" 42 | LABEL org.opencontainers.image.source='https://github.com/Brandawg93/PeaNUT' 43 | LABEL org.opencontainers.image.licenses='Apache-2.0' 44 | 45 | COPY --from=build --link /app/.next/standalone ./ 46 | COPY --from=build --link /app/.next/static ./.next/static 47 | 48 | ENV CI=true 49 | ENV NODE_ENV=production 50 | ENV NEXT_TELEMETRY_DISABLED=1 51 | ENV WEB_HOST=0.0.0.0 52 | ENV WEB_PORT=8080 53 | 54 | EXPOSE $WEB_PORT 55 | 56 | HEALTHCHECK --interval=10s --timeout=3s --start-period=20s \ 57 | CMD wget --no-verbose --tries=1 --spider --no-check-certificate http://${WEB_HOST}:${WEB_PORT}/api/ping || exit 1 58 | 59 | CMD ["npm", "start"] 60 | -------------------------------------------------------------------------------- /__tests__/__mocks__/ldrs.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | helix: { register: jest.fn() }, 3 | dotPulse: { register: jest.fn() }, 4 | } 5 | -------------------------------------------------------------------------------- /__tests__/__mocks__/next-auth.ts: -------------------------------------------------------------------------------- 1 | const NextAuth = jest.fn().mockImplementation(() => ({ 2 | auth: jest.fn(), 3 | signIn: jest.fn(), 4 | signOut: jest.fn(), 5 | handlers: { 6 | GET: jest.fn(), 7 | POST: jest.fn(), 8 | }, 9 | })) 10 | 11 | export default NextAuth 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/ping/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Ping', () => { 4 | test('should reply with pong', async ({ request }) => { 5 | const create = await request.get('/api/ping') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toBe('pong') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/clients/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Clients', () => { 4 | test('should get clients', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/clients') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toHaveLength(1) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/command/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Command', () => { 4 | test('should run command', async ({ request }) => { 5 | const create = await request.post('/api/v1/devices/ups/command/driver.reload') 6 | const text = await create.text() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(text).toBe('"Command driver.reload on device ups run successfully on device ups"') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/commands/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Commands', () => { 4 | test('should get commands', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/commands') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toContain('driver.reload') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/description/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Description', () => { 4 | test('should get description', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/description') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toBe('CPS Test') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Device', () => { 4 | test('should get a device', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson['device.serial']).toBe('test1') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/var/description/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Var Description', () => { 4 | test('should get var description', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/var/device.serial/description') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toBe('Description unavailable') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/var/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Var', () => { 4 | test('should get var', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/var/device.serial') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toBe('test1') 10 | }) 11 | 12 | test('should save var', async ({ request }) => { 13 | const create = await request.post('/api/v1/devices/ups/var/battery.charge.low', { data: '9' }) 14 | const createJson = await create.json() 15 | 16 | expect(create.status()).toBe(200) 17 | expect(createJson).toBe('Variable battery.charge.low on device ups saved successfully on device ups') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/var/type/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Var Type', () => { 4 | test('should get var type', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/var/battery.charge.low/type') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toBe('RW NUMBER') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/[device]/vars/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Vars', () => { 4 | test('should get vars', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices/ups/vars') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson['device.serial']).toBe('test1') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/devices/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Devices', () => { 4 | test('should get devices', async ({ request }) => { 5 | const create = await request.get('/api/v1/devices') 6 | const createJson = await create.json() 7 | 8 | expect(create.status()).toBe(200) 9 | expect(createJson).toHaveLength(4) 10 | expect(createJson[0]['device.serial']).toBe('test1') 11 | expect(createJson[1]['device.serial']).toBe('test2') 12 | expect(createJson[2]['device.serial']).toBe('test3') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/metrics/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Metrics', () => { 4 | test('should get devices', async ({ request }) => { 5 | const create = await request.get('/api/v1/metrics') 6 | 7 | expect(create.status()).toBe(200) 8 | expect(create.headers()['content-type']).toBe('text/plain; version=0.0.4') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/netversion/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Netversion', () => { 4 | test('should get net version', async ({ request }) => { 5 | const req = await request.get('/api/v1/netversion') 6 | const json = await req.json() 7 | 8 | expect(req.status()).toBe(200) 9 | expect(json[0]).toBe('1.3') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/api/v1/version/route.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('Version', () => { 4 | test('should get version', async ({ request }) => { 5 | const req = await request.get('/api/v1/version') 6 | const json = await req.json() 7 | 8 | expect(req.status()).toBe(200) 9 | expect(json[0]).toContain('Network UPS Tools') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /__tests__/e2e/app/api/docs/page.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | const hostname = process.env.HOSTNAME || 'localhost' 4 | const port = process.env.PORT || '3000' 5 | 6 | test.describe('Docs', () => { 7 | test('renders the docs', async ({ page }) => { 8 | await page.goto(`http://${hostname}:${port}/api/docs`) 9 | const heading = await page.$('.swagger-ui') 10 | 11 | expect(heading).toBeDefined() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /__tests__/e2e/app/login/page.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | const hostname = process.env.HOSTNAME || 'localhost' 4 | const port = process.env.PORT || '3000' 5 | 6 | test.describe('Login', () => { 7 | test('renders the login', async ({ page }) => { 8 | await page.goto(`http://${hostname}:${port}/login`) 9 | const container = await page.$('[data-testid="login-wrapper"]') 10 | 11 | expect(container).toBeDefined() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /__tests__/e2e/app/page.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | const hostname = process.env.HOSTNAME || 'localhost' 4 | const port = process.env.PORT || '3000' 5 | 6 | test.describe('Home', () => { 7 | test('renders the index', async ({ page }) => { 8 | await page.goto(`http://${hostname}:${port}`) 9 | const grid = await page.$('[data-testid="grid"]') 10 | 11 | expect(grid).toBeDefined() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /__tests__/e2e/app/settings/page.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | const hostname = process.env.HOSTNAME || 'localhost' 4 | const port = process.env.PORT || '3000' 5 | 6 | test.describe('Settings', () => { 7 | test('renders the settings', async ({ page }) => { 8 | await page.goto(`http://${hostname}:${port}/settings`) 9 | const container = await page.$('[data-testid="settings-wrapper"]') 10 | 11 | expect(container).toBeDefined() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /__tests__/unit/app/page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import * as ReactQuery from '@tanstack/react-query' 4 | import Page from '@/app/page' 5 | import { checkSettings } from '@/app/actions' 6 | 7 | const queryClient = new ReactQuery.QueryClient() 8 | 9 | jest.mock('@tanstack/react-query', () => { 10 | const original: typeof ReactQuery = jest.requireActual('@tanstack/react-query') 11 | 12 | return { 13 | ...original, 14 | useQuery: jest.fn(), 15 | } 16 | }) 17 | 18 | jest.mock('../../../src/app/actions', () => ({ 19 | getDevices: jest.fn(), 20 | checkSettings: jest.fn(), 21 | disconnect: jest.fn(), 22 | getAllCommands: jest.fn().mockResolvedValue([]), 23 | })) 24 | 25 | global.fetch = jest.fn(() => 26 | Promise.resolve({ 27 | json: () => Promise.resolve([{ name: '1.0.0' }]), 28 | }) 29 | ) as jest.Mock 30 | 31 | describe('Home Page', () => { 32 | const mockDevicesData = { 33 | devices: [ 34 | { 35 | name: 'Device1', 36 | description: 'Test Device 1', 37 | vars: { 38 | 'ups.status': { value: 'OL' }, 39 | 'input.voltage': { value: '230' }, 40 | 'input.voltage.nominal': { value: '230' }, 41 | 'output.voltage': { value: '230' }, 42 | 'ups.realpower': { value: '100' }, 43 | 'ups.realpower.nominal': { value: '150' }, 44 | 'ups.load': { value: '50' }, 45 | 'battery.charge': { value: '80' }, 46 | 'battery.runtime': { value: '1200' }, 47 | 'ups.mfr': { value: 'Manufacturer' }, 48 | 'ups.model': { value: 'Model' }, 49 | 'device.serial': { value: '123456' }, 50 | }, 51 | rwVars: [], 52 | clients: [], 53 | commands: [], 54 | }, 55 | ], 56 | updated: '2023-10-01T00:00:00Z', 57 | } 58 | 59 | beforeEach(() => { 60 | ;(ReactQuery.useQuery as jest.Mock).mockReturnValue({ 61 | isLoading: false, 62 | data: mockDevicesData, 63 | refetch: jest.fn(), 64 | }) 65 | ;(checkSettings as jest.Mock).mockResolvedValue(true) 66 | }) 67 | 68 | it('renders a heading', async () => { 69 | render( 70 | 71 | 72 | 73 | ) 74 | 75 | const wrapper = await screen.findByTestId('wrapper') 76 | expect(wrapper).toBeInTheDocument() 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /__tests__/unit/app/settings/page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import Page from '@/app/settings/page' 4 | import { checkSettings, getSettings } from '@/app/actions' 5 | 6 | jest.mock('../../../../src/app/actions', () => ({ 7 | checkSettings: jest.fn(), 8 | getSettings: jest.fn(), 9 | })) 10 | 11 | global.fetch = jest.fn(() => 12 | Promise.resolve({ 13 | json: () => Promise.resolve([{ name: '1.0.0' }]), 14 | }) 15 | ) as jest.Mock 16 | 17 | describe('Settings Page', () => { 18 | beforeEach(() => { 19 | ;(checkSettings as jest.Mock).mockResolvedValue(true) 20 | }) 21 | 22 | it('renders a heading', async () => { 23 | ;(getSettings as jest.Mock).mockResolvedValueOnce([{ server: { HOST: 'localhost', PORT: 8080 }, saved: true }]) 24 | render() 25 | 26 | const wrapper = await screen.findByTestId('settings-wrapper') 27 | expect(wrapper).toBeInTheDocument() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /__tests__/unit/client/components/add-server.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, waitFor } from '@testing-library/react' 3 | import AddServer from '@/client/components/add-server' 4 | import { LanguageContext } from '@/client/context/language' 5 | 6 | describe('AddServer Component', () => { 7 | const mockHandleChange = jest.fn() 8 | const mockHandleRemove = jest.fn() 9 | const mockTestConnectionAction = jest.fn() 10 | 11 | const renderComponent = () => { 12 | return render( 13 | 14 | 23 | 24 | ) 25 | } 26 | 27 | test('renders without crashing', () => { 28 | const { getByTestId } = renderComponent() 29 | expect(getByTestId('server')).toBeInTheDocument() 30 | expect(getByTestId('port')).toBeInTheDocument() 31 | }) 32 | 33 | test('calls setServer on server input change', () => { 34 | const { getByTestId } = renderComponent() 35 | const serverInput = getByTestId('server') 36 | fireEvent.change(serverInput, { target: { value: 'new-server' } }) 37 | expect(mockHandleChange).toHaveBeenCalledWith('new-server', 8080, 'admin', 'nut_test') 38 | }) 39 | 40 | test('calls setPort on port input change', () => { 41 | const { getByTestId } = renderComponent() 42 | const portInput = getByTestId('port') 43 | fireEvent.change(portInput, { target: { value: '9090' } }) 44 | expect(mockHandleChange).toHaveBeenCalledWith('localhost', 9090, 'admin', 'nut_test') 45 | }) 46 | 47 | test('calls handleRemove on remove button click', () => { 48 | const { getByTitle } = renderComponent() 49 | const removeButton = getByTitle('settings.remove') 50 | fireEvent.click(removeButton) 51 | expect(mockHandleRemove).toHaveBeenCalled() 52 | }) 53 | 54 | test('calls setUsername on username input change', () => { 55 | const { getByTestId } = renderComponent() 56 | const usernameInput = getByTestId('username') 57 | fireEvent.change(usernameInput, { target: { value: 'new-user' } }) 58 | expect(mockHandleChange).toHaveBeenCalledWith('localhost', 8080, 'new-user', 'nut_test') 59 | }) 60 | 61 | test('calls setPassword on password input change', () => { 62 | const { getByTestId } = renderComponent() 63 | const passwordInput = getByTestId('password') 64 | fireEvent.change(passwordInput, { target: { value: 'new-password' } }) 65 | expect(mockHandleChange).toHaveBeenCalledWith('localhost', 8080, 'admin', 'new-password') 66 | }) 67 | 68 | test('toggles password visibility', () => { 69 | const { getByTestId } = renderComponent() 70 | const toggleButton = getByTestId('toggle-password') 71 | const passwordInput = getByTestId('password') 72 | 73 | // Initially password should be hidden 74 | expect(passwordInput).toHaveAttribute('type', 'password') 75 | 76 | // Click to show password 77 | fireEvent.click(toggleButton) 78 | expect(passwordInput).toHaveAttribute('type', 'text') 79 | 80 | // Click again to hide password 81 | fireEvent.click(toggleButton) 82 | expect(passwordInput).toHaveAttribute('type', 'password') 83 | }) 84 | 85 | test('calls testConnectionAction on test connection button click', async () => { 86 | mockTestConnectionAction.mockResolvedValue('Success') 87 | const { getByText } = renderComponent() 88 | const testButton = getByText('connect.test') 89 | fireEvent.click(testButton) 90 | expect(mockTestConnectionAction).toHaveBeenCalledWith('localhost', 8080, 'admin', 'nut_test') 91 | 92 | await waitFor(() => expect(testButton).not.toBeDisabled()) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /__tests__/unit/client/components/daynight.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { fireEvent, render, screen } from '@testing-library/react' 3 | import DayNightSwitch from '@/client/components/daynight' 4 | import LanguageProvider from '@/client/context/language' 5 | 6 | describe('Daynight', () => { 7 | let component: React.ReactElement 8 | beforeAll(() => { 9 | component = ( 10 | 11 | 12 | 13 | ) 14 | }) 15 | 16 | it('displays the correct initial theme icon', () => { 17 | const { getByTitle } = render(component) 18 | expect(getByTitle('theme.title')).toBeInTheDocument() 19 | }) 20 | 21 | it('changes theme on selection', async () => { 22 | const { getByTestId } = render(component) 23 | const select = await getByTestId('daynight-trigger') 24 | fireEvent.pointerDown(select) 25 | const option = await screen.findByText('theme.dark') 26 | fireEvent.click(option) 27 | expect(option).toHaveClass('self-center') 28 | }) 29 | 30 | it('displays the correct icon for each theme', async () => { 31 | const { getByTestId } = render(component) 32 | const select = await getByTestId('daynight-trigger') 33 | fireEvent.pointerDown(select) 34 | const optionDark = await screen.findByText('theme.dark') 35 | const optionLight = await screen.findByText('theme.light') 36 | const optionSystem = await screen.findByText('theme.system') 37 | fireEvent.click(optionDark) 38 | expect(optionDark).toHaveClass('self-center') 39 | fireEvent.click(optionLight) 40 | expect(optionLight).toHaveClass('self-center') 41 | fireEvent.click(optionSystem) 42 | expect(optionSystem).toHaveClass('self-center') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /__tests__/unit/client/components/footer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import Footer from '@/client/components/footer' 4 | 5 | global.fetch = jest.fn(() => 6 | Promise.resolve({ 7 | json: () => 8 | Promise.resolve([ 9 | { 10 | name: 'v1.0.0', 11 | published_at: '2021-01-01T00:00:00Z', 12 | html_url: '', 13 | }, 14 | ]), 15 | }) 16 | ) as jest.Mock 17 | 18 | describe('Footer', () => { 19 | beforeEach(() => { 20 | // Clear localStorage before each test 21 | localStorage.clear() 22 | }) 23 | 24 | it('renders', () => { 25 | const { getByTestId } = render(