├── .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 |
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 |
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 |
--------------------------------------------------------------------------------