├── .github └── workflows │ ├── openapi.yml │ ├── publication.yml │ └── test-server.yml ├── .gitignore ├── LICENSE ├── README.md ├── dashboard ├── .env.development ├── .env.template ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── tenta-artwork-recolored.png │ ├── tenta-banner-dashboard-og-1024-512.png │ ├── tenta-banner-dashboard-og-1200-630.png │ ├── tenta-favicon-1024.png │ └── tenta-favicon-512.png ├── src │ ├── .gitignore │ ├── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── networks │ │ │ └── [networkIdentifier] │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── sensors │ │ │ │ └── [sensorIdentifier] │ │ │ │ ├── .gitignore │ │ │ │ ├── activity │ │ │ │ └── page.tsx │ │ │ │ ├── configurations │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── logs │ │ │ │ └── page.tsx │ │ │ │ ├── measurements │ │ │ │ └── page.tsx │ │ │ │ └── plots │ │ │ │ └── page.tsx │ │ ├── offline │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── signup │ │ │ └── page.tsx │ │ ├── style │ │ │ └── page.tsx │ │ └── swr-provider.tsx │ ├── components │ │ ├── custom │ │ │ ├── auth-loading-screen.tsx │ │ │ ├── config-revision-tag.tsx │ │ │ ├── creation-dialog.tsx │ │ │ ├── navigation-bar.tsx │ │ │ ├── pagination.tsx │ │ │ ├── spinner.tsx │ │ │ ├── the-tenta.tsx │ │ │ └── timestamp-label.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── select.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ ├── lib │ │ └── utils.ts │ └── requests │ │ ├── configurations.ts │ │ ├── logs.ts │ │ ├── measurements-aggregation.ts │ │ ├── measurements.ts │ │ ├── networks.ts │ │ ├── sensors.ts │ │ ├── status.ts │ │ └── user.ts ├── tailwind.config.js └── tsconfig.json ├── docker-compose.yml ├── docs ├── README.md ├── netlify.toml ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _app.mdx │ ├── _meta.json │ ├── community.mdx │ ├── connect.mdx │ ├── contribute.mdx │ ├── deployment.mdx │ ├── design.mdx │ ├── export.mdx │ ├── index.mdx │ ├── introduction.mdx │ ├── mqtt.mdx │ ├── next.mdx │ ├── overview.mdx │ └── roadmap.mdx ├── postcss.config.js ├── public │ ├── architecture.png │ └── banner.png ├── style.css ├── tailwind.config.js ├── theme.config.jsx └── tsconfig.json ├── publication ├── images │ ├── architecture.png │ ├── configurations.png │ └── screenshot.png ├── paper.bib └── paper.md └── server ├── .env.example ├── .gitignore ├── .python-version ├── Dockerfile ├── app ├── __init__.py ├── auth.py ├── database.py ├── errors.py ├── logs.py ├── main.py ├── mqtt.py ├── queries.sql ├── settings.py ├── utils.py └── validation │ ├── __init__.py │ ├── constants.py │ ├── mqtt.py │ ├── routes.py │ └── types.py ├── migrations └── .gitkeep ├── openapi.yml ├── poetry.lock ├── pyproject.toml ├── schema.sql ├── scripts ├── README.md ├── build ├── check ├── develop ├── initialize ├── initialize.py ├── jupyter ├── setup └── test └── tests ├── README.md ├── __init__.py ├── conftest.py ├── data.json ├── mosquitto.conf ├── test_mqtt.py ├── test_routes.py └── test_validation.py /.github/workflows/openapi.yml: -------------------------------------------------------------------------------- 1 | name: openapi 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - server/openapi.yml 7 | - .github/workflows/openapi.yml 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | - name: Deploy OpenAPI documentation 15 | uses: bump-sh/github-action@v1 16 | with: 17 | file: server/openapi.yml 18 | doc: 24616c25-ad93-410b-8a2f-d3a9b96c04c6 19 | token: ${{ secrets.BUMP_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/publication.yml: -------------------------------------------------------------------------------- 1 | name: Publication 2 | on: 3 | push: 4 | paths: 5 | - publication/** 6 | - .github/workflows/publication.yml 7 | 8 | jobs: 9 | paper: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | - name: Build PDF 15 | uses: openjournals/openjournals-draft-action@master 16 | with: 17 | journal: joss 18 | paper-path: publication/paper.md 19 | - name: Upload artifact 20 | uses: actions/upload-artifact@v4 21 | with: 22 | name: paper 23 | path: publication/paper.pdf 24 | -------------------------------------------------------------------------------- /.github/workflows/test-server.yml: -------------------------------------------------------------------------------- 1 | name: test-server 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - server/** 7 | - .github/workflows/test-server.yml 8 | pull_request: 9 | paths: 10 | - server/** 11 | - .github/workflows/test-server.yml 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | working-directory: server 18 | shell: bash 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 # Uses the Python version in .python-version 24 | with: 25 | python-version-file: server/.python-version 26 | - name: Install poetry 27 | uses: snok/install-poetry@v1 28 | with: 29 | virtualenvs-create: true 30 | virtualenvs-in-project: true 31 | installer-parallel: true 32 | - name: Load virtual environment cache 33 | id: cache 34 | uses: actions/cache@v2 35 | with: 36 | path: server/.venv 37 | key: ${{ runner.os }}-${{ hashFiles('server/poetry.lock') }}-2 # Increment to invalidate cache 38 | - name: Install dependencies 39 | if: steps.cache.outputs.cache-hit != 'true' 40 | run: scripts/setup 41 | - name: Run tests 42 | run: scripts/test 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 iterize 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Tenta 4 | 5 | ![Tenta's dashboard](docs/public/banner.png) 6 | 7 | Tenta allows you to manage sensors remotely and in real-time: 8 | 9 | - Collect and store measurements and logs from sensors 10 | - Configure sensors remotely 11 | - Monitor sensors in real-time with an intuitive dashboard 12 | 13 | Tenta is lightweight and composable. It is designed to be used as a building block in your IoT stack, together with other awesome tools like [Grafana](https://grafana.com/), [DuckDB](https://duckdb.org/), or [polars](https://www.pola.rs/). Sensors connect to Tenta over a language-independent MQTT interface. 14 | 15 | _Read the documentation at [tenta.onrender.com](https://tenta.onrender.com/)_ 16 | 17 | ## Try it out! 18 | 19 | You can try out Tenta in a few minutes with Docker Compose. Clone the repository and run: 20 | 21 | ```sh 22 | NEXT_PUBLIC_BUILD_TIMESTAMP=$(date +%s) COMMIT_SHA=$(git rev-parse --verify HEAD) BRANCH_NAME=$(git branch --show-current) docker compose up --build 23 | ``` 24 | 25 | The dashboard will be available at [http://localhost:3000](http://localhost:3000). You can log in with the default username `happy-un1c0rn` and password `12345678`. 26 | 27 | You can exit the application with `Ctrl+C` and remove the containers with: 28 | 29 | ```sh 30 | docker compose down -v 31 | ``` 32 | 33 | ## More 34 | 35 | **Publication:** [![status](https://joss.theoj.org/papers/5daf8d2d13c01da24e949c20a08d29d0/status.svg)](https://joss.theoj.org/papers/5daf8d2d13c01da24e949c20a08d29d0) 36 | 37 | **License:** Tenta is licensed under the [MIT License](https://github.com/iterize/tenta/blob/main/LICENSE). 38 | 39 | **Research:** We are open for collaborations! If you want to use Tenta in your research, don't hesitate to reach out to contact@iterize.dev. We are happy to help you get started and provide support. 40 | 41 | **Contributing:** We are happy about contributions to Tenta! You can start by reading [our contribution guide](https://tenta.onrender.com/contribute). 42 | 43 | **Versioning:** Tenta's MQTT, HTTP, and database interfaces adhere to Semantic Versioning. Changes will be tracked in release notes. Please expect breaking changes until we reach version 1.0.0. 44 | -------------------------------------------------------------------------------- /dashboard/.env.development: -------------------------------------------------------------------------------- 1 | # required | URL of the tenta server (no trailing slash!) 2 | NEXT_PUBLIC_SERVER_URL="http://localhost:8421" 3 | 4 | # optional | rendered on the login page 5 | NEXT_PUBLIC_CONTACT_EMAIL="contact.email@login.page" 6 | 7 | # optional | rendered in the header 8 | NEXT_PUBLIC_INSTANCE_TITLE="Your Department Name" 9 | 10 | -------------------------------------------------------------------------------- /dashboard/.env.template: -------------------------------------------------------------------------------- 1 | # put this into the ".env.local" file 2 | 3 | # required | URL of the tenta server (no trailing slash!) 4 | NEXT_PUBLIC_SERVER_URL="http://your-server-domain.com" 5 | 6 | # optional | rendered on the login page 7 | NEXT_PUBLIC_CONTACT_EMAIL="contact.email@login.page" 8 | 9 | # optional | rendered in the header 10 | NEXT_PUBLIC_INSTANCE_TITLE="Your Department Name" 11 | 12 | -------------------------------------------------------------------------------- /dashboard/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | # custom 2 | hidden/ 3 | 4 | # Created by https://www.toptal.com/developers/gitignore/api/node,nextjs 5 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,nextjs 6 | 7 | ### NextJS ### 8 | # dependencies 9 | /node_modules 10 | /.pnp 11 | .pnp.js 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | ### Node ### 44 | # Logs 45 | logs 46 | *.log 47 | lerna-debug.log* 48 | 49 | # Diagnostic reports (https://nodejs.org/api/report.html) 50 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 51 | 52 | # Runtime data 53 | pids 54 | *.pid 55 | *.seed 56 | *.pid.lock 57 | 58 | # Directory for instrumented libs generated by jscoverage/JSCover 59 | lib-cov 60 | 61 | # Coverage directory used by tools like istanbul 62 | coverage 63 | *.lcov 64 | 65 | # nyc test coverage 66 | .nyc_output 67 | 68 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 69 | .grunt 70 | 71 | # Bower dependency directory (https://bower.io/) 72 | bower_components 73 | 74 | # node-waf configuration 75 | .lock-wscript 76 | 77 | # Compiled binary addons (https://nodejs.org/api/addons.html) 78 | build/Release 79 | 80 | # Dependency directories 81 | node_modules/ 82 | jspm_packages/ 83 | 84 | # Snowpack dependency directory (https://snowpack.dev/) 85 | web_modules/ 86 | 87 | # TypeScript cache 88 | 89 | # Optional npm cache directory 90 | .npm 91 | 92 | # Optional eslint cache 93 | .eslintcache 94 | 95 | # Optional stylelint cache 96 | .stylelintcache 97 | 98 | # Microbundle cache 99 | .rpt2_cache/ 100 | .rts2_cache_cjs/ 101 | .rts2_cache_es/ 102 | .rts2_cache_umd/ 103 | 104 | # Optional REPL history 105 | .node_repl_history 106 | 107 | # Output of 'npm pack' 108 | *.tgz 109 | 110 | # Yarn Integrity file 111 | .yarn-integrity 112 | 113 | # dotenv environment variable files 114 | .env 115 | .env.development.local 116 | .env.test.local 117 | .env.production.local 118 | .env.local 119 | 120 | # parcel-bundler cache (https://parceljs.org/) 121 | .cache 122 | .parcel-cache 123 | 124 | # Next.js build output 125 | .next 126 | out 127 | 128 | # Nuxt.js build / generate output 129 | .nuxt 130 | dist 131 | 132 | # Gatsby files 133 | .cache/ 134 | # Comment in the public line in if your project uses Gatsby and not Next.js 135 | # https://nextjs.org/blog/next-9-1#public-directory-support 136 | # public 137 | 138 | # vuepress build output 139 | .vuepress/dist 140 | 141 | # vuepress v2.x temp and cache directory 142 | .temp 143 | 144 | # Docusaurus cache and generated files 145 | .docusaurus 146 | 147 | # Serverless directories 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | .fusebox/ 152 | 153 | # DynamoDB Local files 154 | .dynamodb/ 155 | 156 | # TernJS port file 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | .vscode-test 161 | 162 | # yarn v2 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.* 168 | 169 | ### Node Patch ### 170 | # Serverless Webpack directories 171 | .webpack/ 172 | 173 | # Optional stylelint cache 174 | 175 | # SvelteKit build / generate output 176 | .svelte-kit 177 | 178 | # End of https://www.toptal.com/developers/gitignore/api/node,nextjs -------------------------------------------------------------------------------- /dashboard/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=20.6.1 5 | FROM node:${NODE_VERSION}-slim AS base 6 | 7 | LABEL fly_launch_runtime="Next.js" 8 | 9 | # Next.js app lives here 10 | WORKDIR /app 11 | 12 | # Throw-away build stage to reduce size of final image 13 | FROM base AS build 14 | 15 | # Install packages needed to build node modules 16 | RUN apt-get update -qq && \ 17 | apt-get install -y build-essential pkg-config python-is-python3 18 | 19 | # Install node modules 20 | COPY --link package-lock.json package.json ./ 21 | RUN npm ci --include=dev 22 | 23 | # Copy application code 24 | COPY --link . . 25 | 26 | # Set production environment 27 | ENV NODE_ENV="production" 28 | ARG NEXT_PUBLIC_SERVER_URL="https://url-to-your-server.com" 29 | ARG NEXT_PUBLIC_INSTANCE_TITLE="Professorship of Environmental Sensing and Modeling" 30 | 31 | # Build application 32 | RUN npm run build 33 | 34 | # Remove development dependencies 35 | RUN npm prune --omit=dev 36 | 37 | # Final stage for app image 38 | FROM base 39 | 40 | # Copy built application 41 | COPY --from=build /app /app 42 | 43 | # Start the server by default, this can be overwritten at runtime 44 | EXPOSE 3000 45 | CMD [ "npm", "run", "start" ] 46 | -------------------------------------------------------------------------------- /dashboard/README.md: -------------------------------------------------------------------------------- 1 | # Tenta Dashboard 2 | 3 | Built using NextJS 13, Typscript, TailwindCSS and ShadcnUI. 4 | 5 | Run the development server with: 6 | 7 | ```bash 8 | npm run dev 9 | ``` 10 | -------------------------------------------------------------------------------- /dashboard/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /dashboard/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { unoptimized: true } 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tenta-dashboard", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "export NEXT_PUBLIC_BUILD_TIMESTAMP=$(date +%s) && export NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse HEAD) && export NEXT_PUBLIC_BRANCH_NAME=$(git branch --show-current) && next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dialog": "^1.0.4", 13 | "@radix-ui/react-icons": "^1.3.0", 14 | "@radix-ui/react-label": "^2.0.2", 15 | "@radix-ui/react-select": "^1.2.2", 16 | "@radix-ui/react-slot": "^1.0.2", 17 | "@radix-ui/react-tabs": "^1.0.4", 18 | "@radix-ui/react-tooltip": "^1.0.6", 19 | "@tabler/icons-react": "^2.32.0", 20 | "@types/d3": "^7.4.0", 21 | "@types/date-fns": "^2.6.0", 22 | "@types/js-cookie": "^3.0.3", 23 | "@types/lodash": "^4.14.197", 24 | "@types/node": "20.5.7", 25 | "@types/react": "18.2.21", 26 | "@types/react-dom": "18.2.7", 27 | "autoprefixer": "10.4.15", 28 | "axios": "^1.5.0", 29 | "class-variance-authority": "^0.7.0", 30 | "clsx": "^2.0.0", 31 | "d3": "^7.8.5", 32 | "date-fns": "^2.30.0", 33 | "eslint": "8.48.0", 34 | "eslint-config-next": "13.4.19", 35 | "js-cookie": "^3.0.5", 36 | "lodash": "^4.17.21", 37 | "next": "^14.0.2", 38 | "postcss": "8.4.28", 39 | "prettier": "^3.0.3", 40 | "react": "18.2.0", 41 | "react-dom": "18.2.0", 42 | "react-hot-toast": "^2.4.1", 43 | "swr": "^2.2.2", 44 | "tailwind-merge": "^1.14.0", 45 | "tailwindcss": "3.3.3", 46 | "tailwindcss-animate": "^1.0.7", 47 | "typescript": "5.2.2", 48 | "zod": "^3.22.2" 49 | }, 50 | "devDependencies": { 51 | "@flydotio/dockerfile": "^0.4.10" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /dashboard/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /dashboard/public/tenta-artwork-recolored.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iterize/tenta/48274dd016049a9cb4202c7cb7aebf861a8d50ce/dashboard/public/tenta-artwork-recolored.png -------------------------------------------------------------------------------- /dashboard/public/tenta-banner-dashboard-og-1024-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iterize/tenta/48274dd016049a9cb4202c7cb7aebf861a8d50ce/dashboard/public/tenta-banner-dashboard-og-1024-512.png -------------------------------------------------------------------------------- /dashboard/public/tenta-banner-dashboard-og-1200-630.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iterize/tenta/48274dd016049a9cb4202c7cb7aebf861a8d50ce/dashboard/public/tenta-banner-dashboard-og-1200-630.png -------------------------------------------------------------------------------- /dashboard/public/tenta-favicon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iterize/tenta/48274dd016049a9cb4202c7cb7aebf861a8d50ce/dashboard/public/tenta-favicon-1024.png -------------------------------------------------------------------------------- /dashboard/public/tenta-favicon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iterize/tenta/48274dd016049a9cb4202c7cb7aebf861a8d50ce/dashboard/public/tenta-favicon-512.png -------------------------------------------------------------------------------- /dashboard/src/.gitignore: -------------------------------------------------------------------------------- 1 | !lib -------------------------------------------------------------------------------- /dashboard/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iterize/tenta/48274dd016049a9cb4202c7cb7aebf861a8d50ce/dashboard/src/app/favicon.ico -------------------------------------------------------------------------------- /dashboard/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .background-paper-pattern { 79 | z-index: -1; 80 | background-size: 8rem 1.6rem; 81 | background-position: -1px -1px; 82 | background-color: #ffffff; 83 | 84 | background-image: url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='%23e2e8f0' fill-opacity='0.4' fill-rule='evenodd'/%3E%3C/svg%3E"); 85 | } 86 | -------------------------------------------------------------------------------- /dashboard/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | import { Rubik } from "next/font/google"; 4 | import { SWRProvider } from "@/app/swr-provider"; 5 | import { NavigationBar } from "@/components/custom/navigation-bar"; 6 | import { Toaster } from "react-hot-toast"; 7 | 8 | const rubik = Rubik({ subsets: ["latin"], display: "swap" }); 9 | 10 | export const metadata: Metadata = { 11 | metadataBase: new URL("https://someridiculousdomaintogetridofthaterror.com"), 12 | title: "Tenta Dashboard", 13 | description: "Remote and real-time management of distributed sensor networks", 14 | openGraph: { 15 | type: "website", 16 | locale: "en_IE", 17 | url: "https://github.com/iterize/tenta", 18 | title: "Tenta Dashboard", 19 | description: 20 | "Remote and real-time management of distributed sensor networks", 21 | }, 22 | }; 23 | 24 | export default function RootLayout({ 25 | children, 26 | }: { 27 | children: React.ReactNode; 28 | }) { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | {children} 59 | 60 |
61 |
62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /dashboard/src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { use, useState } from "react"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useUser } from "@/requests/user"; 7 | import { AuthLoadingScreen } from "@/components/custom/auth-loading-screen"; 8 | import { redirect } from "next/navigation"; 9 | import Link from "next/link"; 10 | import toast from "react-hot-toast"; 11 | import { TheTenta } from "@/components/custom/the-tenta"; 12 | import { useStatus } from "@/requests/status"; 13 | 14 | export default function Page() { 15 | const [username, setUsername] = useState(""); 16 | const [password, setPassword] = useState(""); 17 | const [isSubmitting, setIsSubmitting] = useState(false); 18 | 19 | const { userData, userDataIsloading, loginUser } = useUser(); 20 | 21 | const serverStatus = useStatus(); 22 | 23 | async function submit() { 24 | setIsSubmitting(true); 25 | try { 26 | await toast.promise(loginUser(username, password), { 27 | loading: "Authenticating", 28 | success: "Successfully authenticated", 29 | error: "Failed to authenticate", 30 | }); 31 | setUsername(""); 32 | setPassword(""); 33 | } catch (error) { 34 | console.error(error); 35 | } finally { 36 | setIsSubmitting(false); 37 | } 38 | } 39 | 40 | if (userDataIsloading || serverStatus === undefined) { 41 | return ; 42 | } else if (userData !== undefined) { 43 | redirect("/"); 44 | } 45 | 46 | const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL; 47 | 48 | return ( 49 | <> 50 |
51 |
52 |
53 | 54 |
55 |
56 |

Login

57 | setUsername(e.target.value)} 63 | /> 64 | setPassword(e.target.value)} 70 | /> 71 |
72 | 76 | Sign up instead 77 | 78 |
79 | 86 |
87 | {contactEmail !== undefined && ( 88 |
89 | If you have questions about this Tenta instance, please contact{" "} 90 | 91 | {contactEmail} 92 | 93 |
94 | )} 95 |
96 |
97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /dashboard/src/app/networks/[networkIdentifier]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TheTenta } from "@/components/custom/the-tenta"; 4 | 5 | export default function Page(props: { params: { networkIdentifier: string } }) { 6 | return ( 7 |
8 |
9 | please select a sensor in the 10 | list 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /dashboard/src/app/networks/[networkIdentifier]/sensors/[sensorIdentifier]/.gitignore: -------------------------------------------------------------------------------- 1 | !logs/ -------------------------------------------------------------------------------- /dashboard/src/app/networks/[networkIdentifier]/sensors/[sensorIdentifier]/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function Page(props: { 4 | children: React.ReactNode; 5 | params: { networkIdentifier: string; sensorIdentifier: string }; 6 | }) { 7 | return ( 8 |
9 | {props.children} 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /dashboard/src/app/networks/[networkIdentifier]/sensors/[sensorIdentifier]/logs/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLoadingScreen } from "@/components/custom/auth-loading-screen"; 4 | import { useSensors } from "@/requests/sensors"; 5 | import { useUser } from "@/requests/user"; 6 | import { redirect } from "next/navigation"; 7 | import { useEffect, useState } from "react"; 8 | import { Pagination } from "@/components/custom/pagination"; 9 | import { Button } from "@/components/ui/button"; 10 | import toast from "react-hot-toast"; 11 | import { formatDistanceToNow } from "date-fns"; 12 | import { useLogs } from "@/requests/logs"; 13 | import { IconDatabaseExclamation } from "@tabler/icons-react"; 14 | import { ConfigRevisionTag } from "@/components/custom/config-revision-tag"; 15 | import { Spinner } from "@/components/custom/spinner"; 16 | 17 | export default function Page(props: { 18 | params: { networkIdentifier: string; sensorIdentifier: string }; 19 | }) { 20 | const { userData, userDataIsloading, logoutUser } = useUser(); 21 | 22 | const [currentPageNumber, setCurrentPageNumber] = useState(1); 23 | 24 | const { sensorsData } = useSensors( 25 | userData?.accessToken, 26 | logoutUser, 27 | props.params.networkIdentifier 28 | ); 29 | const { 30 | logsData, 31 | logsDataFetchingState, 32 | numberOfLogsPages, 33 | fetchNewerLogs, 34 | fetchOlderLogs, 35 | } = useLogs( 36 | userData?.accessToken, 37 | logoutUser, 38 | props.params.networkIdentifier, 39 | props.params.sensorIdentifier 40 | ); 41 | const [dataLoadingToastId, setDataLoadingToastId] = useState< 42 | string | undefined 43 | >(); 44 | 45 | useEffect(() => { 46 | const interval = setInterval(() => { 47 | console.log( 48 | `fetching newer logs for sensor ${props.params.sensorIdentifier}` 49 | ); 50 | fetchNewerLogs(); 51 | }, 5000); 52 | 53 | return () => clearInterval(interval); 54 | }); 55 | 56 | useEffect(() => { 57 | if ( 58 | logsDataFetchingState === "user-fetching" && 59 | dataLoadingToastId === undefined 60 | ) { 61 | setDataLoadingToastId(toast.loading("loading data")); 62 | } 63 | if ( 64 | (logsDataFetchingState === "new data" || 65 | logsDataFetchingState === "no new data") && 66 | dataLoadingToastId !== undefined 67 | ) { 68 | setDataLoadingToastId(undefined); 69 | toast.success(logsDataFetchingState, { 70 | id: dataLoadingToastId, 71 | duration: 1500, 72 | }); 73 | } 74 | }, [logsDataFetchingState, dataLoadingToastId]); 75 | 76 | // when new data is fetched, go to the last page 77 | useEffect(() => { 78 | setCurrentPageNumber(numberOfLogsPages); 79 | }, [numberOfLogsPages]); 80 | 81 | // when page is left, dismiss all toasts 82 | useEffect(() => { 83 | return () => toast.dismiss(); 84 | }, []); 85 | 86 | if (userDataIsloading || sensorsData === undefined) { 87 | return ; 88 | } else if (userData === undefined) { 89 | redirect("/login"); 90 | } 91 | 92 | const sensor = sensorsData?.find( 93 | (sensor) => sensor.identifier === props.params.sensorIdentifier 94 | ); 95 | 96 | if (sensor === undefined) { 97 | return "unknown sensor id"; 98 | } 99 | 100 | return ( 101 | <> 102 |
103 |
104 |
105 | 106 |
107 |
Raw Logs
108 |
109 |
110 |
111 | 122 | 123 |
124 | {logsDataFetchingState === "background-fetching" && } 125 |
126 |
127 |
128 | {logsDataFetchingState !== "background-fetching" && 129 | logsData.length === 0 && ( 130 |
131 | no logs 132 |
133 | )} 134 | {logsData.map((log) => ( 135 |
139 |
143 | 144 |
145 |
146 | {formatDistanceToNow(new Date(log.creationTimestamp * 1000), { 147 | addSuffix: true, 148 | })} 149 |
150 |
151 |
152 | {new Date(log.creationTimestamp * 1000).toISOString()} 153 |
154 |
155 |
156 |
157 | 165 | {log.severity} 166 | {" "} 167 | {log.message} 168 |
169 |
170 | ))} 171 |
172 | 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /dashboard/src/app/networks/[networkIdentifier]/sensors/[sensorIdentifier]/measurements/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLoadingScreen } from "@/components/custom/auth-loading-screen"; 4 | import { useSensors } from "@/requests/sensors"; 5 | import { useUser } from "@/requests/user"; 6 | import { redirect } from "next/navigation"; 7 | import { useEffect, useState } from "react"; 8 | import { Pagination } from "@/components/custom/pagination"; 9 | import { Button } from "@/components/ui/button"; 10 | import { useMeasurements } from "@/requests/measurements"; 11 | import toast from "react-hot-toast"; 12 | import { formatDistanceToNow } from "date-fns"; 13 | import { IconDatabaseSearch } from "@tabler/icons-react"; 14 | import { ConfigRevisionTag } from "@/components/custom/config-revision-tag"; 15 | import { Spinner } from "@/components/custom/spinner"; 16 | 17 | export default function Page(props: { 18 | params: { networkIdentifier: string; sensorIdentifier: string }; 19 | }) { 20 | const { userData, userDataIsloading, logoutUser } = useUser(); 21 | 22 | const [currentPageNumber, setCurrentPageNumber] = useState(1); 23 | 24 | const { sensorsData } = useSensors( 25 | userData?.accessToken, 26 | logoutUser, 27 | props.params.networkIdentifier 28 | ); 29 | const { 30 | measurementsData, 31 | measurementsDataFetchingState, 32 | numberOfMeasurementsPages, 33 | fetchNewerMeasurements, 34 | fetchOlderMeasurements, 35 | } = useMeasurements( 36 | userData?.accessToken, 37 | logoutUser, 38 | props.params.networkIdentifier, 39 | props.params.sensorIdentifier 40 | ); 41 | const [dataLoadingToastId, setDataLoadingToastId] = useState< 42 | string | undefined 43 | >(); 44 | 45 | useEffect(() => { 46 | const interval = setInterval(() => { 47 | console.log( 48 | `fetching newer measurements for sensor ${props.params.sensorIdentifier}` 49 | ); 50 | fetchNewerMeasurements(); 51 | }, 5000); 52 | 53 | return () => clearInterval(interval); 54 | }); 55 | 56 | useEffect(() => { 57 | if ( 58 | measurementsDataFetchingState === "user-fetching" && 59 | dataLoadingToastId === undefined 60 | ) { 61 | setDataLoadingToastId(toast.loading("loading data")); 62 | } 63 | if ( 64 | (measurementsDataFetchingState === "new data" || 65 | measurementsDataFetchingState === "no new data") && 66 | dataLoadingToastId !== undefined 67 | ) { 68 | setDataLoadingToastId(undefined); 69 | toast.success(measurementsDataFetchingState, { 70 | id: dataLoadingToastId, 71 | duration: 1500, 72 | }); 73 | } 74 | }, [measurementsDataFetchingState, dataLoadingToastId, measurementsData]); 75 | 76 | // when new data is fetched, go to the last page 77 | useEffect(() => { 78 | setCurrentPageNumber(numberOfMeasurementsPages); 79 | }, [numberOfMeasurementsPages]); 80 | 81 | // when page is left, dismiss all toasts 82 | useEffect(() => { 83 | return () => toast.dismiss(); 84 | }, []); 85 | 86 | if (userDataIsloading || sensorsData === undefined) { 87 | return ; 88 | } else if (userData === undefined) { 89 | redirect("/login"); 90 | } 91 | 92 | const sensor = sensorsData?.find( 93 | (sensor) => sensor.identifier === props.params.sensorIdentifier 94 | ); 95 | 96 | if (sensor === undefined) { 97 | return "unknown sensor id"; 98 | } 99 | 100 | return ( 101 | <> 102 |
103 |
104 |
105 | 106 |
107 |
108 | Raw Measurements 109 |
110 |
111 |
112 |
113 | 124 | 125 |
126 | {measurementsDataFetchingState === "background-fetching" && ( 127 | 128 | )} 129 |
130 |
131 |
132 | {measurementsDataFetchingState !== "background-fetching" && 133 | measurementsData.length === 0 && ( 134 |
135 | no measurements 136 |
137 | )} 138 | {measurementsData 139 | .slice((currentPageNumber - 1) * 64, currentPageNumber * 64) 140 | .map((measurement) => ( 141 |
145 |
149 | 150 |
151 |
152 | {formatDistanceToNow( 153 | new Date(measurement.creationTimestamp * 1000), 154 | { 155 | addSuffix: true, 156 | } 157 | )} 158 |
159 |
160 |
161 | {new Date( 162 | measurement.creationTimestamp * 1000 163 | ).toISOString()} 164 |
165 |
166 |
167 |
168 | {Object.entries(measurement.value).map(([key, value]) => ( 169 |
173 |
174 | {key}: 175 |
176 |
{value}
177 |
178 | ))} 179 |
180 |
181 | ))} 182 |
183 | 184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /dashboard/src/app/networks/[networkIdentifier]/sensors/[sensorIdentifier]/plots/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLoadingScreen } from "@/components/custom/auth-loading-screen"; 4 | import { useMeasurementsAggregation } from "@/requests/measurements-aggregation"; 5 | import { useUser } from "@/requests/user"; 6 | import { redirect } from "next/navigation"; 7 | import { useEffect, useRef } from "react"; 8 | import * as d3 from "d3"; 9 | import { maxBy, minBy, range } from "lodash"; 10 | import { IconChartHistogram } from "@tabler/icons-react"; 11 | 12 | export default function Page(props: { 13 | params: { networkIdentifier: string; sensorIdentifier: string }; 14 | }) { 15 | const { userData, userDataIsloading, logoutUser } = useUser(); 16 | const { measurementsAggregationData } = useMeasurementsAggregation( 17 | userData?.accessToken, 18 | logoutUser, 19 | props.params.networkIdentifier, 20 | props.params.sensorIdentifier 21 | ); 22 | 23 | if (userDataIsloading || measurementsAggregationData === undefined) { 24 | return ; 25 | } else if (userData === undefined) { 26 | redirect("/login"); 27 | } 28 | 29 | console.log(measurementsAggregationData); 30 | 31 | return ( 32 | <> 33 |
34 |
35 |
36 | 37 |
38 |
39 | Plots 40 | last 4 weeks 41 |
42 |
43 |
44 | Plot times in UTC 45 |
46 | {Object.keys(measurementsAggregationData) 47 | .sort() 48 | .map((key) => ( 49 | 54 | ))} 55 | {Object.keys(measurementsAggregationData).length === 0 && ( 56 |
no measurements
57 | )} 58 | 59 | ); 60 | } 61 | 62 | function MeasurementAggregationPlot(props: { 63 | label: string; 64 | data: { average: number; bucketTimestamp: number }[]; 65 | }) { 66 | const plotRef = useRef(null); 67 | 68 | useEffect(() => { 69 | const svg = d3.select(plotRef.current); 70 | 71 | const now = new Date(); 72 | const nextUTCMidnightTimestamp = 73 | Math.floor(now.getTime() / 1000) - 74 | now.getUTCSeconds() - 75 | now.getUTCMinutes() * 60 - 76 | now.getUTCHours() * 3600 + 77 | 24 * 3600; 78 | 79 | const maxX = nextUTCMidnightTimestamp + 7200; 80 | const minX = nextUTCMidnightTimestamp - 29 * 24 * 3600 - 7200; 81 | 82 | let minY = minBy(props.data, (d) => d.average)?.average; 83 | let maxY = maxBy(props.data, (d) => d.average)?.average; 84 | 85 | if (minY === undefined || maxY === undefined) { 86 | return; 87 | } 88 | 89 | const dy = maxY - minY; 90 | minY -= dy * 0.1; 91 | maxY += dy * 0.1; 92 | 93 | const xScale = d3.scaleLinear([minX, maxX], [65, 1050]); 94 | const yScale = d3.scaleLinear([minY, maxY], [130, 10]); 95 | 96 | svg.selectAll("*").remove(); 97 | 98 | const utcMidnightTimestamps = range(minX + 7200, maxX, 24 * 3600); 99 | 100 | svg 101 | .append("g") 102 | .attr("class", "major-x-tick-lines text-slate-300 z-0") 103 | .selectAll("line") 104 | .data(utcMidnightTimestamps) 105 | .enter() 106 | .append("line") 107 | .attr("x1", (d) => xScale(d)) 108 | .attr("x2", (d) => xScale(d)) 109 | .attr("y1", yScale(minY)) 110 | .attr("y2", yScale(maxY)) 111 | .attr("stroke", "currentColor"); 112 | 113 | svg 114 | .append("g") 115 | .attr("class", "minor-x-tick-lines text-slate-150 z-0") 116 | .selectAll("line") 117 | .data( 118 | range(minX + 3600 * 2, maxX, 6 * 3600).filter( 119 | (d) => !utcMidnightTimestamps.includes(d) 120 | ) 121 | ) 122 | .enter() 123 | .append("line") 124 | .attr("x1", (d) => xScale(d)) 125 | .attr("x2", (d) => xScale(d)) 126 | .attr("y1", yScale(minY)) 127 | .attr("y2", yScale(maxY)) 128 | .attr("stroke", "currentColor"); 129 | 130 | svg 131 | .append("g") 132 | .attr( 133 | "class", 134 | "major-x-tick-labels text-slate-600 z-10 text-xs font-medium" 135 | ) 136 | .selectAll("text") 137 | .data( 138 | range(minX + 3600 + 12 * 3600, maxX - 3599 - 12 * 3600, 3 * 24 * 3600) 139 | ) 140 | .enter() 141 | .append("text") 142 | .text((d) => 143 | new Date(d * 1000).toLocaleDateString("en-US", { 144 | month: "short", 145 | day: "numeric", 146 | }) 147 | ) 148 | .attr("x", (d) => xScale(d)) 149 | .attr("y", 147) 150 | .attr("text-anchor", "middle") 151 | .attr("fill", "currentColor"); 152 | 153 | const yTicks = yScale.ticks(5); 154 | 155 | svg 156 | .append("g") 157 | .attr("class", "y-tick-lines text-slate-300 z-0") 158 | .selectAll("line") 159 | .data(yTicks) 160 | .enter() 161 | .append("line") 162 | .attr("x1", xScale(minX - 1 * 3600)) 163 | .attr("x2", xScale(maxX - 2 * 3600)) 164 | .attr("y1", (d) => yScale(d)) 165 | .attr("y2", (d) => yScale(d)) 166 | .attr("stroke", "currentColor"); 167 | 168 | svg 169 | .append("g") 170 | .attr( 171 | "class", 172 | "y-tick-labels text-slate-600 z-10 text-xs font-medium font-mono" 173 | ) 174 | .selectAll("text") 175 | .data(yTicks) 176 | .enter() 177 | .append("text") 178 | .text((d) => d.toPrecision(4)) 179 | .attr("x", 60) 180 | .attr("y", (d) => yScale(d) + 4) 181 | .attr("text-anchor", "end") 182 | .attr("fill", "currentColor"); 183 | 184 | svg 185 | .append("g") 186 | .attr("class", "data-point-circles text-slate-900 z-10") 187 | .selectAll("circle") 188 | .data(props.data) 189 | .enter() 190 | .append("circle") 191 | .attr("r", 1.25) 192 | .attr("cx", (d) => xScale(d.bucketTimestamp)) 193 | .attr("cy", (d) => yScale(d.average)) 194 | .attr("fill", "currentColor"); 195 | 196 | svg 197 | .append("line") 198 | .attr("class", "current-time-line z-10 stroke-rose-500") 199 | .attr("x1", xScale(now.getTime() / 1000)) 200 | .attr("x2", xScale(now.getTime() / 1000)) 201 | .attr("y1", yScale(minY) + 2) 202 | .attr("y2", yScale(maxY) - 2) 203 | .attr("stroke-width", 2.5) 204 | .attr("stroke-linecap", "round"); 205 | 206 | svg 207 | .append("text") 208 | .attr( 209 | "class", 210 | "current-time-label z-10 text-rose-500 text-[0.65rem] font-semibold" 211 | ) 212 | .text("now") 213 | .attr("x", xScale(now.getTime() / 1000) - 5) 214 | .attr("y", yScale(maxY) - 1) 215 | .attr("text-anchor", "end") 216 | .attr("fill", "currentColor"); 217 | }, [props.data, plotRef]); 218 | 219 | return ( 220 |
221 |
222 | {props.label} 223 |
224 |
225 | 226 |
227 |
228 | ); 229 | } 230 | -------------------------------------------------------------------------------- /dashboard/src/app/offline/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useStatus } from "@/requests/status"; 4 | import Link from "next/link"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export default function Page() { 8 | const serverStatus = useStatus(); 9 | 10 | if (serverStatus !== undefined) { 11 | redirect("/login"); 12 | } 13 | 14 | return ( 15 |
16 |
17 |
18 | Server is Offline 19 |
20 |
21 | Could not reach Tenta server at{" "} 22 | 23 | {process.env.NEXT_PUBLIC_SERVER_URL} 24 | 25 | . Read the Tenta documentation about deployment at{" "} 26 | 31 | tenta.onrender.com/deployment 32 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /dashboard/src/app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useUser } from "@/requests/user"; 7 | import { AuthLoadingScreen } from "@/components/custom/auth-loading-screen"; 8 | import { redirect } from "next/navigation"; 9 | import Link from "next/link"; 10 | import toast from "react-hot-toast"; 11 | import { TheTenta } from "@/components/custom/the-tenta"; 12 | 13 | export default function Page() { 14 | const [username, setUsername] = useState(""); 15 | const [password, setPassword] = useState(""); 16 | const [passwordConfirmation, setPasswordConfirmation] = useState(""); 17 | 18 | const [isSubmitting, setIsSubmitting] = useState(false); 19 | 20 | const { userData, userDataIsloading, signupUser } = useUser(); 21 | 22 | async function submit() { 23 | if (password !== passwordConfirmation) { 24 | toast.error("Passwords do not match"); 25 | return; 26 | } 27 | 28 | setIsSubmitting(true); 29 | try { 30 | await toast.promise(signupUser(username, password), { 31 | loading: "Creating new account", 32 | success: "Successfully created new account", 33 | error: "Username already exists", 34 | }); 35 | setUsername(""); 36 | setPassword(""); 37 | } catch (error) { 38 | console.error(error); 39 | } finally { 40 | setIsSubmitting(false); 41 | } 42 | } 43 | 44 | if (userDataIsloading) { 45 | return ; 46 | } else if (userData !== undefined) { 47 | redirect("/"); 48 | } 49 | 50 | const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL; 51 | 52 | return ( 53 | <> 54 |
55 |
56 |
57 | 58 |
59 |
60 |

Signup

61 | setUsername(e.target.value)} 67 | /> 68 | setPassword(e.target.value)} 75 | /> 76 | setPasswordConfirmation(e.target.value)} 83 | /> 84 |
85 | 89 | Log in instead 90 | 91 |
92 | 99 |
100 | {contactEmail !== undefined && ( 101 |
102 | If you have questions about this Tenta instance, please contact{" "} 103 | 104 | {contactEmail} 105 | 106 |
107 | )} 108 |
109 |
110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /dashboard/src/app/style/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLoadingScreen } from "@/components/custom/auth-loading-screen"; 4 | import { NavigationBar } from "@/components/custom/navigation-bar"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useUser } from "@/requests/user"; 7 | import { redirect } from "next/navigation"; 8 | 9 | export default function Page() { 10 | return ( 11 |
12 |
13 |
Color `blue`
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
Color `red`
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
Color `orange`
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
Color `yellow`
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
Color `eggshell`
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /dashboard/src/app/swr-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SWRConfig } from "swr"; 4 | 5 | export const SWRProvider = (props: { children: React.ReactNode }) => { 6 | return {props.children}; 7 | }; 8 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/auth-loading-screen.tsx: -------------------------------------------------------------------------------- 1 | export function AuthLoadingScreen() { 2 | return ( 3 |
4 |
5 | loading the application 6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/config-revision-tag.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger, 6 | } from "@/components/ui/tooltip"; 7 | import { IconFileSettings } from "@tabler/icons-react"; 8 | 9 | export function ConfigRevisionTag(props: { 10 | revision: number | null; 11 | to_revision?: number | null; 12 | }) { 13 | const noRevision = 14 | props.to_revision === undefined 15 | ? props.revision === null 16 | : props.revision === null && props.to_revision === null; 17 | 18 | return ( 19 | 20 | 21 | 22 |
30 | <> 31 | {" "} 39 | {(noRevision || props.to_revision === undefined) && 40 | (props.revision === null ? "-" : props.revision)} 41 | {!noRevision && props.to_revision !== undefined && ( 42 | <> 43 | from {props.to_revision === null ? "-" : props.to_revision} to{" "} 44 | {props.to_revision === null ? "-" : props.to_revision} 45 | 46 | )} 47 | 48 |
49 |
50 | 51 |

52 | {noRevision 53 | ? "No Config Revision" 54 | : `Config Revision ${props.revision}` + 55 | (props.to_revision !== undefined 56 | ? ` to ${props.to_revision}` 57 | : "")} 58 |

59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/creation-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogFooter, 5 | DialogHeader, 6 | DialogTitle, 7 | DialogTrigger, 8 | } from "@/components/ui/dialog"; 9 | import { useEffect, useState } from "react"; 10 | import toast from "react-hot-toast"; 11 | import { Label } from "@/components/ui/label"; 12 | import { Input } from "@/components/ui/input"; 13 | import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react"; 14 | import { Button } from "@/components/ui/button"; 15 | 16 | export function CreationDialog(props: { 17 | action: "create" | "update"; 18 | label: "sensor" | "network"; 19 | submit: (name: string) => Promise; 20 | onSuccess?: (newIdentifier: string) => void; 21 | children: React.ReactNode; 22 | previousValue?: string; 23 | }) { 24 | const [name, setName] = useState(""); 25 | const [isSubmitting, setIsSubmitting] = useState(false); 26 | const [isOpen, setIsOpen] = useState(false); 27 | 28 | const rules = [ 29 | { 30 | label: "at least one character", 31 | valid: name.length > 0, 32 | }, 33 | { 34 | label: "max. 64 characters", 35 | valid: name.length <= 64, 36 | }, 37 | { 38 | label: "only lowercase letters/numbers/ dashes", 39 | valid: name.match(/^[a-z0-9-]*$/) !== null, 40 | }, 41 | { 42 | label: "no leading/trailing/consecutive dashes", 43 | valid: 44 | name.match(/--/) === null && 45 | name.match(/^-/) === null && 46 | name.match(/-$/) === null, 47 | }, 48 | ]; 49 | 50 | const formatIsValid = rules.every((rule) => rule.valid); 51 | 52 | async function submit() { 53 | if (!formatIsValid) { 54 | toast.error(`Invalid ${props.label} name`); 55 | return; 56 | } 57 | 58 | setIsSubmitting(true); 59 | try { 60 | await toast.promise(props.submit(name), { 61 | loading: `${ 62 | props.action.slice(0, 1).toUpperCase() + 63 | props.action.slice(1, -1) + 64 | "ing" 65 | } ${props.label}`, 66 | success: (data) => { 67 | if (props.onSuccess && typeof data === "string") { 68 | props.onSuccess(data); 69 | } 70 | setIsOpen(false); 71 | return `Successfully ${props.action + "d"} ${props.label}`; 72 | }, 73 | error: `Could not ${props.action} ${props.label}`, 74 | }); 75 | } catch (error) { 76 | console.error(error); 77 | } finally { 78 | setIsSubmitting(false); 79 | } 80 | } 81 | 82 | return ( 83 | { 86 | setIsOpen(open); 87 | setName(props.previousValue || ""); 88 | }} 89 | > 90 | {props.children} 91 | 92 | 93 | 94 | {props.action} {props.label} 95 | 96 | 97 |
98 | 101 |
102 | setName(e.target.value)} 106 | className="w-full mb-0.5" 107 | onKeyDown={(e) => { 108 | if (e.key === "Enter") { 109 | submit(); 110 | } 111 | }} 112 | /> 113 | {props.previousValue !== undefined && ( 114 |
115 | previously{" "} 116 | 117 | {props.previousValue} 118 | 119 |
120 | )} 121 | {rules.map((rule, index) => ( 122 |
129 | {rule.valid ? ( 130 | 131 | ) : ( 132 | 133 | )} 134 | 135 |
{rule.label}
136 |
137 | ))} 138 |
139 |
140 | 141 | 142 | 149 | 150 |
151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/navigation-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useUser } from "@/requests/user"; 5 | import { Button } from "@/components/ui/button"; 6 | import { IconRipple } from "@tabler/icons-react"; 7 | 8 | export function NavigationBar() { 9 | const { userData, logoutUser } = useUser(); 10 | 11 | return ( 12 |
13 | 17 | 18 | 19 | 20 |

21 | Tenta Dashboard{" "} 22 | 23 | {process.env.NEXT_PUBLIC_INSTANCE_TITLE !== undefined && ( 24 | <> |  {process.env.NEXT_PUBLIC_INSTANCE_TITLE} 25 | )} 26 | 27 |

28 | 29 |
30 |

31 | powered by{" "} 32 | 37 | github.com/iterize/tenta 38 | 39 |

40 | {userData !== undefined && ( 41 | 44 | )} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; 2 | import { range } from "lodash"; 3 | import { clsx } from "clsx"; 4 | import { toast } from "react-hot-toast"; 5 | 6 | export function Pagination(props: { 7 | currentPageNumber: number; 8 | numberOfPages: number; 9 | setCurrentPageNumber: (page: number) => void; 10 | noDataPlaceholder: string; 11 | }) { 12 | const showLeftDots = props.numberOfPages > 5 && props.currentPageNumber > 3; 13 | const showRightDots = 14 | props.numberOfPages > 5 && 15 | props.currentPageNumber < props.numberOfPages - 2; 16 | 17 | let visiblePages: number[] = []; 18 | if (showLeftDots && !showRightDots) { 19 | visiblePages = range(props.numberOfPages - 3, props.numberOfPages + 1); 20 | } else if (!showLeftDots && showRightDots) { 21 | visiblePages = [1, 2, 3, 4]; 22 | } else if (showLeftDots && showRightDots) { 23 | visiblePages = [ 24 | props.currentPageNumber - 1, 25 | props.currentPageNumber, 26 | props.currentPageNumber + 1, 27 | ]; 28 | } else { 29 | visiblePages = range(1, props.numberOfPages + 1); 30 | } 31 | 32 | return ( 33 |
34 | 47 |
48 | {showLeftDots && ( 49 |
50 | ... 51 |
52 | )} 53 | 54 | {visiblePages.length === 0 && ( 55 |
56 | {props.noDataPlaceholder} 57 |
58 | )} 59 | 60 | {visiblePages.length > 0 && ( 61 | <> 62 |
66 | {props.currentPageNumber} 67 |
68 | 69 | )} 70 | 71 | {visiblePages.map((pageNumber) => ( 72 | 83 | ))} 84 | 85 | {showRightDots && ( 86 |
87 | ... 88 |
89 | )} 90 |
91 | 104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/spinner.tsx: -------------------------------------------------------------------------------- 1 | export function Spinner() { 2 | return ( 3 | 15 | 16 | 20 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/the-tenta.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export function TheTenta(props: { className: string }) { 4 | return ( 5 |
6 | Tenta Artwork 13 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /dashboard/src/components/custom/timestamp-label.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger, 6 | } from "@/components/ui/tooltip"; 7 | import { formatDistanceToNow } from "date-fns"; 8 | 9 | export function TimestampLabel(props: { 10 | label: string; 11 | timestamp: number | null; 12 | labelClassName?: string; 13 | }) { 14 | if (props.timestamp === null) { 15 | return ( 16 |
17 | not {props.label} (yet) 18 |
19 | ); 20 | } else { 21 | return ( 22 |
23 | 24 | 25 | 26 | {props.label}{" "} 27 | {formatDistanceToNow(new Date(props.timestamp * 1000), { 28 | addSuffix: true, 29 | })} 30 | 31 | 32 |

{new Date(props.timestamp * 1000).toISOString()}

33 |
34 |
35 |
36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-regular transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-8 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | } 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = (props: DialogPrimitive.DialogPortalProps) => ( 12 | 13 | ); 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogTrigger, 112 | DialogContent, 113 | DialogHeader, 114 | DialogFooter, 115 | DialogTitle, 116 | DialogDescription, 117 | } 118 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons" 3 | import * as SelectPrimitive from "@radix-ui/react-select" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectContent = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, children, position = "popper", ...props }, ref) => ( 37 | 38 | 49 | 56 | {children} 57 | 58 | 59 | 60 | )) 61 | SelectContent.displayName = SelectPrimitive.Content.displayName 62 | 63 | const SelectLabel = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, ...props }, ref) => ( 67 | 72 | )) 73 | SelectLabel.displayName = SelectPrimitive.Label.displayName 74 | 75 | const SelectItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef 78 | >(({ className, children, ...props }, ref) => ( 79 | 87 | 88 | 89 | 90 | 91 | 92 | {children} 93 | 94 | )) 95 | SelectItem.displayName = SelectPrimitive.Item.displayName 96 | 97 | const SelectSeparator = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 108 | 109 | export { 110 | Select, 111 | SelectGroup, 112 | SelectValue, 113 | SelectTrigger, 114 | SelectContent, 115 | SelectLabel, 116 | SelectItem, 117 | SelectSeparator, 118 | } 119 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )); 51 | TabsContent.displayName = TabsPrimitive.Content.displayName; 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 54 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |