├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── deploy-dev.yml │ └── deploy.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── backend ├── esbuild.config.js ├── eslint.config.mjs ├── index.ts ├── nodemon.json ├── package.json ├── src │ ├── config │ │ └── config.json │ ├── constants │ │ └── constants.ts │ ├── middleware │ │ ├── auth.middleware.ts │ │ ├── error-handler.ts │ │ └── rate-limiter.ts │ ├── routes │ │ ├── app-shortcut.route.ts │ │ ├── auth.route.ts │ │ ├── config.route.ts │ │ ├── deluge.route.ts │ │ ├── health.route.ts │ │ ├── index.ts │ │ ├── pihole-v6.route.ts │ │ ├── pihole.route.ts │ │ ├── qbittorrent.route.ts │ │ ├── system.route.ts │ │ ├── timezone.route.ts │ │ ├── transmission.route.ts │ │ ├── uploads.route.ts │ │ └── weather.route.ts │ ├── system-monitor │ │ └── index.ts │ ├── types │ │ ├── custom-error.ts │ │ ├── index.ts │ │ └── system-information-response.ts │ └── utils │ │ ├── config-lookup.ts │ │ ├── crypto.ts │ │ └── utils.ts └── tsconfig.json ├── docker-compose.yml ├── frontend ├── eslint.config.mjs ├── index.html ├── package.json ├── public │ ├── apple-touch-icon.png │ ├── gradient_logo.png │ ├── icon192.png │ ├── icon512_maskable.png │ ├── manifest.json │ └── space4k-min.webp ├── src │ ├── App.tsx │ ├── api │ │ └── dash-api.ts │ ├── assets │ │ ├── gradient_logo.png │ │ ├── jellyfin.png │ │ ├── jellyfin.svg │ │ ├── jellyseerr.png │ │ ├── pihole.svg │ │ ├── prowlarr.png │ │ ├── qb.png │ │ ├── radarr.png │ │ ├── readarr.png │ │ └── sonarr.png │ ├── components │ │ ├── Logo.tsx │ │ ├── ScrollToTop.tsx │ │ ├── dashboard │ │ │ ├── DashboardGrid.tsx │ │ │ ├── base-items │ │ │ │ ├── WiggleWrapper.tsx │ │ │ │ ├── apps │ │ │ │ │ ├── AppShortcut.tsx │ │ │ │ │ └── BlankAppShortcut.tsx │ │ │ │ └── widgets │ │ │ │ │ ├── BlankWidget.tsx │ │ │ │ │ ├── DateTimeWidget.tsx │ │ │ │ │ ├── DelugeWidget.tsx │ │ │ │ │ ├── DualWidget.tsx │ │ │ │ │ ├── DualWidgetContainer.tsx │ │ │ │ │ ├── EditMenu.tsx │ │ │ │ │ ├── GroupWidget.tsx │ │ │ │ │ ├── PiholeWidget │ │ │ │ │ └── PiholeWidget.tsx │ │ │ │ │ ├── QBittorrentWidget.tsx │ │ │ │ │ ├── StatusIndicator.tsx │ │ │ │ │ ├── SystemMonitorWidget │ │ │ │ │ ├── DiskUsageWidget.tsx │ │ │ │ │ ├── GaugeWidget.tsx │ │ │ │ │ └── SystemMonitorWidget.tsx │ │ │ │ │ ├── TorrentClientWidget.tsx │ │ │ │ │ ├── TransmissionWidget.tsx │ │ │ │ │ ├── WeatherWidget.tsx │ │ │ │ │ └── WidgetContainer.tsx │ │ │ └── sortable-items │ │ │ │ ├── apps │ │ │ │ └── SortableAppShortcut.tsx │ │ │ │ └── widgets │ │ │ │ ├── SortableDateTime.tsx │ │ │ │ ├── SortableDeluge.tsx │ │ │ │ ├── SortableDualWidget.tsx │ │ │ │ ├── SortableGroupWidget.tsx │ │ │ │ ├── SortablePihole.tsx │ │ │ │ ├── SortableQBittorrent.tsx │ │ │ │ ├── SortableSystemMonitor.tsx │ │ │ │ ├── SortableTransmission.tsx │ │ │ │ └── SortableWeather.tsx │ │ ├── forms │ │ │ ├── AddEditForm.tsx │ │ │ ├── FileInput.tsx │ │ │ ├── IconSearch.tsx │ │ │ ├── LoginForm.tsx │ │ │ ├── MultiFileInput.tsx │ │ │ ├── SettingsForm.tsx │ │ │ ├── SetupForm.tsx │ │ │ ├── VirtualizedListBox.tsx │ │ │ └── configs │ │ │ │ ├── AppShortcutConfig.tsx │ │ │ │ ├── DateTimeWidgetConfig.tsx │ │ │ │ ├── DualWidgetConfig.tsx │ │ │ │ ├── GroupWidgetConfig.tsx │ │ │ │ ├── PiholeWidgetConfig.tsx │ │ │ │ ├── PlaceholderConfig.tsx │ │ │ │ ├── SystemMonitorWidgetConfig.tsx │ │ │ │ ├── TorrentClientWidgetConfig.tsx │ │ │ │ ├── WeatherWidgetConfig.tsx │ │ │ │ ├── WidgetConfig.tsx │ │ │ │ └── index.ts │ │ ├── modals │ │ │ ├── CenteredModal.tsx │ │ │ ├── PopupManager.ts │ │ │ ├── SetupModal.tsx │ │ │ ├── UpdateModal.tsx │ │ │ └── VersionModal.tsx │ │ ├── navbar │ │ │ ├── ResponsiveAppBar.tsx │ │ │ ├── WithNav.tsx │ │ │ └── WithoutNav.tsx │ │ ├── search │ │ │ ├── GlobalSearch.tsx │ │ │ └── SearchBar.tsx │ │ └── toast │ │ │ ├── ToastInitializer.tsx │ │ │ └── ToastManager.tsx │ ├── constants │ │ ├── constants.ts │ │ ├── version.ts │ │ └── widget-dimensions.ts │ ├── context │ │ ├── AppContext.tsx │ │ ├── AppContextProvider.tsx │ │ └── useAppContext.tsx │ ├── env.d.ts │ ├── hooks │ │ ├── useIsMobile.tsx │ │ ├── useServiceStatus.tsx │ │ └── useWindowDimensions.tsx │ ├── index.tsx │ ├── main.tsx │ ├── pages │ │ ├── DashboardPage.tsx │ │ ├── LoginPage.tsx │ │ └── SettingsPage.tsx │ ├── theme │ │ ├── App.css │ │ ├── earthorbiter.ttf │ │ ├── index.css │ │ ├── styles.ts │ │ └── theme.ts │ ├── types │ │ ├── dnd.ts │ │ ├── group.ts │ │ └── index.ts │ ├── utils │ │ ├── updateChecker.ts │ │ ├── utils.ts │ │ └── version.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── kubernetes └── lab-dash │ ├── Chart.yaml │ ├── templates │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── ingress.yaml │ ├── pvc-config.yaml │ ├── pvc-uploads.yaml │ ├── pvc.yaml │ └── service.yaml │ └── values.yaml └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | frontend/node_modules 3 | backend/node_modules 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Logs** 27 | If applicable, add any logs related to your problem. 28 | 29 | **please complete the following information:** 30 | - OS: [e.g. Ubuntu 22.04, Linux, Windows] 31 | - Browser: [e.g. chrome, safari] 32 | - App Version: [e.g. 1.1.0] 33 | 34 | **Additional context** 35 | Add any other context about your specific setup relating or details about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yml: -------------------------------------------------------------------------------- 1 | name: deploy-beta 2 | 3 | on: 4 | push: 5 | branches: [ dev ] 6 | 7 | jobs: 8 | publish: 9 | permissions: 10 | contents: write 11 | packages: write 12 | attestations: write 13 | id-token: write 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout git repo 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Fetch all history for release notes 20 | 21 | - name: Install Node and NPM 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | 26 | - run: git fetch --all --tags 27 | 28 | - name: Version Check 29 | uses: thebongy/version-check@v2 30 | with: 31 | file: package.json 32 | tagFormat: v${version} 33 | failBuild: false 34 | id: version_check 35 | 36 | - name: Set up QEMU for multi-platform builds 37 | uses: docker/setup-qemu-action@v3 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | with: 42 | platforms: linux/amd64,linux/arm64,linux/arm/v7 43 | 44 | - name: Login to GitHub Container Registry 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Install 52 | run: npm install 53 | 54 | - name: Set repository name lowercase 55 | run: | 56 | echo "REPO_LOWER=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 57 | 58 | - name: Build & Push Multi-Architecture Docker Image 59 | uses: docker/build-push-action@v5 60 | with: 61 | context: . 62 | push: true 63 | platforms: linux/amd64,linux/arm64,linux/arm/v7 64 | tags: | 65 | ghcr.io/${{ env.REPO_LOWER }}-beta:latest 66 | ghcr.io/${{ env.REPO_LOWER }}-beta:v${{ steps.version_check.outputs.rawVersion }} 67 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | publish: 9 | permissions: 10 | contents: write 11 | packages: write 12 | attestations: write 13 | id-token: write 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout git repo 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Fetch all history for release notes 20 | 21 | - name: Install Node and NPM 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | 26 | - run: git fetch --all --tags 27 | 28 | - name: Version Check 29 | uses: thebongy/version-check@v2 30 | with: 31 | file: package.json 32 | tagFormat: v${version} 33 | failBuild: true 34 | id: version_check 35 | 36 | - name: Set up QEMU for multi-platform builds 37 | uses: docker/setup-qemu-action@v3 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | with: 42 | platforms: linux/amd64,linux/arm64,linux/arm/v7 43 | 44 | - name: Login to GitHub Container Registry 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Install 52 | run: npm install 53 | 54 | - name: Set repository name lowercase 55 | run: | 56 | echo "REPO_LOWER=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 57 | 58 | - name: Build & Push Multi-Architecture Docker Image 59 | uses: docker/build-push-action@v5 60 | with: 61 | context: . 62 | push: true 63 | platforms: linux/amd64,linux/arm64,linux/arm/v7 64 | tags: | 65 | ghcr.io/${{ env.REPO_LOWER }}:latest 66 | ghcr.io/${{ env.REPO_LOWER }}:v${{ steps.version_check.outputs.rawVersion }} 67 | 68 | - name: Create Release 69 | if: steps.version_check.outputs.versionChanged == 'true' 70 | run: | 71 | CURR_TAG="v${{ steps.version_check.outputs.rawVersion }}" 72 | gh release create "$CURR_TAG" --title "$CURR_TAG" --notes "Release $CURR_TAG" 73 | env: 74 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | 76 | - uses: AButler/upload-release-assets@v2.0 77 | with: 78 | files: 'docker-compose*' 79 | repo-token: ${{ secrets.GITHUB_TOKEN }} 80 | release-tag: "v${{ steps.version_check.outputs.rawVersion }}" 81 | 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | build 26 | backend/src/config/config.json 27 | backend/src/config/users.json 28 | backend/src/public/uploads 29 | backend/public/uploads/* 30 | package-lock.json 31 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official email address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [INSERT CONTACT METHOD]. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build (Backend) 2 | FROM node:lts-slim AS backend-build 3 | 4 | WORKDIR /usr/src/app 5 | COPY ./backend ./ 6 | 7 | # Check architecture and install Python 3 for ARM 8 | RUN apt-get update && \ 9 | ARCH=$(uname -m) && \ 10 | echo "Detected architecture: $ARCH" && \ 11 | if [ "$ARCH" = "armv7l" ] || [ "$ARCH" = "armhf" ] || [ "$ARCH" = "arm" ]; then \ 12 | echo "Installing Python 3 for ARM architecture" && \ 13 | apt-get install -y python3 python3-pip; \ 14 | else \ 15 | echo "Skipping Python 3 installation for architecture: $ARCH"; \ 16 | fi 17 | 18 | RUN npm install --omit-optional 19 | RUN npm run build 20 | 21 | # Build (Frontend) 22 | FROM node:lts-slim AS frontend-build 23 | WORKDIR /usr/src/app 24 | # Copy root package.json for version access 25 | COPY ./package.json ../package.json 26 | COPY ./frontend ./ 27 | RUN npm install 28 | ENV NODE_ENV=production 29 | RUN npm run build 30 | 31 | # Deploy (Backend) 32 | FROM node:lts-slim AS backend-deploy 33 | 34 | WORKDIR /app 35 | ENV NODE_ENV=production 36 | EXPOSE 2022 37 | 38 | # Install runtime dependencies and Python 3 for ARM 39 | RUN apt-get update && \ 40 | apt-get install -y iputils-ping lm-sensors ca-certificates && \ 41 | ARCH=$(uname -m) && \ 42 | echo "Detected architecture: $ARCH" && \ 43 | if [ "$ARCH" = "armv7l" ] || [ "$ARCH" = "armhf" ] || [ "$ARCH" = "arm" ]; then \ 44 | echo "Installing Python 3 for ARM architecture" && \ 45 | apt-get install -y python3 python3-pip; \ 46 | fi 47 | 48 | COPY --from=backend-build /usr/src/app/dist/config ../config 49 | COPY --from=backend-build /usr/src/app/dist/index.js ./ 50 | COPY --from=backend-build /usr/src/app/dist/package.json ./ 51 | COPY --from=frontend-build /usr/src/app/dist ./public 52 | RUN npm i --omit-dev --omit-optional 53 | CMD [ "node", "index.js" ] 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lab Dash 2 | This is an open-source user interface designed to be your internally hosted homepage for your homelab/server. 3 | 4 | Screenshot 2025-05-08 at 8 58 34 PM 5 | 6 | 7 | # Features 8 | Lab Dash features a customizable grid layout where you can add various widgets: 9 | - Shortcuts to your tools/services 10 | - System information 11 | - Service health checks 12 | - Custom widgets and more 13 | 14 | ### Customization 15 | You can easily customize your dashboard by: 16 | - Dragging and reordering 17 | - Changing the background image 18 | - Adding custom search providers 19 | - Custom title and tab name 20 | 21 | ### Privacy & Data Control 22 | You have complete control over your data and dashboard configuration. 23 | - All data is stored & used on your own device 24 | - Sensitive data is encrypted locally using [AES-256-CBC](https://docs.anchormydata.com/docs/what-is-aes-256-cbc) 25 | - Only administrator accounts can make changes 26 | - Configurations can be easily backed up and restored 27 | 28 | # Installation 29 | This only requires docker to be installed. [Install Docker](https://docs.docker.com/engine/install/). Run using `docker compose` 30 | ```yaml 31 | --- 32 | services: 33 | lab-dash: 34 | container_name: lab-dash 35 | image: ghcr.io/anthonygress/lab-dash:latest 36 | privileged: true 37 | network_mode: host # for monitoring network usage stats 38 | ports: 39 | - 2022:2022 40 | environment: 41 | - SECRET=YOUR_SECRET_KEY # any random string for used for encryption. 42 | # You can run `openssl rand -base64 32` to generate a key 43 | volumes: 44 | - /sys:/sys:ro 45 | - /docker/lab-dash/config:/config 46 | - /docker/lab-dash/uploads:/app/public/uploads 47 | - /var/run/docker.sock:/var/run/docker.sock 48 | restart: unless-stopped 49 | labels: 50 | - "com.centurylinklabs.watchtower.enable=true" 51 | 52 | ``` 53 | 54 | # Usage 55 | Lab Dash can aslo be accessed from any web browser via 56 | - `http://localhost:2022` on the device running the container 57 | - `192.168.x.x:2022` on local network 58 | - `www.your-homepage.com` using your custom domain name 59 | 60 | Lab Dash can also be installed as an app on your computer/phone as a PWA (Progressive Web App): 61 | - Using Google Chrome on Mac/Windows/Android/Linux 62 | - Using Safari on iOS/iPad OS via the share menu > add to homscreen 63 | 64 | Screenshot 2025-03-24 at 12 13 07 AM 65 | 66 | 67 | > [!IMPORTANT] 68 | > You should assign a static IP address for you server so any LAN/WAN device can access the Lab Dash instance. 69 | 70 | Simply copy/download the [docker-compose.yml](docker-compose.yml) or add it to an existing docker-compose file. 71 | 72 | 73 | ## Running Docker compose file 74 | 75 | ```bash 76 | docker compose up -d 77 | ``` 78 | 79 | This docker container will restart automatically after reboots unless it was manually stopped. This is designed to be run on your hosting server. 80 | 81 | ## Stopping this docker container 82 | 1. Navigate to the directory that this docker compose file is in 83 | 2. Run: `docker compose down` 84 | 85 | # Local Development 86 | ``` 87 | npm install 88 | npm run dev 89 | ``` 90 | 91 | # Updating 92 | ### Portainer 93 | - Navigate to stacks 94 | - Click on the `lab-dash` stack 95 | - Click Editor tab at the top 96 | - Click Update the stack 97 | - Enable Re-pull image and redploy toggle 98 | - Click Update 99 | 100 | ### Docker CLI: 101 | - `cd /directory_of_compose_yaml` 102 | - `docker compose down` 103 | - `docker compose pull` 104 | - `docker compose up -d` 105 | 106 | # Disclaimer 107 | This code is provided for informational and educational purposes only. I am not associated with any of the services/applications mentioned in this project. 108 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The following versions are currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 1.x.x | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you believe you have found a security vulnerability in any, please report it to us through coordinated disclosure. 14 | 15 | Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests. 16 | 17 | Instead, please report the vulnerability at https://github.com/AnthonyGress/lab-dash/security 18 | 19 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 20 | 21 | * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | -------------------------------------------------------------------------------- /backend/esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const { copy } = require('esbuild-plugin-copy'); 3 | const { nodeExternalsPlugin } = require('esbuild-node-externals'); 4 | 5 | const isProduction = process.env.NODE_ENV === 'production'; 6 | 7 | esbuild.build({ 8 | entryPoints: ['index.ts'], 9 | bundle: true, 10 | platform: 'node', 11 | target: 'node22', 12 | outfile: 'dist/index.js', 13 | tsconfig: 'tsconfig.json', 14 | sourcemap: !isProduction, 15 | minify: isProduction, 16 | external: ['express'], 17 | plugins: [ 18 | nodeExternalsPlugin(), 19 | copy({ 20 | assets: [ 21 | { from: ['./package.json'], to: ['./package.json'] }, 22 | { from: ['./src/config/config.json'], to: ['./config/config.json'] }, 23 | ], 24 | }), 25 | ], 26 | }).then(() => { 27 | console.log('✅ Build complete.'); 28 | }).catch(() => { 29 | console.error('❌ Build failed.'); 30 | process.exit(1); 31 | }); 32 | -------------------------------------------------------------------------------- /backend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import tseslint from 'typescript-eslint' 4 | import reactImport from 'eslint-plugin-import'; 5 | 6 | export default tseslint.config( 7 | { ignores: ['dist', '*.css'] }, 8 | { 9 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 10 | files: ['**/*.{ts,tsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.node, 14 | }, 15 | plugins: { 16 | 'import': reactImport 17 | }, 18 | rules: { 19 | 'import/no-extraneous-dependencies': 'off', 20 | 'import/extensions': 'off', 21 | 'import/no-unresolved': 'off', 22 | 'import/no-import-module-exports': 'off', 23 | 'no-shadow': 'off', 24 | '@typescript-eslint/no-shadow': 'error', 25 | 'no-unused-vars': 'off', 26 | '@typescript-eslint/no-unused-vars': 'warn', 27 | 'no-console': 'off', 28 | 'radix': 'off', 29 | 'global-require': 'off', 30 | 'import/no-dynamic-require': 'off', 31 | indent: ['warn', 4, { 32 | 'ignoredNodes': [ 33 | 'FunctionExpression > .params > :matches(Decorator, :not(:first-child))', 34 | 'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key', 35 | ], 36 | },], 37 | quotes: ['warn', 'single'], 38 | 'prettier/prettier': 0, 39 | 'object-curly-spacing': ['warn', 'always'], 40 | '@typescript-eslint/ban-types': 'off', 41 | '@typescript-eslint/no-var-requires': 'warn', 42 | '@typescript-eslint/no-non-null-assertion': 'off', 43 | 'import/prefer-default-export': 'off', 44 | 'spaced-comment': 'warn', 45 | 'lines-between-class-members': 'off', 46 | 'class-methods-use-this': 'off', 47 | 'no-return-await': 'off', 48 | 'no-undef': 'warn', 49 | 50 | 'no-plusplus': 'off', 51 | 52 | 'import/newline-after-import': 'off', 53 | 'promise/always-return': 'off', 54 | 'import/order': [ 55 | 'warn', 56 | { 57 | 'alphabetize': { 58 | 'caseInsensitive': true, 59 | 'order': 'asc' 60 | }, 61 | 'groups': [ 62 | ['builtin', 'external', 'object', 'type'], 63 | ['internal', 'parent', 'sibling', 'index'] 64 | ], 65 | 'newlines-between': 'always' 66 | } 67 | ], 68 | 'sort-imports': [ 69 | 'warn', 70 | { 71 | 'allowSeparatedGroups': true, 72 | 'ignoreCase': true, 73 | 'ignoreDeclarationSort': true, 74 | 'ignoreMemberSort': false, 75 | 'memberSyntaxSortOrder': ['none', 'all', 'multiple', 'single'] 76 | } 77 | ], 78 | 'jsx-quotes': ['warn', 'prefer-single'], 79 | 80 | '@typescript-eslint/no-explicit-any': 'warn', 81 | 'semi': 'warn' 82 | }, 83 | }, 84 | ) 85 | -------------------------------------------------------------------------------- /backend/index.ts: -------------------------------------------------------------------------------- 1 | import cookieParser from 'cookie-parser'; 2 | import cors from 'cors'; 3 | import dotenv from 'dotenv'; 4 | import express, { Application } from 'express'; 5 | import path from 'path'; 6 | 7 | import { UPLOAD_DIRECTORY } from './src/constants/constants'; 8 | import { errorHandler } from './src/middleware/error-handler'; 9 | import { authLimiter, generalLimiter } from './src/middleware/rate-limiter'; 10 | import routes from './src/routes'; 11 | 12 | dotenv.config(); 13 | 14 | const app: Application = express(); 15 | const PORT = Number(process.env.PORT) || 2022; 16 | 17 | const iconsPath = path.join(__dirname, './node_modules/@loganmarchione/homelab-svg-assets/assets'); 18 | const iconListPath = path.join(__dirname, './node_modules/@loganmarchione/homelab-svg-assets/icons.json'); 19 | 20 | // Middleware 21 | app.use(cors({ 22 | origin: true, 23 | credentials: true, 24 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 25 | allowedHeaders: ['Content-Type', 'Authorization'] 26 | })); 27 | app.use(cookieParser()); 28 | app.use(express.json()); 29 | app.use(express.urlencoded({ extended: true })); 30 | 31 | // Apply general rate limiter to all requests 32 | app.use(generalLimiter); 33 | 34 | // Routes 35 | app.use('/icon-list', express.static(iconListPath)); 36 | app.use('/icons', express.static(iconsPath)); 37 | app.use('/uploads', express.static(UPLOAD_DIRECTORY)); 38 | app.use('/api', routes); 39 | app.use(express.static(path.join(__dirname, 'public'))); 40 | app.get('*', (req, res) => { 41 | res.sendFile(path.join(__dirname, 'public', 'index.html')); 42 | }); 43 | 44 | // Global Error Handler 45 | app.use(errorHandler); 46 | 47 | // Start Server 48 | app.listen(PORT, '0.0.0.0', () => { 49 | console.log(`Server running on port ${PORT}, accessible via LAN`); 50 | }); 51 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "index.ts", 4 | "src" 5 | ], 6 | "ext": "ts", 7 | "exec": "ts-node index.ts" 8 | } 9 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lab-dash-backend", 3 | "main": "index.js", 4 | "scripts": { 5 | "dev": "PORT=5000 nodemon", 6 | "start": "node ./index.js", 7 | "build": "NODE_ENV=production node esbuild.config.js", 8 | "lint": "eslint .", 9 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 10 | "docker:build:dev": "docker build . -t ghcr.io/anthonygress/lab-dash-backend:latest", 11 | "docker:build": "docker build --platform linux/amd64 . -t ghcr.io/anthonygress/lab-dash-backend:latest", 12 | "docker:run": "docker run --platform=linux/amd64 -p 2022:80 -p 5000:5000 ghcr.io/anthonygress/lab-dash-backend:latest", 13 | "docker:run:dev": "docker run -p 2022:80 -p 5000:5000 ghcr.io/anthonygress/lab-dash-backend:latest", 14 | "docker:push": "docker push ghcr.io/anthonygress/lab-dash-backend:latest", 15 | "docker:stop": "docker ps -q --filter 'ancestor=ghcr.io/anthonygress/lab-dash-backend:latest' | xargs -r docker stop", 16 | "docker:rm": "docker ps -a -q --filter 'ancestor=ghcr.io/anthonygress/lab-dash-backend:latest' | xargs -r docker rm", 17 | "docker:clean": "npm run docker:stop && npm run docker:rm", 18 | "docker:restart": "npm run docker:stop && npm run docker:rm && npm run docker:run" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "ISC", 23 | "description": "", 24 | "dependencies": { 25 | "@loganmarchione/homelab-svg-assets": "^0.4.8", 26 | "@types/cookie-parser": "^1.4.8", 27 | "axios": "^1.8.3", 28 | "bcrypt": "^5.1.1", 29 | "cookie-parser": "^1.4.7", 30 | "cors": "^2.8.5", 31 | "dotenv": "^16.4.7", 32 | "express": "^4.21.2", 33 | "express-rate-limit": "^7.5.0", 34 | "geo-tz": "^8.1.4", 35 | "http-status-codes": "^2.3.0", 36 | "jsonwebtoken": "^9.0.2", 37 | "multer": "^1.4.5-lts.1", 38 | "systeminformation": "^5.25.11", 39 | "wol": "^1.0.7" 40 | }, 41 | "devDependencies": { 42 | "@types/bcrypt": "^5.0.2", 43 | "@types/cors": "^2.8.17", 44 | "@types/express": "^5.0.0", 45 | "@types/jsonwebtoken": "^9.0.9", 46 | "@types/multer": "^1.4.12", 47 | "@types/node": "^22.13.1", 48 | "@types/wol": "^1.0.4", 49 | "esbuild": "^0.25.0", 50 | "esbuild-node-externals": "^1.18.0", 51 | "esbuild-plugin-copy": "^2.1.1", 52 | "eslint": "^9.17.0", 53 | "eslint-plugin-import": "^2.28.1", 54 | "nodemon": "^3.1.9", 55 | "ts-node": "^10.9.2", 56 | "typescript": "^5.7.3", 57 | "typescript-eslint": "^8.18.2" 58 | }, 59 | "optionalDependencies": { 60 | "osx-temperature-sensor": "^1.0.8" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "layout": { 3 | "desktop": [], 4 | "mobile": [] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export const UPLOAD_DIRECTORY: string = path.join( 'public', 'uploads'); 4 | -------------------------------------------------------------------------------- /backend/src/middleware/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | const JWT_SECRET = process.env.SECRET || '@jZCgtn^qg8So*^^6A2M'; // Same secret used in auth.route.ts 5 | 6 | // Define custom Request interface with user property 7 | 8 | declare module 'express-serve-static-core' { 9 | interface Request { 10 | user?: { 11 | username: string; 12 | role: string; 13 | [key: string]: any; 14 | }; 15 | } 16 | } 17 | 18 | export const authenticateToken = (req: Request, res: Response, next: NextFunction): void => { 19 | // Check for token in cookies first (for login/logout/refresh routes) 20 | // console.log('#### req', req); 21 | const tokenFromCookie = req.cookies?.access_token; 22 | // console.log('#### req.token', req.cookies); 23 | 24 | // Then check Authorization header (for API routes that don't use cookies) 25 | const authHeader = req.headers.authorization; 26 | const tokenFromHeader = authHeader && authHeader.split(' ')[1]; 27 | 28 | // Use cookie token if available, otherwise use header token 29 | const token = tokenFromCookie || tokenFromHeader; 30 | 31 | if (!token) { 32 | res.status(401).json({ message: 'Authentication required' }); 33 | return; 34 | } 35 | 36 | jwt.verify(token, JWT_SECRET, (err: any, user: any) => { 37 | if (err) { 38 | res.status(401).json({ message: 'Invalid or expired token' }); 39 | return; 40 | } 41 | 42 | // Add user to request 43 | req.user = user; 44 | next(); 45 | }); 46 | }; 47 | 48 | // Middleware to check if user is an admin 49 | export const requireAdmin = (req: Request, res: Response, next: NextFunction): void => { 50 | // authenticateToken must be called before this middleware 51 | // console.log('#### req', req); 52 | 53 | if (!req.user) { 54 | res.status(401).json({ message: 'Authentication required' }); 55 | return; 56 | } 57 | 58 | if (req.user.role !== 'admin') { 59 | res.status(403).json({ message: 'Admin access required' }); 60 | return; 61 | } 62 | 63 | next(); 64 | }; 65 | -------------------------------------------------------------------------------- /backend/src/middleware/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | import { CustomError } from '../types/custom-error'; 4 | 5 | export const errorHandler = ( 6 | error: CustomError, 7 | req: Request, 8 | res: Response, 9 | _next: NextFunction 10 | ) => { 11 | console.error(`🔥 Error: ${error.message}`); 12 | 13 | const status = error.statusCode || 500; 14 | res.status(status).json({ 15 | success: false, 16 | message: error.message || 'Internal Server Error', 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /backend/src/middleware/rate-limiter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import rateLimit from 'express-rate-limit'; 3 | 4 | // Helper function to log rate limit hits with consistent format 5 | const logRateLimitHit = (req: Request, limiterName: string) => { 6 | const timestamp = new Date().toISOString(); 7 | const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown'; 8 | const method = req.method; 9 | const path = req.originalUrl || req.url; 10 | 11 | console.error(`Rate limit hit [${limiterName}] at ${timestamp}, Method: ${method}, Path: ${path}`); 12 | }; 13 | 14 | // General API rate limiter - used as the default 15 | export const generalLimiter = rateLimit({ 16 | windowMs: 5 * 60 * 1000, // 5 minutes 17 | max: 2000, 18 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 19 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 20 | handler: (req: Request, res: Response) => { 21 | logRateLimitHit(req, 'GENERAL'); 22 | res.status(429).json({ 23 | success: false, 24 | message: 'Too many requests from this general IP, please try again after 5 minutes', 25 | error_source: 'labdash_api' 26 | }); 27 | } 28 | }); 29 | 30 | // Auth endpoints rate limiter - more restrictive for security 31 | export const authLimiter = rateLimit({ 32 | windowMs: 5 * 60 * 1000, // 5 minutes 33 | max: 150, 34 | standardHeaders: true, 35 | legacyHeaders: false, 36 | handler: (req: Request, res: Response) => { 37 | logRateLimitHit(req, 'AUTH'); 38 | res.status(429).json({ 39 | success: false, 40 | message: 'Too many authentication attempts, please try again after 5 minutes', 41 | error_source: 'labdash_api' 42 | }); 43 | } 44 | }); 45 | 46 | // Internal/External API endpoints rate limiter - to prevent overwhelming third-party services 47 | export const apiLimiter = rateLimit({ 48 | windowMs: 5 * 60 * 1000, // 5 minutes 49 | max: 500, 50 | standardHeaders: true, 51 | legacyHeaders: false, 52 | handler: (req: Request, res: Response) => { 53 | logRateLimitHit(req, 'API'); 54 | res.status(429).json({ 55 | success: false, 56 | message: 'Too many API requests, please try again after 5 minutes', 57 | error_source: 'labdash_api' 58 | }); 59 | } 60 | }); 61 | 62 | // Health check endpoints limiter - higher limits for monitoring tools 63 | export const healthLimiter = rateLimit({ 64 | windowMs: 30 * 1000, 65 | max: 1000, 66 | standardHeaders: true, 67 | legacyHeaders: false, 68 | handler: (req: Request, res: Response) => { 69 | logRateLimitHit(req, 'HEALTH'); 70 | res.status(429).json({ 71 | success: false, 72 | message: 'Health check rate limit exceeded, please try again later', 73 | error_source: 'labdash_api' 74 | }); 75 | } 76 | }); 77 | 78 | // Weather API specific limiter - weather APIs often have strict rate limits 79 | export const weatherApiLimiter = rateLimit({ 80 | windowMs: 5 * 60 * 1000, // 5 minutes 81 | max: 100, 82 | standardHeaders: true, 83 | legacyHeaders: false, 84 | handler: (req: Request, res: Response) => { 85 | logRateLimitHit(req, 'WEATHER'); 86 | res.status(429).json({ 87 | success: false, 88 | message: 'Weather API rate limit exceeded, please try again later', 89 | error_source: 'labdash_api' 90 | }); 91 | } 92 | }); 93 | 94 | // Timezone API specific limiter 95 | export const timezoneApiLimiter = rateLimit({ 96 | windowMs: 5 * 60 * 1000, // 5 minutes 97 | max: 200, 98 | standardHeaders: true, 99 | legacyHeaders: false, 100 | handler: (req: Request, res: Response) => { 101 | logRateLimitHit(req, 'TIMEZONE'); 102 | res.status(429).json({ 103 | success: false, 104 | message: 'Timezone API rate limit exceeded, please try again later', 105 | error_source: 'labdash_api' 106 | }); 107 | } 108 | }); 109 | 110 | // Torrent client API limiter - prevent DDoS of torrent clients 111 | export const torrentApiLimiter = rateLimit({ 112 | windowMs: 60 * 1000, // 1 minute 113 | max: 100, 114 | standardHeaders: true, 115 | legacyHeaders: false, 116 | handler: (req: Request, res: Response) => { 117 | logRateLimitHit(req, 'TORRENT'); 118 | res.status(429).json({ 119 | success: false, 120 | message: 'Torrent client API rate limit exceeded, please try again later', 121 | error_source: 'labdash_api' 122 | }); 123 | } 124 | }); 125 | 126 | // System monitor API limiter 127 | export const systemMonitorLimiter = rateLimit({ 128 | windowMs: 60 * 1000, // 1 minute 129 | max: 100, 130 | standardHeaders: true, 131 | legacyHeaders: false, 132 | handler: (req: Request, res: Response) => { 133 | logRateLimitHit(req, 'SYSTEM'); 134 | res.status(429).json({ 135 | success: false, 136 | message: 'System monitor API rate limit exceeded, please try again later', 137 | error_source: 'labdash_api' 138 | }); 139 | } 140 | }); 141 | -------------------------------------------------------------------------------- /backend/src/routes/app-shortcut.route.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import fs from 'fs'; 3 | import multer from 'multer'; 4 | import path from 'path'; 5 | 6 | import { UPLOAD_DIRECTORY } from '../constants/constants'; 7 | import { authenticateToken } from '../middleware/auth.middleware'; 8 | 9 | export const appShortcutRoute = Router(); 10 | 11 | const sanitizeFileName = (fileName: string): string => { 12 | // Replace special characters and normalize spaces, but keep the extension 13 | return fileName 14 | .replace(/[^\w\s.-]/g, '') 15 | .replace(/[\s_-]+/g, ' ') 16 | .trim(); 17 | }; 18 | 19 | // Configure storage for file uploads 20 | const storage = multer.diskStorage({ 21 | destination: (req, file, cb) => { 22 | const uploadPath = path.join(UPLOAD_DIRECTORY, 'app-icons'); 23 | fs.mkdirSync(uploadPath, { recursive: true }); 24 | cb(null, uploadPath); 25 | }, 26 | filename: (req, file, cb) => { 27 | const originalName = path.parse(file.originalname).name; 28 | 29 | const sanitizedName = originalName 30 | .replace(/[^\w\s-]/g, '') 31 | .replace(/[\s_-]+/g, '-') 32 | .trim(); 33 | 34 | const timestamp = Date.now(); 35 | const ext = path.extname(file.originalname); 36 | 37 | // Final format: sanitizedOriginalName-timestamp.ext 38 | cb(null, `${sanitizedName}-${timestamp}${ext}`); 39 | } 40 | }); 41 | 42 | const upload = multer({ storage }); 43 | 44 | // Upload app icon (single file) 45 | appShortcutRoute.post('/upload', upload.single('file'), (req: Request, res: Response) => { 46 | if (!req.file) { 47 | res.status(400).json({ message: 'No file uploaded' }); 48 | return; 49 | } 50 | 51 | // Sanitize the file name for display (keeping extension) 52 | const sanitizedName = sanitizeFileName(req.file.originalname); 53 | 54 | console.log('File uploaded successfully:', { 55 | originalName: req.file.originalname, 56 | sanitizedName, 57 | filename: req.file.filename, 58 | path: req.file.path 59 | }); 60 | 61 | res.status(200).json({ 62 | message: 'App icon uploaded successfully', 63 | filePath: `/uploads/app-icons/${req.file.filename}`, 64 | name: sanitizedName, // Use sanitized name 65 | source: 'custom' 66 | }); 67 | }); 68 | 69 | // Upload multiple app icons (batch upload) 70 | appShortcutRoute.post('/upload-batch', authenticateToken, upload.array('files', 20), (req: Request, res: Response) => { 71 | const files = req.files as Express.Multer.File[]; 72 | 73 | if (!files || files.length === 0) { 74 | res.status(400).json({ message: 'No files uploaded' }); 75 | return; 76 | } 77 | 78 | const uploadedIcons = files.map(file => { 79 | const sanitizedName = sanitizeFileName(file.originalname); 80 | 81 | console.log('File uploaded successfully:', { 82 | originalName: file.originalname, 83 | sanitizedName, 84 | filename: file.filename, 85 | path: file.path 86 | }); 87 | 88 | return { 89 | name: sanitizedName, 90 | filePath: `/uploads/app-icons/${file.filename}`, 91 | source: 'custom' 92 | }; 93 | }); 94 | 95 | res.status(200).json({ 96 | message: `${uploadedIcons.length} app icon(s) uploaded successfully`, 97 | icons: uploadedIcons 98 | }); 99 | }); 100 | 101 | // Get list of custom app icons 102 | appShortcutRoute.get('/custom-icons', (req: Request, res: Response) => { 103 | try { 104 | const uploadPath = path.join(UPLOAD_DIRECTORY, 'app-icons'); 105 | 106 | // Create directory if it doesn't exist 107 | if (!fs.existsSync(uploadPath)) { 108 | fs.mkdirSync(uploadPath, { recursive: true }); 109 | res.json({ icons: [] }); 110 | return; 111 | } 112 | 113 | // Read the directory 114 | const files = fs.readdirSync(uploadPath); 115 | 116 | // Map files to icon objects 117 | const icons = files.map(file => { 118 | // Get the file name without extension and the extension separately 119 | const fileExtension = path.extname(file); 120 | const filenameWithoutExt = path.parse(file).name; 121 | 122 | // Extract the original name part (everything before the timestamp) 123 | // Format is: sanitizedName-timestamp 124 | const nameParts = filenameWithoutExt.split('-'); 125 | 126 | // If the filename has our expected format with a timestamp suffix, 127 | // remove the timestamp; otherwise keep the full name 128 | let displayNameWithoutExt = filenameWithoutExt; 129 | 130 | // Check if the last part is a timestamp (all digits) 131 | if (nameParts.length > 1 && /^\d+$/.test(nameParts[nameParts.length - 1])) { 132 | // Remove the timestamp part and join the rest 133 | displayNameWithoutExt = nameParts.slice(0, -1).join('-'); 134 | } 135 | 136 | // Ensure the display name is sanitized and add back the extension 137 | const displayName = sanitizeFileName(displayNameWithoutExt + fileExtension); 138 | 139 | // Create the icon object 140 | return { 141 | name: displayName, 142 | path: `/uploads/app-icons/${file}`, 143 | source: 'custom' 144 | }; 145 | }); 146 | 147 | res.json({ icons }); 148 | } catch (error) { 149 | console.error('Error reading custom icons:', error); 150 | res.status(500).json({ message: 'Failed to retrieve custom icons' }); 151 | } 152 | }); 153 | -------------------------------------------------------------------------------- /backend/src/routes/health.route.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { exec } from 'child_process'; 3 | import { Request, Response, Router } from 'express'; 4 | import https from 'https'; 5 | import { URL } from 'url'; 6 | 7 | export const healthRoute = Router(); 8 | 9 | const httpsAgent = new https.Agent({ 10 | rejectUnauthorized: false, 11 | }); 12 | 13 | // Helper function to ping a hostname 14 | const pingHost = (hostname: string): Promise => { 15 | return new Promise((resolve) => { 16 | exec(`ping -c 1 -W 1 ${hostname}`, (error) => { 17 | if (error) { 18 | resolve(false); 19 | } else { 20 | resolve(true); 21 | } 22 | }); 23 | }); 24 | }; 25 | 26 | healthRoute.get('/', async (req: Request, res: Response): Promise => { 27 | const { url, type } = req.query; 28 | 29 | if (!url || typeof url !== 'string') { 30 | res.status(400).json({ status: 'error', message: 'Invalid or missing URL' }); 31 | return; 32 | } 33 | 34 | const checkType = type as string || 'http'; 35 | 36 | try { 37 | // For ping type health checks 38 | if (checkType === 'ping') { 39 | try { 40 | // For ping, the URL parameter is just the hostname 41 | const isReachable = await pingHost(url); 42 | res.json({ status: isReachable ? 'online' : 'offline' }); 43 | return; 44 | } catch (pingError) { 45 | console.log('Ping failed for', url); 46 | res.json({ status: 'offline' }); 47 | return; 48 | } 49 | } 50 | 51 | // For HTTP health checks (default) 52 | const response = await axios.get(url, { 53 | timeout: 5000, 54 | httpsAgent, 55 | responseType: 'text', 56 | validateStatus: () => true // Accept any HTTP status code 57 | }); 58 | 59 | if (response.status >= 200 && response.status < 400) { 60 | res.json({ status: 'online' }); 61 | return; 62 | } 63 | } catch (error) { 64 | console.log('service is offline', req.query.url); 65 | res.json({ status: 'offline' }); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /backend/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { appShortcutRoute } from './app-shortcut.route'; 4 | import { authRoute } from './auth.route'; 5 | import { configRoute } from './config.route'; 6 | import { delugeRoute } from './deluge.route'; 7 | import { healthRoute } from './health.route'; 8 | import { piholeV6Route } from './pihole-v6.route'; 9 | import { piholeRoute } from './pihole.route'; 10 | import { qbittorrentRoute } from './qbittorrent.route'; 11 | import { systemRoute } from './system.route'; 12 | import { timezoneRoute } from './timezone.route'; 13 | import { transmissionRoute } from './transmission.route'; 14 | import { uploadsRoute } from './uploads.route'; 15 | import { weatherRoute } from './weather.route'; 16 | import { 17 | apiLimiter, 18 | authLimiter, 19 | healthLimiter, 20 | systemMonitorLimiter, 21 | timezoneApiLimiter, 22 | torrentApiLimiter, 23 | weatherApiLimiter 24 | } from '../middleware/rate-limiter'; 25 | 26 | const router = Router(); 27 | 28 | // Config routes should be protected by general rate limiter middleware already 29 | router.use('/config', configRoute); 30 | 31 | // System routes - use dedicated system monitor limiter 32 | router.use('/system', systemMonitorLimiter, systemRoute); 33 | 34 | // Health check route 35 | router.use('/health', healthLimiter, healthRoute); 36 | 37 | // Weather routes 38 | router.use('/weather', weatherApiLimiter, weatherRoute); 39 | 40 | // Timezone routes 41 | router.use('/timezone', timezoneApiLimiter, timezoneRoute); 42 | 43 | // App shortcut routes 44 | router.use('/app-shortcut', apiLimiter, appShortcutRoute); 45 | 46 | // Uploads management routes 47 | router.use('/uploads', apiLimiter, uploadsRoute); 48 | 49 | // Torrent client routes 50 | router.use('/qbittorrent', torrentApiLimiter, qbittorrentRoute); 51 | router.use('/transmission', torrentApiLimiter, transmissionRoute); 52 | 53 | // Pi-hole routes 54 | router.use('/pihole', apiLimiter, piholeRoute); 55 | 56 | // Pi-hole v6 routes (separate to maintain backward compatibility) 57 | router.use('/pihole/v6', apiLimiter, piholeV6Route); 58 | 59 | // Deluge routes 60 | router.use('/deluge', torrentApiLimiter, delugeRoute); 61 | 62 | router.use('/auth', authLimiter, authRoute); 63 | 64 | export default router; 65 | -------------------------------------------------------------------------------- /backend/src/routes/timezone.route.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import { find as findTimezone } from 'geo-tz'; 3 | import StatusCodes from 'http-status-codes'; 4 | 5 | export const timezoneRoute = Router(); 6 | 7 | /** 8 | * GET /timezone 9 | * Requires query parameters `latitude` and `longitude`. 10 | * Example request: 11 | * GET /timezone?latitude=27.87&longitude=-82.626 12 | * Returns: 13 | * { timezone: "America/New_York" } 14 | */ 15 | timezoneRoute.get('/', async (req: Request, res: Response): Promise => { 16 | try { 17 | // Validate required parameters 18 | if (!req.query.latitude || !req.query.longitude) { 19 | res.status(StatusCodes.BAD_REQUEST).json({ 20 | error: 'Both latitude and longitude are required parameters' 21 | }); 22 | return; 23 | } 24 | 25 | const latitude = parseFloat(req.query.latitude as string); 26 | const longitude = parseFloat(req.query.longitude as string); 27 | 28 | // Validate latitude and longitude 29 | if (isNaN(latitude) || isNaN(longitude)) { 30 | res.status(StatusCodes.BAD_REQUEST).json({ 31 | error: 'Latitude and longitude must be valid numbers' 32 | }); 33 | return; 34 | } 35 | 36 | if (latitude < -90 || latitude > 90) { 37 | res.status(StatusCodes.BAD_REQUEST).json({ 38 | error: 'Latitude must be between -90 and 90' 39 | }); 40 | return; 41 | } 42 | 43 | if (longitude < -180 || longitude > 180) { 44 | res.status(StatusCodes.BAD_REQUEST).json({ 45 | error: 'Longitude must be between -180 and 180' 46 | }); 47 | return; 48 | } 49 | 50 | // Use geo-tz to find the timezone for the coordinates 51 | const timezones = findTimezone(latitude, longitude); 52 | 53 | // Use the first timezone found (geo-tz can return multiple) 54 | const timezone = timezones.length > 0 ? timezones[0] : null; 55 | 56 | if (!timezone) { 57 | res.status(StatusCodes.NOT_FOUND).json({ 58 | error: 'Could not determine timezone for the provided coordinates' 59 | }); 60 | return; 61 | } 62 | 63 | res.json({ timezone }); 64 | 65 | } catch (error) { 66 | console.error('Error determining timezone:', error); 67 | res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 68 | error: 'Error processing timezone request' 69 | }); 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /backend/src/routes/uploads.route.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | import { UPLOAD_DIRECTORY } from '../constants/constants'; 6 | import { authenticateToken } from '../middleware/auth.middleware'; 7 | 8 | export const uploadsRoute = Router(); 9 | 10 | // Get list of all uploaded images 11 | uploadsRoute.get('/images', authenticateToken, (req: Request, res: Response) => { 12 | try { 13 | const images: Array<{ 14 | name: string; 15 | path: string; 16 | size: number; 17 | uploadDate: Date; 18 | type: 'app-icon' | 'background' | 'other'; 19 | }> = []; 20 | 21 | // Check app-icons directory 22 | const appIconsPath = path.join(UPLOAD_DIRECTORY, 'app-icons'); 23 | if (fs.existsSync(appIconsPath)) { 24 | const appIconFiles = fs.readdirSync(appIconsPath); 25 | appIconFiles.forEach(file => { 26 | const filePath = path.join(appIconsPath, file); 27 | const stats = fs.statSync(filePath); 28 | 29 | // Only include image files 30 | if (/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) { 31 | // Extract display name by removing timestamp suffix but keeping extension 32 | const fileExtension = path.extname(file); 33 | const filenameWithoutExt = path.parse(file).name; 34 | const nameParts = filenameWithoutExt.split('-'); 35 | 36 | let displayNameWithoutExt = filenameWithoutExt; 37 | 38 | // Check if the last part is a timestamp (all digits) 39 | if (nameParts.length > 1 && /^\d+$/.test(nameParts[nameParts.length - 1])) { 40 | // Remove the timestamp part and join the rest 41 | displayNameWithoutExt = nameParts.slice(0, -1).join('-'); 42 | } 43 | 44 | // Apply the same sanitization as in app-shortcut route 45 | const sanitizeFileName = (fileName: string): string => { 46 | return fileName.trim(); 47 | }; 48 | 49 | const displayName = sanitizeFileName(displayNameWithoutExt) + fileExtension; 50 | 51 | images.push({ 52 | name: displayName, 53 | path: `/uploads/app-icons/${file}`, 54 | size: stats.size, 55 | uploadDate: stats.birthtime, 56 | type: 'app-icon' 57 | }); 58 | } 59 | }); 60 | } 61 | 62 | // Check for background images in the main uploads directory 63 | if (fs.existsSync(UPLOAD_DIRECTORY)) { 64 | const uploadFiles = fs.readdirSync(UPLOAD_DIRECTORY); 65 | uploadFiles.forEach(file => { 66 | const filePath = path.join(UPLOAD_DIRECTORY, file); 67 | const stats = fs.statSync(filePath); 68 | 69 | // Only include image files and exclude directories 70 | if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) { 71 | images.push({ 72 | name: file, 73 | path: `/uploads/${file}`, 74 | size: stats.size, 75 | uploadDate: stats.birthtime, 76 | type: 'background' // All images in root uploads directory are background images 77 | }); 78 | } 79 | }); 80 | } 81 | 82 | // Sort by upload date (newest first) 83 | images.sort((a, b) => b.uploadDate.getTime() - a.uploadDate.getTime()); 84 | 85 | res.json({ images }); 86 | } catch (error) { 87 | console.error('Error reading uploaded images:', error); 88 | res.status(500).json({ message: 'Failed to retrieve uploaded images' }); 89 | } 90 | }); 91 | 92 | // Delete an uploaded image 93 | uploadsRoute.delete('/images', authenticateToken, (req: Request, res: Response) => { 94 | try { 95 | const { imagePath } = req.body; 96 | 97 | if (!imagePath) { 98 | res.status(400).json({ message: 'Image path is required' }); 99 | return; 100 | } 101 | 102 | // Validate that the path is within the uploads directory 103 | const fullPath = path.join(process.cwd(), 'public', imagePath); 104 | const uploadsDir = path.join(process.cwd(), 'public', 'uploads'); 105 | 106 | if (!fullPath.startsWith(uploadsDir)) { 107 | res.status(400).json({ message: 'Invalid image path' }); 108 | return; 109 | } 110 | 111 | // Check if file exists 112 | if (!fs.existsSync(fullPath)) { 113 | res.status(404).json({ message: 'Image not found' }); 114 | return; 115 | } 116 | 117 | // Delete the file 118 | fs.unlinkSync(fullPath); 119 | 120 | res.json({ message: 'Image deleted successfully' }); 121 | } catch (error) { 122 | console.error('Error deleting image:', error); 123 | res.status(500).json({ message: 'Failed to delete image' }); 124 | } 125 | }); 126 | -------------------------------------------------------------------------------- /backend/src/routes/weather.route.ts: -------------------------------------------------------------------------------- 1 | // src/routes/weather.route.ts 2 | import axios from 'axios'; 3 | import { Request, Response, Router } from 'express'; 4 | import StatusCodes from 'http-status-codes'; 5 | 6 | export const weatherRoute = Router(); 7 | 8 | /** 9 | * GET /weather 10 | * Requires query parameters `latitude` and `longitude`. 11 | * Example request: 12 | * GET /weather?latitude=27.87&longitude=-82.626 13 | */ 14 | weatherRoute.get('/', async (req: Request, res: Response): Promise => { 15 | try { 16 | // Validate required parameters 17 | if (!req.query.latitude || !req.query.longitude) { 18 | res.status(StatusCodes.BAD_REQUEST).json({ error: 'Both latitude and longitude are required parameters' }); 19 | return; 20 | } 21 | 22 | const latitude = req.query.latitude; 23 | const longitude = req.query.longitude; 24 | 25 | // Fetch weather data from Open-Meteo with timeout 26 | const weatherResponse = await axios.get('https://api.open-meteo.com/v1/forecast', { 27 | params: { 28 | latitude: latitude, 29 | longitude: longitude, 30 | current: 'temperature_2m,weathercode,windspeed_10m', 31 | daily: 'temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset', 32 | timezone: 'auto' 33 | }, 34 | timeout: 5000 // 5 second timeout 35 | }); 36 | 37 | res.json(weatherResponse.data); 38 | 39 | } catch (error) { 40 | let statusCode = StatusCodes.INTERNAL_SERVER_ERROR; 41 | let errorMessage = 'Error fetching weather data'; 42 | 43 | if (axios.isAxiosError(error)) { 44 | if (error.code === 'ECONNABORTED') { 45 | statusCode = StatusCodes.GATEWAY_TIMEOUT; 46 | errorMessage = 'Weather API timeout'; 47 | } else if (error.response) { 48 | statusCode = error.response.status; 49 | errorMessage = `Weather API error: ${error.response.statusText}`; 50 | } 51 | 52 | console.error(`Weather API error: ${errorMessage}`, { 53 | status: statusCode, 54 | message: error.message, 55 | url: error.config?.url, 56 | params: error.config?.params 57 | }); 58 | } else { 59 | console.error('Unknown error fetching weather:', error); 60 | } 61 | 62 | res.status(statusCode).json({ error: errorMessage }); 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /backend/src/system-monitor/index.ts: -------------------------------------------------------------------------------- 1 | import si, { Systeminformation } from 'systeminformation'; 2 | 3 | import { SysteminformationResponse } from '../types/system-information-response'; 4 | 5 | export const getSystemInfo = async (networkInterface?: string): Promise => { 6 | try { 7 | const results = await Promise.allSettled([ 8 | si.cpu(), 9 | si.cpuTemperature(), 10 | si.currentLoad(), 11 | si.osInfo(), 12 | Promise.resolve(si.time()), // synchronous 13 | si.fsSize(), 14 | si.mem(), 15 | si.memLayout(), 16 | si.networkStats(), 17 | si.networkInterfaces() 18 | ]); 19 | 20 | // extract values 21 | const cpuInfo = results[0].status === 'fulfilled' ? (results[0].value as Systeminformation.CpuData) : null; 22 | const cpuTemp = results[1].status === 'fulfilled' ? (results[1].value as Systeminformation.CpuTemperatureData) : null; 23 | const currentLoad = results[2].status === 'fulfilled' ? (results[2].value as Systeminformation.CurrentLoadData) : null; 24 | const os = results[3].status === 'fulfilled' ? (results[3].value as Systeminformation.OsData) : null; 25 | const uptime = results[4].status === 'fulfilled' ? (results[4].value as Systeminformation.TimeData) : null; 26 | const disk = results[5].status === 'fulfilled' ? (results[5].value as Systeminformation.FsSizeData[]) : null; 27 | const memoryInfo = results[6].status === 'fulfilled' ? (results[6].value as Systeminformation.MemData) : null; 28 | const memoryLayout = results[7].status === 'fulfilled' ? (results[7].value as Systeminformation.MemLayoutData[]) : null; 29 | const networkInfo = results[8].status === 'fulfilled' ? (results[8].value as Systeminformation.NetworkStatsData[]) : null; 30 | const networkInterfaces = results[9].status === 'fulfilled' ? (results[9].value as Systeminformation.NetworkInterfacesData[]) : null; 31 | 32 | // construct return objects 33 | const cpu = cpuInfo 34 | ? { ...cpuInfo, ...cpuTemp, currentLoad: currentLoad?.currentLoad || 0 } 35 | : null; 36 | const system = os ? { ...os, ...uptime } : null; 37 | const totalInstalled = memoryLayout && memoryLayout.length > 0 38 | ? memoryLayout.reduce((total, slot) => total + slot.size, 0) / (1024 ** 3) 39 | : 0; 40 | const memory = memoryLayout && memoryInfo && { ...memoryInfo, totalInstalled }; 41 | 42 | // Get the primary network interface - look for active interfaces first 43 | let network = null; 44 | if (networkInfo && networkInfo.length > 0) { 45 | let selectedInterface; 46 | 47 | // If a specific interface was requested, use it 48 | if (networkInterface && networkInterfaces) { 49 | // Find the specified interface in network stats 50 | selectedInterface = networkInfo.find(iface => 51 | iface.iface === networkInterface 52 | ); 53 | } 54 | 55 | // If no specific interface or it wasn't found, use the automatic selection logic 56 | if (!selectedInterface) { 57 | // First try to find a "real" interface with traffic (excluding loopback) 58 | const activeInterface = networkInfo.find(iface => 59 | iface.operstate === 'up' && 60 | (iface.rx_sec > 0 || iface.tx_sec > 0) && 61 | !iface.iface.includes('lo') && 62 | !iface.iface.includes('loop')); 63 | 64 | // If no active interface found, get the first non-loopback up interface 65 | const upInterface = !activeInterface 66 | ? networkInfo.find(iface => 67 | iface.operstate === 'up' && 68 | !iface.iface.includes('lo') && 69 | !iface.iface.includes('loop')) 70 | : null; 71 | 72 | // Use the best interface we found, or fall back to the first one as a last resort 73 | selectedInterface = activeInterface || upInterface || networkInfo[0]; 74 | } 75 | 76 | // Find the corresponding interface in networkInterfaces to get speed 77 | let speed = 0; 78 | if (networkInterfaces && selectedInterface) { 79 | const matchingInterface = networkInterfaces.find( 80 | ni => ni.iface === selectedInterface.iface 81 | ); 82 | 83 | if (matchingInterface && typeof matchingInterface.speed === 'number' && matchingInterface.speed > 0) { 84 | speed = matchingInterface.speed; // Speed in Mbps 85 | } 86 | } 87 | 88 | network = { 89 | rx_sec: selectedInterface.rx_sec || 0, 90 | tx_sec: selectedInterface.tx_sec || 0, 91 | iface: selectedInterface.iface || '', 92 | operstate: selectedInterface.operstate || '', 93 | speed 94 | }; 95 | 96 | } else { 97 | console.log('No network interfaces found'); 98 | } 99 | 100 | // Include all network interfaces in the response for the UI to display options 101 | const allNetworkInterfaces = networkInterfaces && networkInfo 102 | ? networkInfo 103 | .filter(iface => 104 | // Filter interfaces that have rx_sec and tx_sec values 105 | iface.operstate === 'up' && 106 | !iface.iface.includes('lo') && 107 | !iface.iface.includes('loop') && 108 | typeof iface.rx_sec === 'number' && 109 | typeof iface.tx_sec === 'number' && 110 | iface.rx_sec > 0 && 111 | iface.tx_sec > 0 112 | ) 113 | .map(iface => { 114 | // Find the corresponding interface details from networkInterfaces 115 | const matchingInterface = networkInterfaces.find(ni => ni.iface === iface.iface); 116 | 117 | return { 118 | iface: iface.iface, 119 | operstate: iface.operstate, 120 | speed: matchingInterface?.speed || 0, 121 | rx_bytes: iface.rx_bytes || 0, 122 | tx_bytes: iface.tx_bytes || 0, 123 | rx_sec: iface.rx_sec || 0, 124 | tx_sec: iface.tx_sec || 0 125 | }; 126 | }) 127 | : []; 128 | 129 | return { cpu, system, memory, disk, network, networkInterfaces: allNetworkInterfaces }; 130 | } catch (e) { 131 | console.error('Error fetching system info:', e); 132 | return null; 133 | } 134 | }; 135 | -------------------------------------------------------------------------------- /backend/src/types/custom-error.ts: -------------------------------------------------------------------------------- 1 | export class CustomError extends Error { 2 | statusCode: number; 3 | 4 | constructor(message: string, statusCode: number) { 5 | super(message); 6 | this.statusCode = statusCode; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum ITEM_TYPE { 2 | WEATHER_WIDGET = 'weather-widget', 3 | DATE_TIME_WIDGET = 'date-time-widget', 4 | SYSTEM_MONITOR_WIDGET = 'system-monitor-widget', 5 | TORRENT_CLIENT = 'torrent-client', 6 | PIHOLE_WIDGET = 'pihole-widget', 7 | DUAL_WIDGET = 'dual-widget', 8 | GROUP_WIDGET = 'group-widget', 9 | APP_SHORTCUT = 'app-shortcut', 10 | PLACEHOLDER = 'placeholder', 11 | // Legacy placeholder types - keeping for backward compatibility 12 | BLANK_APP = 'blank-app', 13 | BLANK_WIDGET = 'blank-widget', 14 | BLANK_ROW = 'blank-row', 15 | PAGE = 'page' 16 | } 17 | 18 | export type Page = { 19 | id: string; 20 | name: string; 21 | adminOnly?: boolean; 22 | layout: { 23 | desktop: DashboardItem[]; 24 | mobile: DashboardItem[]; 25 | }; 26 | } 27 | 28 | export type Config = { 29 | layout: { 30 | desktop: DashboardItem[]; 31 | mobile: DashboardItem[]; 32 | }, 33 | pages?: Page[]; 34 | title?: string; 35 | backgroundImage?: string; 36 | search?: boolean; 37 | searchProvider?: string; 38 | isSetupComplete?: boolean; 39 | lastSeenVersion?: string; 40 | } 41 | 42 | export type DashboardLayout = { 43 | desktop: DashboardItem[]; 44 | mobile: DashboardItem[]; 45 | } 46 | 47 | export type DashboardItem = { 48 | id: string; 49 | label: string; 50 | type: string; 51 | url?: string; 52 | icon?: { path: string; name: string; source?: string; }; 53 | showLabel?: boolean; 54 | adminOnly?: boolean; 55 | config?: { 56 | [key: string]: any; 57 | }; 58 | }; 59 | 60 | -------------------------------------------------------------------------------- /backend/src/types/system-information-response.ts: -------------------------------------------------------------------------------- 1 | import { Systeminformation } from 'systeminformation'; 2 | 3 | export type SysteminformationResponse = { 4 | cpu: { 5 | currentLoad: number; 6 | main?: number | undefined; 7 | cores: number | number[]; 8 | max?: number | undefined; 9 | socket: string | number[]; 10 | chipset?: number; 11 | manufacturer: string; 12 | brand: string; 13 | vendor: string; 14 | family: string; 15 | model: string; 16 | stepping: string; 17 | revision: string; 18 | voltage: string; 19 | speed: number; 20 | speedMin: number; 21 | speedMax: number; 22 | governor: string; 23 | physicalCores: number; 24 | efficiencyCores?: number; 25 | performanceCores?: number; 26 | processors: number; 27 | flags: string; 28 | virtualization: boolean; 29 | cache: Systeminformation.CpuCacheData; 30 | } | null; 31 | 32 | system: { 33 | current?: number | undefined; 34 | uptime?: number | undefined; 35 | timezone?: string | undefined; 36 | timezoneName?: string | undefined; 37 | platform: string; 38 | distro: string; 39 | release: string; 40 | codename: string; 41 | kernel: string; 42 | arch: string; 43 | hostname: string; 44 | fqdn: string; 45 | codepage: string; 46 | logofile: string; 47 | serial: string; 48 | build: string; 49 | servicepack: string; 50 | uefi: boolean | null; 51 | hypervizor?: boolean; 52 | remoteSession?: boolean; 53 | } | null; 54 | 55 | memory: Systeminformation.MemData | null; 56 | disk: Systeminformation.FsSizeData[] | null; 57 | network: { 58 | rx_sec: number; 59 | tx_sec: number; 60 | iface: string; 61 | operstate: string; 62 | speed?: number; 63 | } | null; 64 | networkInterfaces?: Array<{ 65 | iface: string; 66 | operstate: string; 67 | speed: number; 68 | rx_bytes: number; 69 | tx_bytes: number; 70 | rx_sec: number; 71 | tx_sec: number; 72 | }>; 73 | } 74 | 75 | -------------------------------------------------------------------------------- /backend/src/utils/config-lookup.ts: -------------------------------------------------------------------------------- 1 | import fsSync from 'fs'; 2 | import path from 'path'; 3 | 4 | import { Config, DashboardItem } from '../types'; 5 | 6 | const CONFIG_FILE = path.join(__dirname, '../config/config.json'); 7 | 8 | /** 9 | * Load the configuration from disk 10 | */ 11 | export const loadConfig = (): Config => { 12 | if (fsSync.existsSync(CONFIG_FILE)) { 13 | return JSON.parse(fsSync.readFileSync(CONFIG_FILE, 'utf-8')); 14 | } 15 | return { layout: { desktop: [], mobile: [] } }; 16 | }; 17 | 18 | /** 19 | * Find a dashboard item by its ID across all layouts and pages 20 | */ 21 | export const findItemById = (itemId: string): DashboardItem | null => { 22 | const config = loadConfig(); 23 | 24 | // Helper function to search for item in a layout array 25 | const searchInLayout = (items: any[]): DashboardItem | null => { 26 | for (const item of items) { 27 | // Direct match 28 | if (item.id === itemId) { 29 | return item; 30 | } 31 | 32 | // Check if this is a dual widget and the itemId is for a position-specific widget 33 | if (item.type === 'dual-widget' && itemId.startsWith(item.id + '-')) { 34 | const position = itemId.endsWith('-top') ? 'top' : 'bottom'; 35 | const positionWidget = position === 'top' ? item.config?.topWidget : item.config?.bottomWidget; 36 | 37 | if (positionWidget) { 38 | // Return a synthetic item with the position-specific config 39 | return { 40 | id: itemId, 41 | type: positionWidget.type, 42 | config: positionWidget.config 43 | } as DashboardItem; 44 | } 45 | } 46 | 47 | // Check group widgets 48 | if (item.type === 'group-widget' && item.config?.items) { 49 | const groupItem = searchInLayout(item.config.items); 50 | if (groupItem) return groupItem; 51 | } 52 | } 53 | return null; 54 | }; 55 | 56 | // Search in main desktop layout 57 | let foundItem = searchInLayout(config.layout.desktop); 58 | if (foundItem) return foundItem; 59 | 60 | // Search in main mobile layout 61 | foundItem = searchInLayout(config.layout.mobile); 62 | if (foundItem) return foundItem; 63 | 64 | // Search in pages if they exist 65 | if (config.pages) { 66 | for (const page of config.pages) { 67 | // Search in page desktop layout 68 | foundItem = searchInLayout(page.layout.desktop); 69 | if (foundItem) return foundItem; 70 | 71 | // Search in page mobile layout 72 | foundItem = searchInLayout(page.layout.mobile); 73 | if (foundItem) return foundItem; 74 | } 75 | } 76 | 77 | return null; 78 | }; 79 | 80 | /** 81 | * Extract connection information from an item's config 82 | * This function works with the actual stored config (not the filtered frontend config) 83 | * so it has access to the real password and apiToken values 84 | */ 85 | export const getConnectionInfo = (item: DashboardItem) => { 86 | const config = item.config || {}; 87 | 88 | return { 89 | host: config.host || 'localhost', 90 | port: config.port, 91 | ssl: config.ssl || false, 92 | username: config.username, 93 | password: config.password, // This will be the actual password from stored config 94 | apiToken: config.apiToken, // This will be the actual apiToken from stored config 95 | // For torrent clients 96 | clientType: config.clientType, 97 | // For other services 98 | displayName: config.displayName, 99 | // Security flags (these may or may not be present depending on context) 100 | _hasPassword: config._hasPassword, 101 | _hasApiToken: config._hasApiToken, 102 | // Add other common config properties as needed 103 | ...config 104 | }; 105 | }; 106 | 107 | /** 108 | * Get connection info for an item by ID 109 | */ 110 | export const getItemConnectionInfo = (itemId: string) => { 111 | const item = findItemById(itemId); 112 | if (!item) { 113 | throw new Error(`Item with ID ${itemId} not found`); 114 | } 115 | 116 | return getConnectionInfo(item); 117 | }; 118 | -------------------------------------------------------------------------------- /backend/src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | const ENCRYPTION_KEY = process.env.SECRET || '@jZCgtn^qg8So*^^6A2M'; 4 | const IV_LENGTH = 16; // For AES, this is always 16 bytes 5 | 6 | /** 7 | * Encrypts sensitive data using AES-256-CBC 8 | * @param text The plaintext to encrypt 9 | * @returns Encrypted string in format 'iv:encryptedData' encoded in base64 10 | */ 11 | export function encrypt(text: string): string { 12 | if (!text) return ''; 13 | 14 | // Create a random initialization vector 15 | const iv = crypto.randomBytes(IV_LENGTH); 16 | 17 | // Create a cipher using the encryption key and IV 18 | const key = crypto.scryptSync(ENCRYPTION_KEY, 'salt', 32); // Generate a 32-byte key 19 | const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); 20 | 21 | // Encrypt the text 22 | let encrypted = cipher.update(text, 'utf8', 'base64'); 23 | encrypted += cipher.final('base64'); 24 | 25 | // Return IV and encrypted data as a single base64 string 26 | return `ENC:${iv.toString('base64')}:${encrypted}`; 27 | } 28 | 29 | /** 30 | * Decrypts data that was encrypted with the encrypt function 31 | * @param encryptedText The encrypted text (format: 'iv:encryptedData' in base64) 32 | * @returns The decrypted plaintext, or empty string if decryption fails 33 | */ 34 | export function decrypt(encryptedText: string): string { 35 | if (!encryptedText || !encryptedText.startsWith('ENC:')) { 36 | return encryptedText; 37 | } 38 | 39 | try { 40 | // Split the encrypted text into IV and data components 41 | const parts = encryptedText.split(':'); 42 | if (parts.length !== 3) { 43 | console.warn('Invalid encrypted format - missing parts'); 44 | return ''; // Return empty string for invalid format 45 | } 46 | 47 | const iv = Buffer.from(parts[1], 'base64'); 48 | const encryptedData = parts[2]; 49 | 50 | // Create a decipher using the encryption key and IV 51 | const key = crypto.scryptSync(ENCRYPTION_KEY, 'salt', 32); // Generate a 32-byte key 52 | const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); 53 | 54 | // Decrypt the data 55 | let decrypted = decipher.update(encryptedData, 'base64', 'utf8'); 56 | decrypted += decipher.final('utf8'); 57 | 58 | return decrypted; 59 | } catch (error) { 60 | console.error('Decryption error:', error); 61 | console.warn('Decryption failed - possibly encrypted with a different key'); 62 | return ''; 63 | } 64 | } 65 | 66 | /** 67 | * Checks if a string is already encrypted 68 | * @param text The text to check 69 | * @returns True if the text is encrypted 70 | */ 71 | export function isEncrypted(text: string): boolean { 72 | return text?.startsWith('ENC:') || false; 73 | } 74 | -------------------------------------------------------------------------------- /backend/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { UPLOAD_DIRECTORY } from '../constants/constants'; 5 | 6 | export const removeExistingFiles = (exceptFileName?: string) => { 7 | try { 8 | const files = fs.readdirSync(UPLOAD_DIRECTORY); 9 | files.forEach(file => { 10 | // If an exception filename is provided, skip that file 11 | if (exceptFileName && file === exceptFileName) { 12 | return; 13 | } 14 | 15 | const filePath = path.join(UPLOAD_DIRECTORY, file); 16 | 17 | // Skip directories 18 | if (fs.statSync(filePath).isDirectory()) { 19 | return; 20 | } 21 | 22 | fs.unlinkSync(filePath); 23 | }); 24 | } catch (error) { 25 | console.error('Error removing existing files:', error); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "outDir": "./dist", 6 | "rootDir": "./", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": [ 14 | "**/*", 15 | "./src/config/config.json" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | lab-dash: 4 | container_name: lab-dash 5 | image: ghcr.io/anthonygress/lab-dash:latest 6 | privileged: true 7 | network_mode: host # for monitoring network usage stats 8 | ports: 9 | - 2022:2022 10 | environment: 11 | - SECRET=YOUR_SECRET_KEY # any random string for used for encryption. 12 | # You can run `openssl rand -base64 32` to generate a key 13 | volumes: 14 | - /sys:/sys:ro 15 | - /docker/lab-dash/config:/config 16 | - /docker/lab-dash/uploads:/app/public/uploads 17 | - /var/run/docker.sock:/var/run/docker.sock 18 | restart: unless-stopped 19 | labels: 20 | - "com.centurylinklabs.watchtower.enable=true" 21 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import reactImport from 'eslint-plugin-import'; 3 | import react from 'eslint-plugin-react'; 4 | import reactHooks from 'eslint-plugin-react-hooks'; 5 | import reactRefresh from 'eslint-plugin-react-refresh'; 6 | import globals from 'globals'; 7 | import tseslint from 'typescript-eslint'; 8 | 9 | export default tseslint.config( 10 | { ignores: ['dist', '*.css'] }, 11 | { 12 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 13 | files: ['**/*.{ts,tsx}'], 14 | languageOptions: { 15 | ecmaVersion: 2020, 16 | globals: {...globals.browser} 17 | }, 18 | plugins: { 19 | 'react-hooks': reactHooks, 20 | 'react-refresh': reactRefresh, 21 | 'import': reactImport, 22 | 'react': react 23 | }, 24 | rules: { 25 | ...reactHooks.configs.recommended.rules, 26 | 'react-refresh/only-export-components': [ 27 | 'warn', 28 | { allowConstantExport: true }, 29 | ], 30 | 'import/no-extraneous-dependencies': 'off', 31 | 'react/react-in-jsx-scope': 'off', 32 | 'react/jsx-filename-extension': 'off', 33 | 'import/extensions': 'off', 34 | 'import/no-unresolved': 'off', 35 | 'import/no-import-module-exports': 'off', 36 | 'no-shadow': 'off', 37 | '@typescript-eslint/no-shadow': 'error', 38 | 'no-unused-vars': 'off', 39 | '@typescript-eslint/no-unused-vars': 'warn', 40 | 'no-console': 'off', 41 | 'radix': 'off', 42 | 'global-require': 'off', 43 | 'import/no-dynamic-require': 'off', 44 | indent: ['warn', 4, { 45 | 'ignoredNodes': [ 46 | 'FunctionExpression > .params > :matches(Decorator, :not(:first-child))', 47 | 'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key', 48 | ], 49 | },], 50 | quotes: ['warn', 'single'], 51 | 'prettier/prettier': 0, 52 | 'object-curly-spacing': ['warn', 'always'], 53 | '@typescript-eslint/ban-types': 'off', 54 | '@typescript-eslint/no-var-requires': 'warn', 55 | '@typescript-eslint/no-non-null-assertion': 'off', 56 | 'import/prefer-default-export': 'off', 57 | 'spaced-comment': 'warn', 58 | 'lines-between-class-members': 'off', 59 | 'class-methods-use-this': 'off', 60 | 'no-return-await': 'off', 61 | 'no-undef': 'warn', 62 | 'react/require-default-props': 'off', 63 | 'no-plusplus': 'off', 64 | 'react/jsx-props-no-spreading': 'off', 65 | 'import/newline-after-import': 'off', 66 | 'promise/always-return': 'off', 67 | 'import/order': [ 68 | 'warn', 69 | { 70 | 'alphabetize': { 71 | 'caseInsensitive': true, 72 | 'order': 'asc' 73 | }, 74 | 'groups': [ 75 | ['builtin', 'external', 'object', 'type'], 76 | ['internal', 'parent', 'sibling', 'index'] 77 | ], 78 | 'newlines-between': 'always' 79 | } 80 | ], 81 | 'sort-imports': [ 82 | 'warn', 83 | { 84 | 'allowSeparatedGroups': true, 85 | 'ignoreCase': true, 86 | 'ignoreDeclarationSort': true, 87 | 'ignoreMemberSort': false, 88 | 'memberSyntaxSortOrder': ['none', 'all', 'multiple', 'single'] 89 | } 90 | ], 91 | 'jsx-quotes': ['warn', 'prefer-single'], 92 | 'react/function-component-definition': 'off', 93 | '@typescript-eslint/no-explicit-any': 'warn', 94 | 'semi': 'warn' 95 | }, 96 | }, 97 | ) 98 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Lab Dash 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lab-dash-frontend", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite --host", 6 | "build": "tsc -b && vite build", 7 | "build:dev": "vite build", 8 | "lint": "eslint .", 9 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 10 | "preview": "vite preview", 11 | "docker:build:dev": "docker build . -t ghcr.io/anthonygress/lab-dash-frontend:latest", 12 | "docker:build": "docker build --platform linux/amd64 . -t ghcr.io/anthonygress/lab-dash-frontend:latest", 13 | "docker:run": "docker run --platform=linux/amd64 -p 2022:80 -p 5000:5000 ghcr.io/anthonygress/lab-dash-frontend:latest", 14 | "docker:run:dev": "docker run -p 2022:80 -p 5000:5000 ghcr.io/anthonygress/lab-dash-frontend:latest", 15 | "docker:push": "docker push ghcr.io/anthonygress/lab-dash-frontend:latest", 16 | "docker:stop": "docker ps -q --filter 'ancestor=ghcr.io/anthonygress/lab-dash-frontend:latest' | xargs -r docker stop", 17 | "docker:rm": "docker ps -a -q --filter 'ancestor=ghcr.io/anthonygress/lab-dash-frontend:latest' | xargs -r docker rm", 18 | "docker:clean": "npm run docker:stop && npm run docker:rm", 19 | "docker:restart": "npm run docker:stop && npm run docker:rm && npm run docker:run" 20 | }, 21 | "dependencies": { 22 | "@dnd-kit/core": "^6.3.1", 23 | "@dnd-kit/sortable": "^10.0.0", 24 | "@emotion/react": "^11.14.0", 25 | "@emotion/styled": "^11.14.0", 26 | "@mui/icons-material": "^6.4.2", 27 | "@mui/material": "^6.4.3", 28 | "@mui/x-charts": "^7.25.0", 29 | "axios": "^1.8.3", 30 | "framer-motion": "^12.4.7", 31 | "mui-file-input": "^6.0.0", 32 | "nanoid": "^5.1.2", 33 | "react": "^18.3.1", 34 | "react-dom": "^18.3.1", 35 | "react-hook-form": "^7.54.2", 36 | "react-hook-form-mui": "^7.5.0", 37 | "react-icons": "^5.4.0", 38 | "react-markdown": "^10.1.0", 39 | "react-router-dom": "^7.2.0", 40 | "react-virtualized": "^9.22.6", 41 | "shortid": "^2.2.17", 42 | "sweetalert2": "^11.16.0" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^22.10.7", 46 | "@types/react": "^18.3.18", 47 | "@types/react-dom": "^18.3.5", 48 | "@types/react-virtualized": "^9.22.2", 49 | "@types/shortid": "^2.2.0", 50 | "@vitejs/plugin-react-swc": "^3.8.0", 51 | "eslint": "^9.19.0", 52 | "eslint-plugin-import": "^2.28.1", 53 | "eslint-plugin-react": "^7.37.4", 54 | "eslint-plugin-react-hooks": "^5.0.0", 55 | "eslint-plugin-react-refresh": "^0.4.16", 56 | "globals": "^15.14.0", 57 | "http-status-codes": "^2.3.0", 58 | "typescript": "~5.6.2", 59 | "typescript-eslint": "^8.18.2", 60 | "vite": "^6.2.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/gradient_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/public/gradient_logo.png -------------------------------------------------------------------------------- /frontend/public/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/public/icon192.png -------------------------------------------------------------------------------- /frontend/public/icon512_maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/public/icon512_maskable.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#01081e", 3 | "background_color": "#01081e", 4 | "icons": [ 5 | { 6 | "purpose": "any", 7 | "sizes": "192x192", 8 | "src": "icon192.png", 9 | "type": "image/png" 10 | }, 11 | { 12 | "purpose": "maskable", 13 | "sizes": "512x512", 14 | "src": "icon512_maskable.png", 15 | "type": "image/png" 16 | } 17 | ], 18 | "orientation": "any", 19 | "display": "standalone", 20 | "dir": "auto", 21 | "lang": "en-US", 22 | "name": "Lab Dash", 23 | "short_name": "Lab Dash", 24 | "start_url": "/", 25 | "description": "Management console for your homelab services" 26 | } 27 | -------------------------------------------------------------------------------- /frontend/public/space4k-min.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/public/space4k-min.webp -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import './theme/App.css'; 2 | import { GlobalStyles } from '@mui/material'; 3 | import { Box, Paper } from '@mui/material'; 4 | import { useEffect } from 'react'; 5 | import { Route, Routes } from 'react-router-dom'; 6 | 7 | import { DashApi } from './api/dash-api'; 8 | import { SetupForm } from './components/forms/SetupForm'; 9 | import { WithNav } from './components/navbar/WithNav'; 10 | import { ScrollToTop } from './components/ScrollToTop'; 11 | import { BACKEND_URL } from './constants/constants'; 12 | import { AppContextProvider } from './context/AppContextProvider'; 13 | import { useAppContext } from './context/useAppContext'; 14 | import { DashboardPage } from './pages/DashboardPage'; 15 | import { LoginPage } from './pages/LoginPage'; 16 | import { SettingsPage } from './pages/SettingsPage'; 17 | import { styles } from './theme/styles'; 18 | import { theme } from './theme/theme'; 19 | 20 | const SetupPage = () => { 21 | const { isFirstTimeSetup, setupComplete, setSetupComplete, checkLoginStatus } = useAppContext(); 22 | 23 | // Show loading state while checking 24 | if (isFirstTimeSetup === null) { 25 | return ( 26 | 27 | {/* Loading state */} 28 | 29 | ); 30 | } 31 | 32 | return ( 33 | 34 | 35 | setSetupComplete(true)} /> 36 | 37 | 38 | ); 39 | }; 40 | 41 | export const App = () => { 42 | const { 43 | config, 44 | isFirstTimeSetup, 45 | setupComplete, 46 | setSetupComplete, 47 | refreshDashboard, 48 | checkLoginStatus, 49 | isLoggedIn 50 | } = useAppContext(); 51 | 52 | // Check if setup is complete based on the config 53 | useEffect(() => { 54 | // If configuration has been loaded and isSetupComplete is true 55 | if (config && config.isSetupComplete) { 56 | setSetupComplete(true); 57 | } 58 | }, [config, setSetupComplete]); 59 | 60 | // Set the document title based on the custom title in config 61 | useEffect(() => { 62 | if (config?.title) { 63 | document.title = config.title; 64 | } else { 65 | document.title = 'Lab Dash'; 66 | } 67 | }, [config?.title]); 68 | 69 | const backgroundImage = config?.backgroundImage 70 | ? `url('${BACKEND_URL}/uploads/${config?.backgroundImage}')` 71 | : 'url(\'/space4k-min.webp\')'; 72 | 73 | const globalStyles = ( 74 | 90 | ); 91 | 92 | return ( 93 | <> 94 | {globalStyles} 95 | 96 | 97 | }> 98 | : }/> 99 | : }/> 100 | }/> 101 | }/> 102 | }/> 103 | 104 | 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /frontend/src/assets/gradient_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/gradient_logo.png -------------------------------------------------------------------------------- /frontend/src/assets/jellyfin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/jellyfin.png -------------------------------------------------------------------------------- /frontend/src/assets/jellyfin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/jellyseerr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/jellyseerr.png -------------------------------------------------------------------------------- /frontend/src/assets/pihole.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/prowlarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/prowlarr.png -------------------------------------------------------------------------------- /frontend/src/assets/qb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/qb.png -------------------------------------------------------------------------------- /frontend/src/assets/radarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/radarr.png -------------------------------------------------------------------------------- /frontend/src/assets/readarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/readarr.png -------------------------------------------------------------------------------- /frontend/src/assets/sonarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/assets/sonarr.png -------------------------------------------------------------------------------- /frontend/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Box, SxProps, Theme } from '@mui/material'; 2 | 3 | import logo from '../assets/gradient_logo.png'; 4 | 5 | interface LogoProps { 6 | src?: string; 7 | size?: number | string; 8 | alt?: string; 9 | sx?: SxProps; 10 | } 11 | 12 | export const Logo: React.FC = ({ src, size = 40, alt = 'Logo Icon', sx }) => { 13 | return ( 14 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/ScrollToTop.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react'; 2 | import { useLocation } from 'react-router'; 3 | 4 | export const ScrollToTop: FC = (props: any) => { 5 | const { children } = props; 6 | const location = useLocation(); 7 | useEffect(() => { 8 | if (!location.hash) { 9 | window.scrollTo(0, 0); 10 | } 11 | }, [location]); 12 | 13 | 14 | return <>{children}; 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/WiggleWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | editMode: boolean; 5 | children: React.ReactNode; 6 | } 7 | 8 | export const WiggleWrapper = ({ editMode, children }: Props) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/apps/BlankAppShortcut.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Box, Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { styles } from '../../../../theme/styles'; 7 | import { WidgetContainer } from '../widgets/WidgetContainer'; 8 | 9 | type Props = { 10 | id: string; 11 | editMode: boolean; 12 | isOverlay?: boolean; 13 | onDelete?: () => void; 14 | onEdit?: () => void; 15 | onDuplicate?: () => void; 16 | }; 17 | 18 | export const BlankAppShortcut: React.FC = ({ id, editMode, isOverlay = false, onDelete, onEdit, onDuplicate }) => { 19 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 20 | 21 | return ( 22 | 34 | 35 | {/* */} 36 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/BlankWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Box, Grid2, Typography } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { WidgetContainer } from '../widgets/WidgetContainer'; 7 | 8 | type Props = { 9 | id: string; 10 | label?: string; 11 | isOverlay?: boolean; 12 | editMode: boolean; 13 | onDelete?: () => void; 14 | onEdit?: () => void; 15 | onDuplicate?: () => void; 16 | row?: boolean; 17 | }; 18 | 19 | export const BlankWidget: React.FC = ({ id, label, editMode, isOverlay = false, onDelete, onEdit, onDuplicate, row }) => { 20 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 21 | 22 | return ( 23 | 35 | 36 | 37 | {/* {label} */} 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/DualWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider, Typography, useMediaQuery } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | import { DateTimeWidget } from './DateTimeWidget'; 5 | import { DualWidgetContainer } from './DualWidgetContainer'; 6 | import { PiholeWidget } from './PiholeWidget/PiholeWidget'; 7 | import { SystemMonitorWidget } from './SystemMonitorWidget/SystemMonitorWidget'; 8 | import { WeatherWidget } from './WeatherWidget'; 9 | import { DUAL_WIDGET_SECTION_HEIGHT } from '../../../../constants/widget-dimensions'; 10 | import { COLORS } from '../../../../theme/styles'; 11 | import { theme } from '../../../../theme/theme'; 12 | import { ITEM_TYPE } from '../../../../types'; 13 | 14 | export interface DualWidgetProps { 15 | config?: { 16 | topWidget?: { 17 | type: string; 18 | config?: any; 19 | }; 20 | bottomWidget?: { 21 | type: string; 22 | config?: any; 23 | }; 24 | }; 25 | editMode?: boolean; 26 | id?: string; 27 | onEdit?: () => void; 28 | onDelete?: () => void; 29 | onDuplicate?: () => void; 30 | url?: string; 31 | } 32 | 33 | export const DualWidget: React.FC = ({ 34 | config, 35 | editMode = false, 36 | id, 37 | onEdit, 38 | onDelete, 39 | onDuplicate, 40 | url 41 | }) => { 42 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')); 43 | 44 | const renderWidget = (widgetConfig: { type: string; config?: any } | undefined, position: 'top' | 'bottom') => { 45 | if (!widgetConfig || !widgetConfig.type) { 46 | return ( 47 | 56 | 57 | Widget not configured 58 | 59 | 60 | ); 61 | } 62 | 63 | try { 64 | switch (widgetConfig.type) { 65 | case ITEM_TYPE.WEATHER_WIDGET: 66 | return ; 67 | case ITEM_TYPE.DATE_TIME_WIDGET: 68 | return ; 69 | case ITEM_TYPE.SYSTEM_MONITOR_WIDGET: 70 | return ; 74 | case ITEM_TYPE.PIHOLE_WIDGET: 75 | return ; 79 | default: 80 | return ( 81 | 89 | 90 | Unknown widget type: {widgetConfig.type} 91 | 92 | 93 | ); 94 | } 95 | } catch (error) { 96 | console.error(`Error rendering widget of type ${widgetConfig.type}:`, error); 97 | return ( 98 | 107 | 108 | Error rendering widget 109 | 110 | 111 | ); 112 | } 113 | }; 114 | 115 | return ( 116 | 124 | 133 | 147 | {renderWidget(config?.topWidget, 'top')} 148 | 149 | 150 | 158 | 159 | 173 | {renderWidget(config?.bottomWidget, 'bottom')} 174 | 175 | 176 | 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/DualWidgetContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, useMediaQuery } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | import { EditMenu } from './EditMenu'; 5 | import { StatusIndicator } from './StatusIndicator'; 6 | import { DUAL_WIDGET_CONTAINER_HEIGHT } from '../../../../constants/widget-dimensions'; 7 | import { COLORS } from '../../../../theme/styles'; 8 | import { theme } from '../../../../theme/theme'; 9 | 10 | type DualWidgetContainerProps = { 11 | children: React.ReactNode; 12 | editMode?: boolean; 13 | id?: string; 14 | onEdit?: () => void; 15 | onDelete?: () => void; 16 | onDuplicate?: () => void; 17 | url?: string; 18 | }; 19 | 20 | export const DualWidgetContainer: React.FC = ({ 21 | children, 22 | editMode = false, 23 | id, 24 | onEdit, 25 | onDelete, 26 | onDuplicate, 27 | url 28 | }) => { 29 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')); 30 | 31 | return ( 32 | 55 | 56 | 57 | 76 | {children} 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/StatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; 2 | import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; 3 | import { Box, Tooltip } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { useServiceStatus } from '../../../../hooks/useServiceStatus'; 7 | import { isValidHttpUrl } from '../../../../utils/utils'; 8 | 9 | type StatusIndicatorProps = { 10 | url?: string; 11 | healthCheckType?: 'http' | 'ping'; 12 | }; 13 | 14 | export const StatusIndicator: React.FC = ({ url, healthCheckType = 'http' }) => { 15 | // For ping type, we don't need to validate the URL format 16 | const isPingType = healthCheckType === 'ping'; 17 | const isValidUrl = isPingType || (url && isValidHttpUrl(url)); 18 | 19 | const isOnline = useServiceStatus(isValidUrl ? url : null, healthCheckType); 20 | 21 | let dotColor = 'gray'; 22 | let tooltipText = 'Unknown'; 23 | 24 | if (isOnline === true) { 25 | dotColor = 'green'; 26 | tooltipText = 'Online'; 27 | } else if (isOnline === false) { 28 | dotColor = 'red'; 29 | tooltipText = 'Offline'; 30 | } 31 | 32 | if (!url || (!isPingType && !isValidHttpUrl(url))) return null; 33 | 34 | return ( 35 | 42 | 57 | {dotColor === 'green' && ( 58 | 66 | )} 67 | {dotColor === 'red' && ( 68 | 76 | )} 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/SystemMonitorWidget/DiskUsageWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack, Tooltip, Typography } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | import { useIsMobile } from '../../../../../hooks/useIsMobile'; 5 | import { theme } from '../../../../../theme/theme'; 6 | 7 | 8 | 9 | export interface DiskUsageBarProps { 10 | totalSpace: number; // Total disk space in GB 11 | usedSpace: number; // Used disk space in GB 12 | usedPercentage: number; 13 | } 14 | 15 | // Helper function to format space dynamically (GB or TB) 16 | const formatSpace = (space: number): string => { 17 | if (space > 0) { 18 | return space >= 1000 ? `${(space / 1000)} TB` : `${space} GB`; 19 | } 20 | 21 | return '0 GB'; 22 | }; 23 | 24 | export const DiskUsageBar: React.FC = ({ totalSpace, usedSpace, usedPercentage }) => { 25 | const freeSpace = totalSpace - usedSpace; 26 | const freePercentage = 100 - usedPercentage; 27 | const isMobile = useIsMobile(); 28 | return ( 29 | 30 | 31 | 32 | Disk Usage: {usedPercentage.toFixed(1)}% 33 | 34 | 35 | {formatSpace(usedSpace)} / {formatSpace(totalSpace)} 36 | 37 | 38 | 39 | 40 | {/* Used Space Tooltip */} 41 | 48 | 56 | 57 | 58 | {/* Free Space Tooltip */} 59 | 66 | 74 | 75 | 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/SystemMonitorWidget/GaugeWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material'; 2 | import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge'; 3 | import React, { ReactNode } from 'react'; 4 | 5 | import { useIsMobile } from '../../../../../hooks/useIsMobile'; 6 | import { theme } from '../../../../../theme/theme'; 7 | 8 | interface GaugeWidgetProps { 9 | value: number; // The gauge value 10 | title: string; 11 | size?: number; 12 | temperature?: boolean; 13 | isFahrenheit?: boolean; 14 | total?: number; 15 | suffix?: string; 16 | customContent?: ReactNode; 17 | isDualWidget?: boolean; 18 | } 19 | 20 | export const GaugeWidget: React.FC = ({ 21 | value, 22 | title, 23 | size, 24 | temperature, 25 | isFahrenheit, 26 | total, 27 | suffix, 28 | customContent, 29 | isDualWidget 30 | }) => { 31 | const isMobile = useIsMobile(); 32 | 33 | // Calculate the maximum value for temperature gauge based on the unit 34 | const maxValue = temperature 35 | ? (isFahrenheit ? 212 : 100) // Max temp: 212°F or 100°C 36 | : (total ? total : 100); // Default max for non-temperature gauges 37 | 38 | // Determine the suffix to display 39 | const displaySuffix = (): string => { 40 | if (suffix) return suffix; 41 | if (temperature) return isFahrenheit ? '°F' : '°C'; 42 | return '%'; 43 | }; 44 | 45 | // Define responsive width and height for the gauge 46 | // Make gauges smaller on mobile and even smaller in dual widgets 47 | const gaugeSizing = { 48 | // Width is smaller in xs (mobile) and even smaller if in dual widget 49 | width: { 50 | xs: isDualWidget ? 90 : 108, 51 | sm: 100, 52 | md: 108, 53 | xl: 135 54 | }, 55 | // Height follows similar pattern as width 56 | height: { 57 | xs: isDualWidget ? 110 : 135, 58 | sm: 120, 59 | md: 130, 60 | xl: 135 61 | } 62 | }; 63 | 64 | return ( 65 | 66 | {/* Gauge Chart */} 67 | 87 | {/* Center Content */} 88 | 100 | {customContent ? ( 101 | customContent 102 | ) : ( 103 | 112 | {value}{displaySuffix()} 113 | 114 | )} 115 | 116 | 128 | 132 | {title} 133 | 134 | 135 | 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/base-items/widgets/WidgetContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | import { EditMenu } from './EditMenu'; 5 | import { StatusIndicator } from './StatusIndicator'; 6 | import { COLORS } from '../../../../theme/styles'; 7 | 8 | type Props = { 9 | children: React.ReactNode; 10 | editMode: boolean; 11 | id?: string; 12 | onEdit?: () => void 13 | onDelete?: () => void; 14 | onDuplicate?: () => void; 15 | appShortcut?: boolean; 16 | placeholder?: boolean; 17 | url?: string; 18 | healthCheckType?: 'http' | 'ping'; 19 | rowPlaceholder?: boolean; 20 | groupItem?: boolean; 21 | isHighlighted?: boolean; 22 | isPreview?: boolean; 23 | customHeight?: any; // Allow customizing widget height 24 | }; 25 | 26 | export const WidgetContainer: React.FC = ({ 27 | children, 28 | editMode, 29 | id, 30 | onEdit, 31 | onDelete, 32 | onDuplicate, 33 | appShortcut=false, 34 | placeholder=false, 35 | url, 36 | healthCheckType='http', 37 | rowPlaceholder, 38 | groupItem, 39 | isHighlighted = false, 40 | isPreview = false, 41 | customHeight 42 | }) => { 43 | return ( 44 | 80 | {!isPreview && } 81 | {children} 82 | {!isPreview && } 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/apps/SortableAppShortcut.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { ITEM_TYPE } from '../../../../types'; 7 | import { AppShortcut } from '../../base-items/apps/AppShortcut'; 8 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 9 | 10 | type Props = { 11 | id: string; 12 | url?: string; 13 | name: string; 14 | iconName: string; 15 | editMode: boolean; 16 | isOverlay?: boolean; 17 | isPreview?: boolean; 18 | onDelete?: () => void; 19 | onEdit?: () => void; 20 | onDuplicate?: () => void; 21 | showLabel?: boolean; 22 | config?: any; 23 | }; 24 | 25 | export const SortableAppShortcut: React.FC = ({ 26 | id, 27 | url, 28 | name, 29 | iconName, 30 | editMode, 31 | isOverlay = false, 32 | isPreview = false, 33 | onDelete, 34 | onEdit, 35 | onDuplicate, 36 | showLabel, 37 | config 38 | }) => { 39 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ 40 | id, 41 | data: { 42 | type: ITEM_TYPE.APP_SHORTCUT 43 | } 44 | }); 45 | 46 | // Only show label in overlay when dragging, or when not dragging at all 47 | const shouldShowLabel = showLabel && (isOverlay || isPreview || !isDragging); 48 | 49 | // Use healthUrl for status checking if available 50 | const healthUrl = config?.healthUrl; 51 | const healthCheckType = config?.healthCheckType || 'http'; 52 | const statusUrl = healthUrl || url; 53 | 54 | return ( 55 | 76 | 87 | 96 | 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableDateTime.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { DateTimeWidget } from '../../base-items/widgets/DateTimeWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | type DateTimeConfig = { 10 | location?: { 11 | name: string; 12 | latitude: number; 13 | longitude: number; 14 | } | null; 15 | timezone?: string; 16 | }; 17 | 18 | type Props = { 19 | id: string; 20 | editMode: boolean; 21 | isOverlay?: boolean; 22 | onDelete?: () => void; 23 | onEdit?: () => void; 24 | onDuplicate?: () => void; 25 | config?: DateTimeConfig; 26 | }; 27 | 28 | export const SortableDateTimeWidget: React.FC = ({ 29 | id, 30 | editMode, 31 | isOverlay = false, 32 | onDelete, 33 | onEdit, 34 | onDuplicate, 35 | config 36 | }) => { 37 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 38 | 39 | // Ensure we have a properly typed config for the DateTimeWidget 40 | // Only extract the properties we need, ignore the rest 41 | const dateTimeConfig: DateTimeConfig = { 42 | location: config?.location || null, 43 | timezone: config?.timezone || undefined 44 | }; 45 | 46 | return ( 47 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableDeluge.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { DelugeWidget } from '../../base-items/widgets/DelugeWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | interface Props { 10 | id: string; 11 | editMode?: boolean; 12 | isOverlay?: boolean; 13 | onDelete?: () => void; 14 | onEdit?: () => void; 15 | onDuplicate?: () => void; 16 | config?: any; 17 | } 18 | 19 | export const SortableDeluge: React.FC = ({ 20 | id, 21 | editMode = false, 22 | isOverlay = false, 23 | onDelete, 24 | onEdit, 25 | onDuplicate, 26 | config 27 | }) => { 28 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 29 | 30 | return ( 31 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableDualWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { DualWidget } from '../../base-items/widgets/DualWidget'; 7 | 8 | type Props = { 9 | id: string; 10 | editMode: boolean; 11 | isOverlay?: boolean; 12 | onDelete?: () => void; 13 | onEdit?: () => void; 14 | onDuplicate?: () => void; 15 | config?: { 16 | topWidget?: { 17 | type: string; 18 | config?: any; 19 | }; 20 | bottomWidget?: { 21 | type: string; 22 | config?: any; 23 | }; 24 | }; 25 | url?: string; 26 | }; 27 | 28 | export const SortableDualWidget: React.FC = ({ 29 | id, 30 | editMode, 31 | isOverlay = false, 32 | onDelete, 33 | onEdit, 34 | onDuplicate, 35 | config, 36 | url 37 | }) => { 38 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 39 | 40 | return ( 41 | 53 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortablePihole.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { PiholeWidget } from '../../base-items/widgets/PiholeWidget/PiholeWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | type Props = { 10 | id: string; 11 | editMode: boolean; 12 | isOverlay?: boolean; 13 | config?: any; 14 | onDelete?: () => void; 15 | onEdit?: () => void; 16 | onDuplicate?: () => void; 17 | }; 18 | 19 | export const SortablePihole: React.FC = ({ id, editMode, isOverlay = false, config, onDelete, onEdit, onDuplicate }) => { 20 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 21 | 22 | return ( 23 | 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableQBittorrent.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { QBittorrentWidget } from '../../base-items/widgets/QBittorrentWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | interface Props { 10 | id: string; 11 | editMode?: boolean; 12 | isOverlay?: boolean; 13 | onDelete?: () => void; 14 | onEdit?: () => void; 15 | onDuplicate?: () => void; 16 | config?: any; 17 | } 18 | 19 | export const SortableQBittorrent: React.FC = ({ 20 | id, 21 | editMode = false, 22 | isOverlay = false, 23 | onDelete, 24 | onEdit, 25 | onDuplicate, 26 | config 27 | }) => { 28 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 29 | 30 | return ( 31 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableSystemMonitor.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { SystemMonitorWidget } from '../../base-items/widgets/SystemMonitorWidget/SystemMonitorWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | type Props = { 10 | id: string; 11 | editMode: boolean; 12 | isOverlay?: boolean; 13 | config?: { 14 | temperatureUnit?: string; 15 | [key: string]: any; 16 | }; 17 | onDelete?: () => void; 18 | onEdit?: () => void; 19 | onDuplicate?: () => void; 20 | }; 21 | 22 | export const SortableSystemMonitorWidget: React.FC = ({ id, editMode, isOverlay = false, config, onDelete, onEdit, onDuplicate }) => { 23 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 24 | 25 | return ( 26 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableTransmission.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { TransmissionWidget } from '../../base-items/widgets/TransmissionWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | interface Props { 10 | id: string; 11 | editMode?: boolean; 12 | isOverlay?: boolean; 13 | onDelete?: () => void; 14 | onEdit?: () => void; 15 | onDuplicate?: () => void; 16 | config?: any; 17 | } 18 | 19 | export const SortableTransmission: React.FC = ({ 20 | id, 21 | editMode = false, 22 | isOverlay = false, 23 | onDelete, 24 | onEdit, 25 | onDuplicate, 26 | config 27 | }) => { 28 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 29 | 30 | return ( 31 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/sortable-items/widgets/SortableWeather.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | import { Box, Grid2 } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { WeatherWidget } from '../../base-items/widgets/WeatherWidget'; 7 | import { WidgetContainer } from '../../base-items/widgets/WidgetContainer'; 8 | 9 | 10 | type Props = { 11 | id: string; 12 | editMode: boolean; 13 | isOverlay?: boolean; 14 | onDelete?: () => void; 15 | onEdit?: () => void; 16 | onDuplicate?: () => void; 17 | config?: { 18 | temperatureUnit?: string; 19 | }; 20 | }; 21 | 22 | export const SortableWeatherWidget: React.FC = ({ id, editMode, isOverlay = false, onDelete, onEdit, onDuplicate, config }) => { 23 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 24 | 25 | return ( 26 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/components/forms/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import { SxProps } from '@mui/material'; 2 | import { MuiFileInput } from 'mui-file-input'; 3 | import { useState } from 'react'; 4 | import { Controller } from 'react-hook-form-mui'; 5 | import { FaFileUpload } from 'react-icons/fa'; 6 | 7 | import { theme } from '../../theme/theme'; 8 | 9 | type Props = { 10 | name: string; 11 | label?: string; 12 | accept?: string; 13 | width?: string; 14 | maxSize?: number; 15 | sx: SxProps 16 | } 17 | 18 | export const FileInput = ({ 19 | name, 20 | label, 21 | accept='image/png, image/jpeg, image/jpg, image/gif, image/webp', 22 | width, 23 | maxSize = 5 * 1024 * 1024, // 5MB default 24 | sx 25 | }: Props) => { 26 | const [sizeError, setSizeError] = useState(null); 27 | 28 | return ( 29 | ( 34 | { 37 | if (file instanceof File) { 38 | // Check file size 39 | if (file.size > maxSize) { 40 | setSizeError(`File is too large (${Math.round(maxSize/1024/1024)}MB max)`); 41 | } else { 42 | setSizeError(null); 43 | field.onChange(file); 44 | } 45 | } else { 46 | setSizeError(null); 47 | field.onChange(file); 48 | } 49 | }} 50 | // inputProps={{ accept: '*' }} 51 | label={label} 52 | error={!!fieldState.error || !!sizeError} 53 | helperText={fieldState.error?.message || sizeError} 54 | InputProps={{ 55 | inputProps: { 56 | accept 57 | }, 58 | startAdornment: 59 | }} 60 | sx={{ width: width || '100%', ...sx }} 61 | placeholder='Select a File' 62 | fullWidth={!width} 63 | /> 64 | )} 65 | /> 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /frontend/src/components/forms/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, InputAdornment, Typography } from '@mui/material'; 2 | import { FormContainer, TextFieldElement, useForm } from 'react-hook-form-mui'; 3 | import { FaLock, FaUser } from 'react-icons/fa6'; 4 | import { useLocation, useNavigate } from 'react-router-dom'; 5 | 6 | import { DashApi } from '../../api/dash-api'; 7 | import { ToastManager } from '../../components/toast/ToastManager'; 8 | import { useAppContext } from '../../context/useAppContext'; 9 | import { styles } from '../../theme/styles'; 10 | import { theme } from '../../theme/theme'; 11 | 12 | type FormValues = { 13 | username: string, 14 | password: string; 15 | } 16 | 17 | export const LoginForm = () => { 18 | const navigate = useNavigate(); 19 | const location = useLocation(); 20 | const { setIsLoggedIn, setUsername, setIsAdmin, refreshDashboard } = useAppContext(); 21 | 22 | const formContext = useForm({ 23 | defaultValues: { 24 | username: '', 25 | password: '' 26 | } 27 | }); 28 | 29 | const handleSubmit = async (data: FormValues) => { 30 | try { 31 | const response = await DashApi.login(data.username, data.password); 32 | 33 | // Update auth state in context - do this in sequence to avoid race conditions 34 | setUsername(data.username); 35 | 36 | // Get admin status directly from the response 37 | if (response.isAdmin !== undefined) { 38 | setIsAdmin(response.isAdmin); 39 | } 40 | 41 | // Set logged in status last to trigger any dependent effects 42 | setIsLoggedIn(true); 43 | 44 | // Refresh dashboard to load admin-only items if user is admin 45 | await refreshDashboard(); 46 | 47 | // Show success toast and navigate back to previous page or home 48 | ToastManager.success('Login successful!'); 49 | 50 | // Get the previous location from navigation state, default to home 51 | const from = (location.state as any)?.from || '/'; 52 | navigate(from, { replace: true }); 53 | } catch (error: any) { 54 | // Show error message 55 | ToastManager.error(error.message || 'Login failed'); 56 | } 57 | }; 58 | 59 | return ( 60 | 61 | 62 | 63 | Login 64 | 65 | 66 | 67 | 78 | 79 | 80 | ), 81 | autoComplete: 'username' 82 | } 83 | }} 84 | /> 85 | 86 | 87 | 88 | 100 | 101 | 102 | ) 103 | } 104 | }} 105 | /> 106 | 107 | 108 | 109 | 110 | 111 | 112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /frontend/src/components/forms/MultiFileInput.tsx: -------------------------------------------------------------------------------- 1 | import { SxProps } from '@mui/material'; 2 | import { MuiFileInput } from 'mui-file-input'; 3 | import { useState } from 'react'; 4 | import { Controller } from 'react-hook-form-mui'; 5 | import { FaFileUpload } from 'react-icons/fa'; 6 | 7 | import { theme } from '../../theme/theme'; 8 | 9 | type Props = { 10 | name: string; 11 | label?: string; 12 | accept?: string; 13 | width?: string; 14 | maxSize?: number; 15 | maxFiles?: number; 16 | sx: SxProps 17 | } 18 | 19 | export const MultiFileInput = ({ 20 | name, 21 | label, 22 | accept = 'image/png, image/jpeg, image/jpg, image/gif, image/webp, image/svg+xml', 23 | width, 24 | maxSize = 5 * 1024 * 1024, // 5MB default per file 25 | maxFiles = 20, 26 | sx 27 | }: Props) => { 28 | const [sizeError, setSizeError] = useState(null); 29 | 30 | return ( 31 | ( 34 | { 37 | if (files) { 38 | const fileArray = Array.isArray(files) ? files : [files]; 39 | 40 | // Check file count 41 | if (fileArray.length > maxFiles) { 42 | setSizeError(`Too many files (${maxFiles} max)`); 43 | return; 44 | } 45 | 46 | // Check individual file sizes 47 | const oversizedFiles = fileArray.filter(file => file.size > maxSize); 48 | if (oversizedFiles.length > 0) { 49 | setSizeError(`Some files are too large (${Math.round(maxSize/1024/1024)}MB max per file)`); 50 | return; 51 | } 52 | 53 | setSizeError(null); 54 | field.onChange(fileArray); 55 | } else { 56 | setSizeError(null); 57 | field.onChange(null); 58 | } 59 | }} 60 | label={label} 61 | error={!!fieldState.error || !!sizeError} 62 | helperText={fieldState.error?.message || sizeError} 63 | InputProps={{ 64 | inputProps: { 65 | accept, 66 | multiple: true 67 | }, 68 | startAdornment: 69 | }} 70 | sx={{ width: width || '100%', ...sx }} 71 | placeholder={`Select up to ${maxFiles} files`} 72 | fullWidth={!width} 73 | multiple 74 | /> 75 | )} 76 | /> 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /frontend/src/components/forms/SetupForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { initialItems } from '../../constants/constants'; 4 | import { useAppContext } from '../../context/useAppContext'; 5 | import { SetupModal } from '../modals/SetupModal'; 6 | 7 | type SetupFormProps = { 8 | onSuccess: () => void; 9 | }; 10 | 11 | export const SetupForm: React.FC = ({ onSuccess }) => { 12 | const [showSetupModal, setShowSetupModal] = useState(true); 13 | const { updateConfig } = useAppContext(); 14 | 15 | const handleSetupComplete = async () => { 16 | // Mark setup as complete and save initial items to the layout 17 | await updateConfig({ 18 | isSetupComplete: true, 19 | search: true, 20 | searchProvider: { 21 | name: 'Google', 22 | url: 'https://www.google.com/search?q={query}' 23 | }, 24 | layout: { 25 | desktop: initialItems, 26 | mobile: initialItems 27 | } 28 | }); 29 | onSuccess(); 30 | }; 31 | 32 | return ( 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /frontend/src/components/forms/VirtualizedListBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | import React, { forwardRef } from 'react'; 3 | import { AutoSizer, List, ListRowRenderer } from 'react-virtualized'; 4 | 5 | export const VirtualizedListbox = forwardRef>( 6 | function VirtualizedListbox(props, ref) { 7 | const { children, ...other } = props; 8 | const items = React.Children.toArray(children); 9 | 10 | const rowRenderer: ListRowRenderer = ({ index, key, style }) => { 11 | return ( 12 |
13 | {items[index]} 14 |
15 | ); 16 | }; 17 | 18 | return ( 19 | 20 | 21 | {({ width, height }) => ( 22 | 29 | )} 30 | 31 | 32 | ); 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /frontend/src/components/forms/configs/GroupWidgetConfig.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2 as Grid, Typography } from '@mui/material'; 2 | import { UseFormReturn } from 'react-hook-form'; 3 | import { CheckboxElement, SelectElement, TextFieldElement } from 'react-hook-form-mui'; 4 | 5 | import { theme } from '../../../theme/theme'; 6 | import { FormValues } from '../AddEditForm'; 7 | 8 | interface GroupWidgetConfigProps { 9 | formContext: UseFormReturn; 10 | } 11 | 12 | const MAX_ITEMS_OPTIONS = [ 13 | { id: '3', label: '3 Items (3x1)' }, 14 | { id: '6_2x3', label: '6 Items (2x3)' }, 15 | { id: '6_3x2', label: '6 Items (3x2)' }, 16 | { id: '8_4x2', label: '8 Items (4x2)' } 17 | ]; 18 | 19 | export const GroupWidgetConfig = ({ formContext }: GroupWidgetConfigProps) => { 20 | return ( 21 | <> 22 | 23 | 44 | 45 | 46 | 47 | 48 | 70 | 71 | 72 | 86 | 87 | 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /frontend/src/components/forms/configs/PlaceholderConfig.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2 as Grid } from '@mui/material'; 2 | import { UseFormReturn } from 'react-hook-form'; 3 | import { CheckboxElement, SelectElement } from 'react-hook-form-mui'; 4 | 5 | import { useIsMobile } from '../../../hooks/useIsMobile'; 6 | import { COLORS } from '../../../theme/styles'; 7 | import { theme } from '../../../theme/theme'; 8 | import { FormValues } from '../AddEditForm'; 9 | 10 | interface PlaceholderConfigProps { 11 | formContext: UseFormReturn; 12 | } 13 | 14 | const PLACEHOLDER_SIZE_OPTIONS = [ 15 | { id: 'app', label: 'App Shortcut' }, 16 | { id: 'widget', label: 'Widget' }, 17 | { id: 'row', label: 'Full Row' }, 18 | ]; 19 | 20 | export const PlaceholderConfig = ({ formContext }: PlaceholderConfigProps) => { 21 | const isMobile = useIsMobile(); 22 | 23 | return ( 24 | <> 25 | 26 | 61 | 62 | 63 | 64 | 79 | 80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /frontend/src/components/forms/configs/WidgetConfig.tsx: -------------------------------------------------------------------------------- 1 | import { UseFormReturn } from 'react-hook-form'; 2 | 3 | import { FormValues } from '../AddEditForm'; 4 | import { DateTimeWidgetConfig } from './DateTimeWidgetConfig'; 5 | import { DualWidgetConfig } from './DualWidgetConfig'; 6 | import { GroupWidgetConfig } from './GroupWidgetConfig'; 7 | import { PiholeWidgetConfig } from './PiholeWidgetConfig'; 8 | import { SystemMonitorWidgetConfig } from './SystemMonitorWidgetConfig'; 9 | import { TorrentClientWidgetConfig } from './TorrentClientWidgetConfig'; 10 | import { WeatherWidgetConfig } from './WeatherWidgetConfig'; 11 | import { DashboardItem, ITEM_TYPE } from '../../../types'; 12 | 13 | interface WidgetConfigProps { 14 | formContext: UseFormReturn; 15 | widgetType: string; 16 | existingItem?: DashboardItem | null; 17 | } 18 | 19 | export const WidgetConfig = ({ formContext, widgetType, existingItem }: WidgetConfigProps) => { 20 | switch (widgetType) { 21 | case ITEM_TYPE.WEATHER_WIDGET: 22 | return ; 23 | case ITEM_TYPE.DATE_TIME_WIDGET: 24 | return ; 25 | case ITEM_TYPE.SYSTEM_MONITOR_WIDGET: 26 | return ; 27 | case ITEM_TYPE.PIHOLE_WIDGET: 28 | return ; 29 | case ITEM_TYPE.TORRENT_CLIENT: 30 | return ; 31 | case ITEM_TYPE.DUAL_WIDGET: 32 | return ; 33 | case ITEM_TYPE.GROUP_WIDGET: 34 | return ; 35 | default: 36 | return null; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/components/forms/configs/index.ts: -------------------------------------------------------------------------------- 1 | export { WeatherWidgetConfig } from './WeatherWidgetConfig'; 2 | export { SystemMonitorWidgetConfig } from './SystemMonitorWidgetConfig'; 3 | export { PiholeWidgetConfig } from './PiholeWidgetConfig'; 4 | export { TorrentClientWidgetConfig } from './TorrentClientWidgetConfig'; 5 | export { DualWidgetConfig } from './DualWidgetConfig'; 6 | export { WidgetConfig } from './WidgetConfig'; 7 | export { AppShortcutConfig } from './AppShortcutConfig'; 8 | export { DateTimeWidgetConfig } from './DateTimeWidgetConfig'; 9 | export { GroupWidgetConfig } from './GroupWidgetConfig'; 10 | export { PlaceholderConfig } from './PlaceholderConfig'; 11 | -------------------------------------------------------------------------------- /frontend/src/components/modals/CenteredModal.tsx: -------------------------------------------------------------------------------- 1 | import CloseIcon from '@mui/icons-material/Close'; 2 | import { AppBar, Backdrop, Box, Modal, styled, Toolbar, Tooltip, Typography, useMediaQuery } from '@mui/material'; 3 | import { ReactNode, useEffect } from 'react'; 4 | 5 | import { useWindowDimensions } from '../../hooks/useWindowDimensions'; 6 | import { styles } from '../../theme/styles'; 7 | import { theme } from '../../theme/theme'; 8 | 9 | type Props = { 10 | open: boolean; 11 | handleClose: () => void; 12 | title?: string; 13 | children: ReactNode; 14 | width?: string 15 | height?: string 16 | } 17 | 18 | export const CenteredModal = ({ open, handleClose, children, width, height, title }: Props) => { 19 | const windowDimensions = useWindowDimensions(); 20 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')); 21 | 22 | 23 | const setWidth = () => { 24 | if (width) { 25 | return width; 26 | } 27 | 28 | if (windowDimensions.width <= 1200) { 29 | return '92vw'; 30 | } 31 | 32 | return '40vw'; 33 | }; 34 | 35 | const style = { 36 | position: 'absolute' as const, 37 | top: '50%', 38 | left: '50%', 39 | transform: 'translate(-50%, -50%)', 40 | width: setWidth(), 41 | bgcolor: 'background.paper', 42 | borderRadius: '8px', 43 | boxShadow: 24, 44 | maxHeight: '90vh', // Limit the maximum height to 90% of viewport height 45 | display: 'flex', 46 | flexDirection: 'column' 47 | }; 48 | 49 | return ( 50 | 58 | 59 | {/* AppBar with Title and Close Button */} 60 | 65 | 73 | {title} 74 | e.stopPropagation()} // Stop drag from interfering 76 | onClick={(e) => e.stopPropagation()} // Prevent drag from triggering on click 77 | sx={styles.vcenter} 78 | > 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | {/* Modal Content (Fix for Scroll Issues) */} 89 | 112 | {children} 113 | 114 | 115 | 116 | ); 117 | }; 118 | -------------------------------------------------------------------------------- /frontend/src/components/modals/PopupManager.ts: -------------------------------------------------------------------------------- 1 | import { grey } from '@mui/material/colors'; 2 | import Swal from 'sweetalert2'; 3 | 4 | import { theme } from '../../theme/theme'; 5 | const CONFIRM_COLOR = theme.palette.success.main; 6 | const ThemedAlert = Swal.mixin({ 7 | customClass: { 8 | confirmButton: 'confirm-btn', 9 | cancelButton: 'cancel-btn' 10 | }, 11 | }); 12 | 13 | export type ConfirmationOptions = { 14 | title: string, 15 | confirmAction: () => any, 16 | confirmText?: string, 17 | denyAction?: () => any, 18 | text?: string; 19 | html?: string; 20 | } 21 | 22 | export class PopupManager { 23 | public static success(text?: string, action?: () => any): void { 24 | ThemedAlert.fire({ 25 | title: 'Success', 26 | text: text && text, 27 | icon: 'success', 28 | iconColor: theme.palette.success.main, 29 | showConfirmButton: true, 30 | confirmButtonColor: CONFIRM_COLOR, 31 | }).then(() => action && action()); 32 | } 33 | public static failure(text?: string, action?: () => any): void { 34 | ThemedAlert.fire({ 35 | title: 'Error', 36 | text: text && text, 37 | confirmButtonColor: CONFIRM_COLOR, 38 | icon: 'error', 39 | iconColor: theme.palette.error.main 40 | }).then(() => action && action()); 41 | } 42 | 43 | public static loading(text?: string, action?: () => any): void { 44 | ThemedAlert.fire({ 45 | title: 'Loading', 46 | text: text && text, 47 | confirmButtonColor: CONFIRM_COLOR, 48 | icon: 'info' 49 | }).then(() => action && action()); 50 | } 51 | 52 | public static confirmation (options: ConfirmationOptions) { 53 | Swal.fire({ 54 | title: `${options.title}`, 55 | confirmButtonText: options.confirmText ? options.confirmText : 'Yes', 56 | confirmButtonColor: CONFIRM_COLOR, 57 | text: options.text && options.text, 58 | icon: 'info', 59 | showDenyButton: true, 60 | denyButtonColor: grey[500], 61 | denyButtonText: 'Cancel', 62 | reverseButtons: true 63 | }).then((result: any) => { 64 | if (result.isConfirmed) { 65 | options.confirmAction(); 66 | } else if (result.isDenied) { 67 | if (options.denyAction) { 68 | options.denyAction(); 69 | } 70 | } 71 | }); 72 | } 73 | 74 | public static deleteConfirmation (options: ConfirmationOptions) { 75 | Swal.fire({ 76 | title: `${options.title}`, 77 | confirmButtonText: options.confirmText ? options.confirmText : 'Yes, Delete', 78 | confirmButtonColor: theme.palette.error.main, 79 | text: options.text ? options.text : 'This action cannot be undone', 80 | html: options.html && options.html, 81 | icon: 'error', 82 | iconColor: theme.palette.error.main, 83 | showDenyButton: true, 84 | denyButtonText: 'Cancel', 85 | denyButtonColor: grey[500], 86 | reverseButtons: true 87 | }).then((result: any) => { 88 | if (result.isConfirmed) { 89 | options.confirmAction(); 90 | } else if (result.isDenied) { 91 | if (options.denyAction) { 92 | options.denyAction(); 93 | } 94 | } 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/components/navbar/WithNav.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | import { useEffect } from 'react'; 3 | import { Outlet, useLocation, useNavigate } from 'react-router-dom'; 4 | 5 | import { useAppContext } from '../../context/useAppContext'; 6 | import { ResponsiveAppBar } from './ResponsiveAppBar'; 7 | 8 | export const WithNav = () => { 9 | const { isLoggedIn, isAdmin } = useAppContext(); 10 | const location = useLocation(); 11 | const navigate = useNavigate(); 12 | 13 | // Protect settings route - only accessible to logged in admin users 14 | useEffect(() => { 15 | if (location.pathname === '/settings' && (!isLoggedIn || !isAdmin)) { 16 | navigate('/'); 17 | } 18 | }, [location.pathname, isLoggedIn, isAdmin, navigate]); 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/src/components/navbar/WithoutNav.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router'; 2 | 3 | export const WithoutNav = () => ; 4 | -------------------------------------------------------------------------------- /frontend/src/components/search/GlobalSearch.tsx: -------------------------------------------------------------------------------- 1 | import { Box, useMediaQuery } from '@mui/material'; 2 | import { useEffect, useState } from 'react'; 3 | import { useLocation } from 'react-router-dom'; 4 | 5 | import { SearchBar } from './SearchBar'; 6 | import { useAppContext } from '../../context/useAppContext'; 7 | import { theme } from '../../theme/theme'; 8 | import { DashboardItem, ITEM_TYPE } from '../../types'; 9 | import { GroupItem } from '../../types/group'; 10 | import { getIconPath } from '../../utils/utils'; 11 | 12 | type SearchOption = { 13 | label: string; 14 | icon?: string; 15 | url?: string; 16 | }; 17 | 18 | export const GlobalSearch = () => { 19 | const [searchOptions, setSearchOptions] = useState([]); 20 | const [searchValue, setSearchValue] = useState(''); 21 | const { dashboardLayout, config, pages } = useAppContext(); 22 | const location = useLocation(); 23 | const isHomePage = location.pathname === '/'; 24 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')); 25 | 26 | useEffect(() => { 27 | const processItemsFromLayout = (layout: DashboardItem[]): SearchOption[] => { 28 | // Start with items that have direct URLs 29 | const directOptions = layout 30 | .filter((item: DashboardItem) => item.url) 31 | .map((item) => { 32 | let finalUrl = item.url; 33 | 34 | // For torrent client widgets, construct the proper URL from config 35 | if (item.type === ITEM_TYPE.TORRENT_CLIENT && item.config) { 36 | const protocol = item.config.ssl ? 'https' : 'http'; 37 | const host = item.config.host || 'localhost'; 38 | const port = item.config.port || '8080'; 39 | finalUrl = `${protocol}://${host}:${port}`; 40 | } 41 | 42 | return { 43 | label: item.label, 44 | icon: getIconPath(item.icon?.path as string), 45 | url: finalUrl, 46 | }; 47 | }); 48 | 49 | // Find group widgets and extract their items 50 | const groupWidgetItems: SearchOption[] = []; 51 | 52 | layout.forEach((item: DashboardItem) => { 53 | // Check if this is a group widget with items 54 | if (item.type === ITEM_TYPE.GROUP_WIDGET && 55 | item.config?.items && 56 | Array.isArray(item.config.items)) { 57 | 58 | // Extract items from the group 59 | const groupItems = item.config.items as GroupItem[]; 60 | 61 | // Map group items to search options 62 | const groupOptions = groupItems.map((groupItem: GroupItem) => ({ 63 | label: groupItem.name, 64 | icon: getIconPath(groupItem.icon || ''), 65 | url: groupItem.url, 66 | })); 67 | 68 | groupWidgetItems.push(...groupOptions); 69 | } 70 | }); 71 | 72 | return [...directOptions, ...groupWidgetItems]; 73 | }; 74 | 75 | let allOptions: SearchOption[] = []; 76 | 77 | if (isHomePage && config && pages) { 78 | // On home page, include items from all pages (current device type only) 79 | // First add items from current dashboard layout 80 | allOptions = processItemsFromLayout(dashboardLayout); 81 | 82 | // Then add items from all pages (current device type only) 83 | pages.forEach(page => { 84 | const pageItems = isMobile 85 | ? processItemsFromLayout(page.layout.mobile) 86 | : processItemsFromLayout(page.layout.desktop); 87 | 88 | // Add page items to allOptions 89 | allOptions.push(...pageItems); 90 | }); 91 | 92 | // Deduplicate the entire array based on URL and label combination 93 | const seen = new Set(); 94 | allOptions = allOptions.filter(option => { 95 | // Create a unique key combining URL and label (in case URL is undefined) 96 | const key = `${option.url || 'no-url'}-${option.label}`; 97 | if (seen.has(key)) { 98 | return false; 99 | } 100 | seen.add(key); 101 | return true; 102 | }); 103 | } else { 104 | // On other pages, only show items from current page 105 | allOptions = processItemsFromLayout(dashboardLayout); 106 | } 107 | 108 | setSearchOptions(allOptions); 109 | }, [dashboardLayout, isHomePage, config, pages, isMobile]); 110 | 111 | return ( 112 | 113 | 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /frontend/src/components/toast/ToastInitializer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { ToastManager, useToast } from './ToastManager'; 4 | 5 | export const ToastInitializer: React.FC = () => { 6 | const toastContext = useToast(); 7 | 8 | useEffect(() => { 9 | // Set the global instance for static access 10 | ToastManager.setInstance(toastContext); 11 | }, [toastContext]); 12 | 13 | return null; // This component doesn't render anything 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | import { ITEM_TYPE } from '../types'; 2 | 3 | export const initialItems = [ 4 | { id: 'weather-widget', label: 'Weather Widget', type: ITEM_TYPE.WEATHER_WIDGET }, 5 | { id: 'time-date-widget', label: 'Time & Date Widget', type: ITEM_TYPE.DATE_TIME_WIDGET }, 6 | { id: 'system-monitor-widget', label: 'System Monitor Widget', type: ITEM_TYPE.SYSTEM_MONITOR_WIDGET }, 7 | ...Array.from({ length: 18 }, (_, index) => ({ 8 | id: `item-${index + 1}`, 9 | label: `Item ${index + 1}`, 10 | type: 'blank-app', 11 | })), 12 | ]; 13 | 14 | export const BACKEND_URL = import.meta.env.PROD ? '' : 'http://localhost:5000'; 15 | 16 | export const FIFTEEN_MIN_IN_MS = 900000; 17 | export const TWO_MIN_IN_MS = 120000; 18 | -------------------------------------------------------------------------------- /frontend/src/constants/version.ts: -------------------------------------------------------------------------------- 1 | // Get version from Vite's environment variables 2 | // This was set in vite.config.ts to read from the root package.json 3 | const appVersion = import.meta.env.APP_VERSION as string; 4 | 5 | // Export the version for use in the app 6 | export const APP_VERSION = appVersion; 7 | -------------------------------------------------------------------------------- /frontend/src/constants/widget-dimensions.ts: -------------------------------------------------------------------------------- 1 | // Widget dimension constants for consistent sizing across components 2 | 3 | // DualWidget container dimensions 4 | export const DUAL_WIDGET_CONTAINER_HEIGHT = { 5 | xs: '100%', 6 | sm: '25rem', 7 | md: '25rem', 8 | lg: '25rem' 9 | }; 10 | 11 | // DualWidget top/bottom section dimensions 12 | export const DUAL_WIDGET_SECTION_HEIGHT = { 13 | xs: '50%', 14 | sm: '11.5rem', 15 | md: '11.5rem', 16 | lg: '11.5rem' 17 | }; 18 | 19 | // Individual widget dimensions inside dual widgets 20 | export const DUAL_WIDGET_INNER_HEIGHT = { 21 | xs: '100%', 22 | sm: '11rem', 23 | md: '11rem', 24 | lg: '11rem' 25 | }; 26 | 27 | // Standard widget dimensions (for single widgets) 28 | export const STANDARD_WIDGET_HEIGHT = { 29 | xs: '100%', 30 | sm: '12rem', 31 | md: '12rem', 32 | lg: '12rem' 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/src/context/AppContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, Dispatch, SetStateAction } from 'react'; 2 | 3 | import { Config, DashboardItem, NewItem, Page } from '../types'; 4 | 5 | export interface IAppContext { 6 | dashboardLayout: DashboardItem[]; 7 | setDashboardLayout: Dispatch>; 8 | refreshDashboard: () => Promise; 9 | saveLayout: (items: DashboardItem[]) => void; 10 | addItem: (itemToAdd: NewItem) => Promise; 11 | updateItem: (id: string, updatedData: Partial) => Promise; 12 | editMode: boolean; 13 | setEditMode: Dispatch>; 14 | config: Config | undefined; 15 | updateConfig: (partialConfig: Partial) => Promise; 16 | // Page management 17 | currentPageId: string | null; 18 | setCurrentPageId: Dispatch>; 19 | pages: Page[]; 20 | addPage: (name: string, adminOnly?: boolean) => Promise; 21 | deletePage: (pageId: string) => Promise; 22 | switchToPage: (pageId: string) => Promise; 23 | pageNameToSlug: (pageName: string) => string; 24 | moveItemToPage: (itemId: string, targetPageId: string | null) => Promise; 25 | // Authentication & setup states 26 | isLoggedIn: boolean; 27 | setIsLoggedIn: Dispatch>; 28 | username: string | null; 29 | setUsername: Dispatch>; 30 | isAdmin: boolean; 31 | setIsAdmin: Dispatch>; 32 | isFirstTimeSetup: boolean | null; 33 | setIsFirstTimeSetup: Dispatch>; 34 | setupComplete: boolean; 35 | setSetupComplete: Dispatch>; 36 | checkIfUsersExist: () => Promise; 37 | checkLoginStatus: () => Promise; 38 | // Update states 39 | updateAvailable: boolean; 40 | latestVersion: string | null; 41 | releaseUrl: string | null; 42 | checkForAppUpdates: () => Promise; 43 | // Recently updated state 44 | recentlyUpdated: boolean; 45 | handleVersionViewed: () => Promise; 46 | } 47 | 48 | export const AppContext = createContext(null!); 49 | -------------------------------------------------------------------------------- /frontend/src/context/useAppContext.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AppContext } from './AppContext'; 4 | 5 | export const useAppContext = () => useContext(AppContext); 6 | -------------------------------------------------------------------------------- /frontend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | // / 2 | 3 | interface ImportMetaEnv { 4 | readonly APP_VERSION: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hooks/useIsMobile.tsx: -------------------------------------------------------------------------------- 1 | import { useWindowDimensions } from './useWindowDimensions'; 2 | 3 | export const useIsMobile = () => { 4 | const { width } = useWindowDimensions(); 5 | return width < 600; 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/hooks/useServiceStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { DashApi } from '../api/dash-api'; 4 | import { TWO_MIN_IN_MS } from '../constants/constants'; 5 | 6 | export function useServiceStatus( 7 | pingUrl: string | null | undefined, 8 | healthCheckType: 'http' | 'ping' = 'http', 9 | intervalMs = TWO_MIN_IN_MS 10 | ) { 11 | const [isOnline, setIsOnline] = useState(null); 12 | 13 | useEffect(() => { 14 | if (!pingUrl) return; 15 | 16 | let timer: NodeJS.Timeout | null = null; 17 | 18 | async function checkStatus() { 19 | try { 20 | if (!pingUrl) return; 21 | const status = await DashApi.checkServiceHealth(pingUrl, healthCheckType); 22 | setIsOnline(status === 'online'); 23 | } catch { 24 | setIsOnline(false); 25 | } 26 | } 27 | 28 | checkStatus(); 29 | timer = setInterval(checkStatus, intervalMs); 30 | 31 | return () => { 32 | if (timer) clearInterval(timer); 33 | }; 34 | }, [pingUrl, healthCheckType, intervalMs]); 35 | 36 | return isOnline; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/hooks/useWindowDimensions.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const getWindowDimensions = () => { 4 | const { innerWidth: width, innerHeight: height } = window; 5 | return { 6 | width, 7 | height 8 | }; 9 | }; 10 | 11 | export const useWindowDimensions = () => { 12 | const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); 13 | 14 | useEffect(() => { 15 | function handleResize() { 16 | setWindowDimensions(getWindowDimensions()); 17 | } 18 | 19 | window.addEventListener('resize', handleResize); 20 | return () => window.removeEventListener('resize', handleResize); 21 | }, []); 22 | 23 | return windowDimensions; 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { ThemeProvider } from '@mui/material'; 4 | 5 | import { App } from './App'; 6 | import { AppContextProvider } from './context/AppContextProvider'; 7 | import { theme } from './theme/theme'; 8 | 9 | const root = ReactDOM.createRoot( 10 | document.getElementById('root') as HTMLElement 11 | ); 12 | 13 | root.render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from '@mui/material'; 2 | import { CssBaseline, GlobalStyles } from '@mui/material'; 3 | import { StrictMode } from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { BrowserRouter as Router } from 'react-router-dom'; 6 | 7 | import { DashApi } from './api/dash-api.ts'; 8 | import { App } from './App.tsx'; 9 | import { ToastInitializer } from './components/toast/ToastInitializer.tsx'; 10 | import { ToastProvider } from './components/toast/ToastManager.tsx'; 11 | import { AppContextProvider } from './context/AppContextProvider.tsx'; 12 | import { theme } from './theme/theme.ts'; 13 | import './theme/index.css'; 14 | 15 | DashApi.setupAxiosInterceptors(); 16 | 17 | createRoot(document.getElementById('root')!).render( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /frontend/src/pages/DashboardPage.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardGrid } from '../components/dashboard/DashboardGrid'; 2 | 3 | export const DashboardPage = () => { 4 | // All page switching logic is now handled in AppContextProvider 5 | // This component just renders the dashboard grid 6 | return ( 7 | 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/pages/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper } from '@mui/material'; 2 | 3 | import { LoginForm } from '../components/forms/LoginForm'; 4 | import { styles } from '../theme/styles'; 5 | 6 | export const LoginPage = () => { 7 | 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/pages/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper, Typography } from '@mui/material'; 2 | import { useEffect } from 'react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { SettingsForm } from '../components/forms/SettingsForm'; 6 | import { useAppContext } from '../context/useAppContext'; 7 | import { styles } from '../theme/styles'; 8 | 9 | export const SettingsPage = () => { 10 | const { isLoggedIn, isAdmin } = useAppContext(); 11 | const navigate = useNavigate(); 12 | 13 | // Check if user is logged in and is an admin 14 | useEffect(() => { 15 | // If not logged in, redirect to login page 16 | if (!isLoggedIn) { 17 | navigate('/login'); 18 | return; 19 | } 20 | 21 | // If not admin, redirect to dashboard 22 | if (!isAdmin) { 23 | navigate('/'); 24 | } 25 | }, [isLoggedIn, isAdmin, navigate]); 26 | 27 | if (!isLoggedIn || !isAdmin) { 28 | return null; // Will redirect in useEffect 29 | } 30 | 31 | return ( 32 | 33 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/src/theme/App.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Earth Orbiter'; 3 | src: url('earthorbiter.ttf') format('truetype'); 4 | } 5 | 6 | * { 7 | -webkit-touch-callout: none !important; 8 | -webkit-tap-highlight-color: transparent !important; /* Remove blue highlight */ 9 | touch-action: manipulation !important; /* Prevents long-press actions */ 10 | } 11 | 12 | html, body { 13 | width: 100vw; 14 | /* background: url('/space4k-min.webp'); */ 15 | background-size: cover; 16 | background-repeat: no-repeat; 17 | background-position: center; 18 | background-attachment: scroll; 19 | image-rendering: crisp-edges; 20 | -webkit-touch-callout: none !important; 21 | /* Fix to prevent layout shift when modals open */ 22 | padding-right: 0 !important; /* Prevent padding added by MUI */ 23 | } 24 | 25 | body { 26 | overflow-x: hidden; 27 | } 28 | 29 | a, img { 30 | -webkit-user-drag: none !important; 31 | user-select: none !important; 32 | -webkit-user-select: none !important; 33 | -webkit-touch-callout: none !important; 34 | } 35 | 36 | #root { 37 | /* text-align: center; */ 38 | /* margin: auto; */ 39 | user-select: none !important; 40 | -ms-user-select: none; 41 | -moz-user-select: -moz-none; 42 | -webkit-user-select: none !important; 43 | -webkit-user-drag: none !important; 44 | -webkit-touch-callout: none !important; 45 | } 46 | 47 | .swal2-container { 48 | z-index: 9999999 !important; 49 | user-select: auto; 50 | } 51 | 52 | .scale { 53 | transition: all .15s ease-in-out; 54 | } 55 | 56 | @keyframes shake { 57 | 0% { transform: translate(0.3px, 0.3px) rotate(0deg); } 58 | 10% { transform: translate(-0.3px, -0.5px) rotate(-0.2deg); } 59 | 20% { transform: translate(-0.7px, 0px) rotate(0.2deg); } 60 | 30% { transform: translate(0.7px, 0.5px) rotate(0deg); } 61 | 40% { transform: translate(0.3px, -0.3px) rotate(0.2deg); } 62 | 50% { transform: translate(-0.3px, 0.5px) rotate(-0.2deg); } 63 | 60% { transform: translate(-0.7px, 0.3px) rotate(0deg); } 64 | 70% { transform: translate(0.7px, 0.3px) rotate(-0.2deg); } 65 | 80% { transform: translate(-0.3px, -0.3px) rotate(0.2deg); } 66 | 90% { transform: translate(0.3px, 0.5px) rotate(0deg); } 67 | 100% { transform: translate(0.3px, -0.5px) rotate(-0.2deg); } 68 | } 69 | 70 | .wiggle-container { 71 | animation: shake 1s infinite ease-in-out; 72 | transform: translate3d(0, 0, 0); 73 | backface-visibility: hidden; 74 | perspective: 1000px; 75 | pointer-events: none; /* Allows smooth scrolling */ 76 | transform-origin: center; 77 | will-change: transform; 78 | contain: layout; 79 | } 80 | 81 | @media (hover: hover) and (pointer: fine) { 82 | .scale:hover { 83 | transform: scale(1.05); 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /frontend/src/theme/earthorbiter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnthonyGress/lab-dash/0dfee4595b9e04e342e7e1870459585a9b007d6d/frontend/src/theme/earthorbiter.ttf -------------------------------------------------------------------------------- /frontend/src/theme/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | /* Custom scrollbar styling */ 17 | ::-webkit-scrollbar { 18 | width: 8px; 19 | height: 8px; 20 | } 21 | 22 | ::-webkit-scrollbar-track { 23 | background: rgba(0, 0, 0, 0.1); 24 | border-radius: 4px; 25 | } 26 | 27 | ::-webkit-scrollbar-thumb { 28 | background: rgba(255, 255, 255, 0.3); 29 | border-radius: 4px; 30 | } 31 | 32 | ::-webkit-scrollbar-thumb:hover { 33 | background: rgba(255, 255, 255, 0.5); 34 | } 35 | 36 | div { 37 | -webkit-tap-highlight-color: transparent; 38 | } 39 | 40 | a { 41 | color: inherit !important; 42 | text-decoration: none !important; 43 | } 44 | 45 | body { 46 | margin: 0; 47 | min-width: 320px; 48 | min-height: 100vh; 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/theme/styles.ts: -------------------------------------------------------------------------------- 1 | export enum COLORS { 2 | GRAY = '#2e2e2e', 3 | DARK_GRAY = '#242424', 4 | LIGHT_GRAY = '#c9c9c9', 5 | LIGHT_GRAY_TRANSPARENT = 'rgba(201, 201, 201, 0.8)', 6 | LIGHT_GRAY_HOVER = '#767676', 7 | PURPLE = '#734CDE', 8 | TRANSPARENT_GRAY = 'rgba(46,46,46, .7)', 9 | TRANSPARENT_DARK_GRAY = 'rgba(46,46,46, .9)', 10 | BORDER = '#424242' 11 | } 12 | 13 | export const styles = { 14 | vcenter: { display: 'flex', justifyContent: 'center', alignContent: 'center', flexDirection: 'column' }, 15 | center: { display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'row' }, 16 | widgetContainer: { backgroundColor: COLORS.TRANSPARENT_GRAY, borderRadius: '4px', border: `1px solid ${COLORS.BORDER}`, height: '14rem' }, 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/types/dnd.ts: -------------------------------------------------------------------------------- 1 | import { DashboardItem } from '.'; 2 | import { GroupItem } from './group'; 3 | 4 | export interface DndDataForAppShortcut { 5 | type: string; 6 | id: string; 7 | originalItem?: DashboardItem; 8 | } 9 | 10 | export interface DndDataForGroupItem { 11 | type: 'group-item'; 12 | parentId: string; 13 | originalItem?: GroupItem; 14 | } 15 | 16 | export interface DndDataForGroupWidget { 17 | type: 'group-widget'; 18 | accepts: string[]; 19 | canDrop: boolean; 20 | } 21 | 22 | export interface DndDataForGroupContainer { 23 | type: 'group-container'; 24 | groupId: string; 25 | accepts: string; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/types/group.ts: -------------------------------------------------------------------------------- 1 | export interface GroupItem { 2 | id: string; 3 | name: string; 4 | url: string; 5 | icon?: string; 6 | isWol?: boolean; 7 | macAddress?: string; 8 | broadcastAddress?: string; 9 | port?: number; 10 | healthUrl?: string; 11 | healthCheckType?: string; 12 | adminOnly?: boolean; 13 | [key: string]: any; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum ITEM_TYPE { 2 | WEATHER_WIDGET = 'weather-widget', 3 | DATE_TIME_WIDGET = 'date-time-widget', 4 | SYSTEM_MONITOR_WIDGET = 'system-monitor-widget', 5 | TORRENT_CLIENT = 'torrent-client', 6 | PIHOLE_WIDGET = 'pihole-widget', 7 | DUAL_WIDGET = 'dual-widget', 8 | GROUP_WIDGET = 'group-widget', 9 | APP_SHORTCUT = 'app-shortcut', 10 | PLACEHOLDER = 'placeholder', 11 | // Legacy placeholder types - keeping for backward compatibility 12 | BLANK_APP = 'blank-app', 13 | BLANK_WIDGET = 'blank-widget', 14 | BLANK_ROW = 'blank-row', 15 | PAGE = 'page' 16 | } 17 | 18 | export enum TORRENT_CLIENT_TYPE { 19 | QBITTORRENT = 'qbittorrent', 20 | DELUGE = 'deluge', 21 | TRANSMISSION = 'transmission' 22 | } 23 | 24 | export type NewItem = { 25 | name?: string; 26 | icon?: { path: string; name: string; source?: string }; 27 | url?: string; 28 | label: string; 29 | type: string; 30 | showLabel?: boolean; 31 | adminOnly?: boolean; 32 | config?: { 33 | temperatureUnit?: string; 34 | healthUrl?: string; 35 | healthCheckType?: string; 36 | // Security flags for sensitive data 37 | _hasApiToken?: boolean; 38 | _hasPassword?: boolean; 39 | [key: string]: any; 40 | }; 41 | } 42 | 43 | export type Icon = { 44 | path: string; 45 | name: string; 46 | source?: string; 47 | guidelines?: string; 48 | } 49 | 50 | export type SearchProvider = { 51 | name: string; 52 | url: string; 53 | } 54 | 55 | export type Page = { 56 | id: string; 57 | name: string; 58 | adminOnly?: boolean; 59 | layout: { 60 | desktop: DashboardItem[]; 61 | mobile: DashboardItem[]; 62 | }; 63 | } 64 | 65 | export type Config = { 66 | layout: { 67 | desktop: DashboardItem[]; 68 | mobile: DashboardItem[]; 69 | }, 70 | pages?: Page[]; 71 | title?: string; 72 | backgroundImage?: string; 73 | search?: boolean; 74 | searchProvider?: SearchProvider; 75 | isSetupComplete?: boolean; 76 | lastSeenVersion?: string; 77 | } 78 | 79 | export type UploadImageResponse = { 80 | message: string; 81 | filePath: string; 82 | } 83 | 84 | export type DashboardLayout = { 85 | layout: { 86 | desktop: DashboardItem[]; 87 | mobile: DashboardItem[]; 88 | } 89 | } 90 | 91 | export type DashboardItem = { 92 | id: string; 93 | label: string; 94 | type: string; 95 | url?: string; 96 | icon?: { path: string; name: string; source?: string; }; 97 | showLabel?: boolean; 98 | adminOnly?: boolean; 99 | config?: { 100 | temperatureUnit?: string; 101 | healthUrl?: string; 102 | healthCheckType?: string; 103 | // Security flags for sensitive data 104 | _hasApiToken?: boolean; 105 | _hasPassword?: boolean; 106 | [key: string]: any; 107 | }; 108 | }; 109 | 110 | -------------------------------------------------------------------------------- /frontend/src/utils/updateChecker.ts: -------------------------------------------------------------------------------- 1 | // import { APP_VERSION } from '../constants/version'; 2 | import { getAppVersion } from './version'; 3 | 4 | interface GitHubRelease { 5 | tag_name: string; 6 | name: string; 7 | published_at: string; 8 | html_url: string; 9 | } 10 | 11 | /** 12 | * Compares two semver version strings 13 | * @returns negative if v1 < v2, 0 if v1 === v2, positive if v1 > v2 14 | */ 15 | export const compareVersions = (v1: string, v2: string): number => { 16 | const v1Parts = v1.replace(/^v/, '').split('.').map(Number); 17 | const v2Parts = v2.replace(/^v/, '').split('.').map(Number); 18 | 19 | for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { 20 | const v1Part = v1Parts[i] || 0; 21 | const v2Part = v2Parts[i] || 0; 22 | if (v1Part !== v2Part) { 23 | return v1Part - v2Part; 24 | } 25 | } 26 | 27 | return 0; 28 | }; 29 | 30 | /** 31 | * Fetches the latest release from GitHub 32 | */ 33 | export const fetchLatestRelease = async (): Promise => { 34 | try { 35 | // Use fetch instead of axios to have more control over CORS and credentials 36 | const response = await fetch( 37 | 'https://api.github.com/repos/anthonygress/lab-dash/releases/latest', 38 | { 39 | method: 'GET', 40 | credentials: 'omit' // Explicitly omit credentials 41 | } 42 | ); 43 | 44 | if (!response.ok) { 45 | throw new Error(`GitHub API returned ${response.status}`); 46 | } 47 | 48 | return await response.json(); 49 | } catch (error) { 50 | console.error('Failed to fetch latest release:', error); 51 | return null; 52 | } 53 | }; 54 | 55 | /** 56 | * Checks if an update is available 57 | */ 58 | export const checkForUpdates = async (): Promise<{ 59 | updateAvailable: boolean; 60 | latestVersion: string | null; 61 | releaseUrl: string | null; 62 | }> => { 63 | try { 64 | const latestRelease = await fetchLatestRelease(); 65 | 66 | if (!latestRelease) { 67 | return { updateAvailable: false, latestVersion: null, releaseUrl: null }; 68 | } 69 | 70 | const latestVersion = latestRelease.tag_name; 71 | const currentVersion = getAppVersion(); 72 | 73 | // Compare versions 74 | const comparison = compareVersions(latestVersion, currentVersion); 75 | const updateAvailable = comparison > 0; 76 | 77 | return { 78 | updateAvailable, 79 | latestVersion: updateAvailable ? latestVersion : null, 80 | releaseUrl: updateAvailable ? latestRelease.html_url : null, 81 | }; 82 | } catch (error) { 83 | console.error('Error checking for updates:', error); 84 | return { updateAvailable: false, latestVersion: null, releaseUrl: null }; 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /frontend/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { BACKEND_URL } from '../constants/constants'; 2 | 3 | export const getIconPath = (icon: string | { path: string; source?: string }) => { 4 | if (!icon) return ''; 5 | 6 | const path = typeof icon === 'string' ? icon : icon?.path; 7 | const source = typeof icon === 'object' ? icon.source : undefined; 8 | 9 | if (!path) return ''; 10 | 11 | // If it's a custom uploaded icon, use the path directly 12 | if (source === 'custom' || path.startsWith('/uploads/')) { 13 | return `${BACKEND_URL}${path}`; 14 | } 15 | 16 | // Otherwise it's a built-in icon 17 | return `${BACKEND_URL}/icons/${path.replace('./assets/', '')}`; 18 | }; 19 | 20 | /** 21 | * Converts bytes to gigabytes (GB) and rounds the result. 22 | * @param bytes - The number of bytes to convert. 23 | * @param decimalPlaces - Number of decimal places to round to (default: 2). 24 | * @returns The size in GB as a string with specified decimal places. 25 | */ 26 | export const convertBytesToGB = (bytes: number, decimalPlaces: number = 2): string => { 27 | if (bytes <= 0) return '0.00 GB'; 28 | 29 | const gb = bytes / 1e9; 30 | return `${gb.toFixed(decimalPlaces)} GB`; 31 | }; 32 | 33 | /** 34 | * Converts seconds into a formatted string: X days, Y hours, Z minutes. 35 | * @param seconds - The total number of seconds to convert. 36 | * @returns A string representing the time in days, hours, and minutes. 37 | */ 38 | export const convertSecondsToUptime = (seconds: number): string => { 39 | if (seconds < 0) return 'Invalid input'; 40 | 41 | const days = Math.floor(seconds / 86400); // 86400 seconds in a day 42 | const hours = Math.floor((seconds % 86400) / 3600); // 3600 seconds in an hour 43 | const minutes = Math.floor((seconds % 3600) / 60); // 60 seconds in a minute 44 | 45 | const result = []; 46 | if (days > 0) result.push(`${days} day${days !== 1 ? 's' : ''}`); 47 | if (hours > 0) result.push(`${hours} hour${hours !== 1 ? 's' : ''}`); 48 | if (minutes > 0) result.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`); 49 | 50 | return result.length > 0 ? result.join(', ') : '0 minutes'; 51 | }; 52 | 53 | export const isValidHttpUrl = (url: string) => { 54 | const httpHttpsPattern = /^https?:\/\/\S+$/i; 55 | return httpHttpsPattern.test(url); 56 | }; 57 | 58 | /** 59 | * Formats a number with comma as thousands separator 60 | * @param value - The number to format 61 | * @returns Formatted number as string with commas 62 | */ 63 | export const formatNumber = (value: number): string => { 64 | return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 65 | }; 66 | 67 | /** 68 | * Checks if a string appears to be encrypted (starts with ENC: prefix) 69 | * @param text - The string to check 70 | * @returns True if the string starts with 'ENC:', false otherwise 71 | */ 72 | export const isEncrypted = (text: string): boolean => { 73 | return text?.startsWith('ENC:') || false; 74 | }; 75 | 76 | /** 77 | * Formats bytes to a human-readable string with units (B, KB, MB, GB, TB) 78 | * @param bytes - Number of bytes to format 79 | * @param decimals - Number of decimal places (default: 2) 80 | * @returns Formatted string with appropriate unit 81 | */ 82 | export const formatBytes = (bytes: number, decimals: number = 2): string => { 83 | if (bytes === 0) return '0 B'; 84 | 85 | const k = 1024; 86 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; 87 | 88 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 89 | 90 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`; 91 | }; 92 | -------------------------------------------------------------------------------- /frontend/src/utils/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Access the root package.json version that is exposed by Vite during build 3 | */ 4 | export const getAppVersion = (): string => { 5 | return import.meta.env.APP_VERSION as string; 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | import 'vite/client'; 2 | // / 3 | // / 4 | declare module '*.css'; 5 | declare module '*.png'; 6 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": [ 7 | "ES2020", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "module": "ESNext", 12 | "skipLibCheck": true, 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "esModuleInterop": true, 18 | "isolatedModules": true, 19 | "moduleDetection": "force", 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noFallthroughCasesInSwitch": true, 27 | "noUncheckedSideEffectImports": true, 28 | }, 29 | "include": [ 30 | "./", 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": [ 6 | "ES2023" 7 | ], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": [ 24 | "./vite.config.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc'; 2 | import { readFileSync } from 'fs'; 3 | import { resolve } from 'path'; 4 | import { defineConfig } from 'vite'; 5 | 6 | // Read the root package.json version 7 | const rootPackagePath = resolve(__dirname, '../package.json'); 8 | const rootPackageJson = JSON.parse(readFileSync(rootPackagePath, 'utf-8')); 9 | const rootVersion = rootPackageJson.version; 10 | 11 | // https://vite.dev/config/ 12 | export default defineConfig({ 13 | plugins: [react()], 14 | server: { 15 | port: 2022 16 | }, 17 | define: { 18 | // Expose the root version to the frontend code 19 | 'import.meta.env.APP_VERSION': JSON.stringify(rootVersion), 20 | }, 21 | // build: { 22 | // rollupOptions: { 23 | // output: { 24 | // manualChunks(id) { 25 | // if (id.includes('node_modules')) { 26 | // const modulePath = id.split('node_modules/')[1]; 27 | // const topLevelFolder = modulePath.split('/')[0]; 28 | // if (topLevelFolder !== '.pnpm') { 29 | // return topLevelFolder; 30 | // } 31 | // const scopedPackageName = modulePath.split('/')[1]; 32 | // const chunkName = scopedPackageName.split('@')[scopedPackageName.startsWith('@') ? 1 : 0]; 33 | // return chunkName; 34 | // } 35 | // } 36 | // } 37 | // } 38 | // } 39 | }); 40 | -------------------------------------------------------------------------------- /kubernetes/lab-dash/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: lab-dash 3 | description: A Helm chart for lab-dash 4 | type: application 5 | version: 0.1.0 6 | appVersion: "latest" -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "lab-dash.name" -}} 5 | {{ .Chart.Name }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | */}} 11 | {{- define "lab-dash.fullname" -}} 12 | {{ .Release.Name }}-{{ .Chart.Name }} 13 | {{- end }} 14 | 15 | {{/* 16 | Create chart label. 17 | */}} 18 | {{- define "lab-dash.chart" -}} 19 | {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 20 | {{- end }} -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "lab-dash.fullname" . }} 5 | namespace: {{ .Values.namespaceOverride | default .Release.Namespace }} 6 | labels: 7 | app: {{ include "lab-dash.name" . }} 8 | spec: 9 | replicas: {{ .Values.replicaCount }} 10 | selector: 11 | matchLabels: 12 | app: {{ include "lab-dash.name" . }} 13 | template: 14 | metadata: 15 | labels: 16 | app: {{ include "lab-dash.name" . }} 17 | spec: 18 | securityContext: 19 | runAsUser: 0 20 | containers: 21 | - name: lab-dash 22 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 23 | imagePullPolicy: {{ .Values.image.pullPolicy }} 24 | ports: 25 | - containerPort: 2022 26 | env: 27 | - name: SECRET 28 | value: "{{ .Values.secret }}" 29 | volumeMounts: 30 | - name: config 31 | mountPath: /config 32 | - name: uploads 33 | mountPath: /app/public/uploads 34 | - name: docker-sock 35 | mountPath: /var/run/docker.sock 36 | - name: sys-ro 37 | mountPath: /sys 38 | readOnly: true 39 | volumes: 40 | - name: config 41 | persistentVolumeClaim: 42 | claimName: {{ include "lab-dash.fullname" . }}-config 43 | - name: uploads 44 | persistentVolumeClaim: 45 | claimName: {{ include "lab-dash.fullname" . }}-uploads 46 | - name: docker-sock 47 | hostPath: 48 | path: /var/run/docker.sock 49 | - name: sys-ro 50 | hostPath: 51 | path: /sys -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled }} 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: {{ include "lab-dash.fullname" . }} 6 | namespace: {{ .Values.namespaceOverride | default .Release.Namespace }} 7 | labels: 8 | app: {{ include "lab-dash.name" . }} 9 | {{- with .Values.ingress.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | spec: 14 | {{- if .Values.ingress.className }} 15 | ingressClassName: {{ .Values.ingress.className }} 16 | {{- end }} 17 | rules: 18 | {{- range .Values.ingress.hosts }} 19 | - host: {{ .host }} 20 | http: 21 | paths: 22 | {{- range .paths }} 23 | - path: {{ .path }} 24 | pathType: {{ .pathType }} 25 | backend: 26 | service: 27 | name: {{ include "lab-dash.fullname" $ }} 28 | port: 29 | number: {{ $.Values.service.port }} 30 | {{- end }} 31 | {{- end }} 32 | {{- if .Values.ingress.tls }} 33 | tls: 34 | {{- toYaml .Values.ingress.tls | nindent 4 }} 35 | {{- end }} 36 | {{- end }} -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/pvc-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: {{ include "lab-dash.fullname" . }}-config 5 | namespace: {{ .Values.namespaceOverride | default .Release.Namespace }} 6 | labels: 7 | app: {{ include "lab-dash.name" . }} 8 | spec: 9 | accessModes: 10 | - ReadWriteOnce 11 | resources: 12 | requests: 13 | storage: 1Gi 14 | storageClassName: local-path -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/pvc-uploads.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: {{ include "lab-dash.fullname" . }}-uploads 5 | namespace: {{ .Values.namespaceOverride | default .Release.Namespace }} 6 | labels: 7 | app: {{ include "lab-dash.name" . }} 8 | spec: 9 | accessModes: 10 | - ReadWriteOnce 11 | resources: 12 | requests: 13 | storage: 1Gi 14 | storageClassName: local-path -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.volumes.config.enabled }} 2 | apiVersion: v1 3 | kind: PersistentVolumeClaim 4 | metadata: 5 | name: {{ include "lab-dash.fullname" . }} 6 | namespace: {{ .Values.namespaceOverride | default .Release.Namespace }} 7 | labels: 8 | app: {{ include "lab-dash.name" . }} 9 | spec: 10 | accessModes: 11 | - ReadWriteOnce 12 | resources: 13 | requests: 14 | storage: {{ .Values.volumes.config.size }} 15 | {{- end }} 16 | 17 | --- 18 | 19 | {{- if .Values.volumes.uploads.enabled }} 20 | apiVersion: v1 21 | kind: PersistentVolumeClaim 22 | metadata: 23 | name: lab-dash-uploads 24 | spec: 25 | accessModes: 26 | - ReadWriteOnce 27 | resources: 28 | requests: 29 | storage: {{ .Values.volumes.uploads.size }} 30 | {{- end }} -------------------------------------------------------------------------------- /kubernetes/lab-dash/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "lab-dash.fullname" . }} 5 | namespace: {{ .Values.namespaceOverride | default .Release.Namespace }} 6 | labels: 7 | app: {{ include "lab-dash.name" . }} 8 | spec: 9 | type: {{ .Values.service.type }} 10 | ports: 11 | - port: {{ .Values.service.port }} 12 | targetPort: 2022 13 | protocol: TCP 14 | name: http 15 | selector: 16 | app: {{ include "lab-dash.name" . }} -------------------------------------------------------------------------------- /kubernetes/lab-dash/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | 3 | namespaceOverride: "default" 4 | 5 | image: 6 | repository: ghcr.io/anthonygress/lab-dash 7 | tag: latest 8 | pullPolicy: IfNotPresent 9 | 10 | service: 11 | type: ClusterIP 12 | port: 2022 13 | 14 | secret: "changeme" 15 | 16 | volumes: 17 | config: 18 | enabled: true 19 | size: 1Gi 20 | uploads: 21 | enabled: true 22 | size: 1Gi 23 | 24 | resources: {} 25 | 26 | ingress: 27 | enabled: true 28 | className: "" 29 | annotations: {} 30 | hosts: 31 | - host: my.domain.net 32 | paths: 33 | - path: / 34 | pathType: Prefix 35 | tls: [] 36 | # To enable tls 37 | # tls: 38 | # - hosts: 39 | # - labdash.local 40 | # secretName: labdash-tls -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lab-dash", 3 | "version": "1.1.8", 4 | "description": "This is an open-source user interface designed to manage your server and homelab", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"", 8 | "preinstall": "cd ./backend && npm install && cd .. && cd ./frontend && npm install", 9 | "docker:build:dev": "docker build . --no-cache -t ghcr.io/anthonygress/lab-dash:latest", 10 | "docker:build": "docker build --platform linux/amd64 . -t ghcr.io/anthonygress/lab-dash:latest", 11 | "docker:build:multi": "docker buildx build --platform linux/amd64,linux/arm64 . -t ghcr.io/anthonygress/lab-dash:latest", 12 | "docker:run": "docker run --platform=linux/amd64 -p 2022:80 -p 5000:5000 ghcr.io/anthonygress/lab-dash:latest", 13 | "docker:run:dev": "docker run -p 2022:2022 ghcr.io/anthonygress/lab-dash:latest", 14 | "clean": "rimraf node_modules package-lock.json frontend/node_modules frontend/package-lock.json backend/node_modules backend/package-lock.json", 15 | "lint": "concurrently \"cd frontend && npm run lint\" \"cd backend && npm run lint\"" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "devDependencies": { 21 | "concurrently": "^9.1.2", 22 | "rimraf": "^5.0.5" 23 | } 24 | } 25 | --------------------------------------------------------------------------------