163 Commits

Author SHA1 Message Date
root fa19dbe504 Update 2025-10-23 06:58:14 +00:00
root 29d40c6f10 Merge dev into master 2025-10-23 06:52:28 +00:00
root 9133928401 Minor Fix 2025-10-23 06:52:10 +00:00
root 5e40b3105c Update 2025-10-22 19:38:42 +00:00
root 354e2844d1 Update 2025-10-22 19:35:27 +00:00
root 597d7ffb6f Release 0.4 2025-10-22 19:24:47 +00:00
root 7072a21ed8 Readme Update 2025-10-22 19:22:13 +00:00
root 754b023435 Update 2025-10-22 19:13:26 +00:00
root ac57b932c2 Update 2025-10-22 14:58:09 +00:00
root e607310689 Final Commit 2025-10-22 13:27:22 +00:00
root 1f8906bc3d Frontend Renewal 2025-10-22 13:13:48 +00:00
root d6ddf12c04 Update 2025-10-22 11:33:12 +00:00
root 994263dc45 Update 2025-10-21 19:32:17 +00:00
root 23d46e7c40 Update 2025-10-21 19:31:45 +00:00
root d8e0bdc814 Update 2025-10-21 19:31:04 +00:00
root f45a2525e6 Update 2025-10-21 16:16:25 +00:00
root 5852fc6616 Update 2025-10-21 10:55:28 +00:00
root aaf9afdc9f Update 2025-10-21 07:05:22 +00:00
root 98a6df5081 Update 2025-10-21 06:30:47 +00:00
root 2c3379026b y 2025-10-20 19:18:35 +00:00
root 84be8e5828 Update 2025-10-20 18:18:04 +00:00
root 1a5de83d50 Update 2025-10-20 12:29:34 +00:00
root 476b09499a Update 2025-10-20 07:51:36 +00:00
root 974462e983 Update 2025-10-16 20:00:39 +00:00
root 749f04c2c4 Update 2025-10-16 10:30:27 +00:00
root 7de4e85a38 Update 2025-10-16 10:11:52 +00:00
root 4266adc853 Logs Pagination 2025-10-16 06:51:09 +00:00
root f7be617e1d Update 2025-10-15 19:31:09 +00:00
root 4dd2ba9d9d Update Preos 2025-10-15 18:55:45 +00:00
root f8209c3712 New Frontend Preps 2025-10-15 14:57:39 +00:00
root d776b80fe0 Update 2025-10-15 14:45:04 +00:00
root dd89d8388e Update 2025-10-13 19:23:56 +00:00
root f5ec13701b Update 2025-10-13 19:23:15 +00:00
root bbdbf0176e Update 2025-10-13 19:22:15 +00:00
root 2fc90639f7 Update 2025-10-13 19:21:43 +00:00
root 5cf3d8fe70 Update Readme 2025-10-13 19:21:05 +00:00
root e3d802eeef Final Push 2025-10-13 18:44:15 +00:00
root ca59231480 Update 2025-10-12 20:42:37 +00:00
root 1d0ece6940 Update 2025-10-12 20:39:48 +00:00
root 5fcc7fdbf1 Update Dockerfile 2025-10-12 13:54:10 +00:00
root 3b12557a89 Update Dockerfile 2025-10-12 13:50:44 +00:00
root 666e4e67ad Update Dockerfile 2025-10-12 13:49:17 +00:00
root 11f4759e78 Merge feature/v03-notifications into dev 2025-10-12 13:33:50 +00:00
root de4a05a6e6 Update 2025-10-12 13:33:28 +00:00
root b682f3378c Merge feature/v03-notifications into dev 2025-10-11 19:08:46 +00:00
root 56891182d5 Portainer Update 2025-10-11 18:53:51 +00:00
root d25d34b8cd Portainer SSH Connection 2025-10-10 20:10:42 +00:00
root 016cb6671a Take all changes from b2 into b1 2025-10-10 10:36:42 +00:00
root 9313c01637 Update 2025-10-10 10:19:53 +00:00
root 96b1446292 Maintenance Page + Portainer Update 2025-10-10 10:18:35 +00:00
root 14b1459767 Update 2025-10-10 10:13:34 +00:00
root e2cc586584 Meine Änderungen 2025-10-10 09:36:07 +00:00
root c71025d717 Merge feature/v03-double_stack_remove into dev 2025-10-09 18:48:24 +00:00
root e8aeecec43 Maintenance Page / Double Stack Remove 2025-10-09 18:47:54 +00:00
root 4e63ae529e Maintenance Page and Doube Stack Find 2025-10-09 18:45:44 +00:00
root a8c01b9cb2 Merge feature/v03-notifications into dev 2025-10-09 18:21:58 +00:00
root b6579f511d Toast Notifications 2025-10-09 10:30:26 +00:00
root db46b366ab Sync README from dev 2025-10-09 08:57:36 +00:00
root efa732f347 Update 2025-10-09 08:57:13 +00:00
root a09a68ed39 Sync README from dev 2025-10-09 08:53:37 +00:00
root 81f7660768 Update 2025-10-09 08:52:55 +00:00
root a53a91caeb Update Readme 2025-10-09 08:52:14 +00:00
root 841a1713d2 Merge feature/v03-stack_filters into dev 2025-10-09 08:32:08 +00:00
root 737495d186 Minor frontend changes 2025-10-08 19:06:19 +00:00
root 6ed9b94d3a Stack Filter Begin 2025-10-08 15:03:42 +00:00
root 95336e4350 Merge feature/v03-stack_filters into dev 2025-10-02 19:15:07 +00:00
root 8a2b4a2ac5 Update 2025-10-02 19:14:48 +00:00
root cf0b51eb7d Merge feature/v03-stack_filters into dev 2025-10-02 19:13:40 +00:00
root 3875d275e0 Update Readme 2025-10-02 19:13:17 +00:00
root 3f68c6e39a Merge feature/v03-stack_filters into dev 2025-10-02 18:56:57 +00:00
root 53f1b55157 Update Readme 2025-10-02 18:56:30 +00:00
root dc9b92bdff Merge dev into master 2025-10-01 19:22:08 +00:00
root 6e7ec194af Merge feature/Logging into dev 2025-10-01 19:21:31 +00:00
root 3003084112 Update 2025-10-01 19:21:13 +00:00
root 4592daff36 Update 2025-10-01 19:20:49 +00:00
root 3c16c4d8e4 Update 2025-10-01 19:20:24 +00:00
root 959af0cdd4 Update 2025-10-01 19:17:46 +00:00
root 777ea4d74f Update 2025-10-01 19:17:29 +00:00
root a9228a2557 Update 2025-10-01 19:16:55 +00:00
root 2b745e3d78 Update 2025-10-01 19:16:34 +00:00
root eada55b8d7 Update 2025-10-01 19:15:29 +00:00
root b1f5ac03f6 Update 2025-10-01 19:10:10 +00:00
root 2c78ab966d Update 2025-10-01 19:08:53 +00:00
root cd003a12f8 Merge dev into master 2025-10-01 19:05:08 +00:00
root 752f8170ba Merge feature/Logging into dev 2025-10-01 19:04:39 +00:00
root be1890582c Update 2025-10-01 19:04:07 +00:00
root b74d49a836 Merge dev into master 2025-10-01 18:57:36 +00:00
root cba236b95f Merge feature/Logging into dev 2025-10-01 18:57:00 +00:00
root 040cd4d3cb Update 2025-10-01 18:56:20 +00:00
root 3649d2ee04 Update 2025-10-01 18:55:15 +00:00
root 0a6d17c64e Update 2025-10-01 18:53:33 +00:00
root 7a69dd0821 Update 2025-10-01 18:52:09 +00:00
root 4053db4a3e Merge dev into master 2025-10-01 11:47:17 +00:00
root 35d64d6765 Merge feature/Logging into dev 2025-10-01 11:46:24 +00:00
root 6000641e50 Update 2025-10-01 11:44:53 +00:00
root d349d31d02 Merge dev into master 2025-10-01 11:39:43 +00:00
root 4241bb3114 Merge feature/Logging into dev 2025-10-01 11:35:36 +00:00
root 08c37245e1 Update Readme 2025-10-01 11:35:05 +00:00
root ef33a0d669 Merge feature/Logging into dev 2025-10-01 11:32:33 +00:00
root ec2d998bc5 Update 2025-10-01 10:40:52 +00:00
root 7cb1201b51 Merge feature/Logging into dev 2025-10-01 08:53:27 +00:00
root 8fa5ffc49c Fix 2025-10-01 08:53:09 +00:00
root e10d0dec1c Merge feature/Logging into dev 2025-09-30 21:03:13 +00:00
root 5483ee9092 Update 2025-09-30 21:02:51 +00:00
root 02fe83d65c Merge feature/Logging into dev 2025-09-30 21:01:05 +00:00
root d99ffc1488 Logging fixes, redeploy selection, Logo 2025-09-30 20:58:11 +00:00
root 2b051d5085 Merge feature/Logging into dev 2025-09-30 09:20:15 +00:00
root f46ba1c820 Merge dev into master 2025-09-30 09:19:45 +00:00
root f14c3aa6dd Update Readme 2025-09-30 09:19:20 +00:00
root 044164a440 Database/LoggingExtended 2025-09-30 09:15:18 +00:00
root c37b046e39 Merge feature/Logging into dev 2025-09-30 09:08:57 +00:00
root cc7d80e569 Delete/Export/Pageination 2025-09-30 09:08:36 +00:00
root deb05814e4 Merge feature/Logging into dev 2025-09-30 08:42:24 +00:00
root 2b284195fb Update 2025-09-30 08:42:05 +00:00
root 24c98aff3e Merge feature/Logging into dev 2025-09-30 08:38:28 +00:00
root b33e9e587b Update Dockerbuild 2025-09-30 08:38:05 +00:00
root b1fdd074ec Merge feature/Logging into dev 2025-09-30 08:22:07 +00:00
root 3a961270d7 Update Reame 2025-09-30 07:09:00 +00:00
root 0a65f3e60f Logging Filter 2025-09-30 07:02:59 +00:00
root 38b96da4e7 db write 2025-09-29 19:18:12 +00:00
root 241a509a15 Log and Database 2025-09-29 19:04:26 +00:00
root 520e36fe85 Merge dev into master 2025-09-29 08:36:32 +00:00
root 1f248e3920 Merge feature/Logging into dev 2025-09-29 08:35:54 +00:00
root ba0905e2b9 Update 2025-09-29 08:19:15 +00:00
root 876acfa763 Update 2025-09-29 08:15:31 +00:00
root ccd7fffe5c Merge dev into master 2025-09-29 08:12:48 +00:00
root ee7ccd0a39 Merge feature/first-stack-display into dev 2025-09-29 08:12:18 +00:00
root 6687a86802 Update 2025-09-29 08:11:52 +00:00
root c7d63a28c4 Merge dev into master 2025-09-29 08:09:06 +00:00
root d91655aee9 Merge feature/first-stack-display into dev 2025-09-29 08:03:45 +00:00
root 8001a2be37 Update 2025-09-29 08:02:39 +00:00
root 7f241d352d Merge dev into master 2025-09-29 07:39:43 +00:00
root 32af9d2584 Merge feature/first-stack-display into dev 2025-09-29 07:39:13 +00:00
root 55a83267a6 Update 2025-09-29 07:38:42 +00:00
root dc56f0cbeb Update 2025-09-29 07:37:53 +00:00
root 66d975ae75 Update 2025-09-29 07:37:19 +00:00
root 571b185b9b Update 2025-09-29 07:36:14 +00:00
root 8c59fc5445 Update 2025-09-29 07:35:58 +00:00
root 6bf4f7aab4 Merge feature/first-stack-display into dev 2025-09-29 07:31:05 +00:00
root cd7933ebc7 Update 2025-09-29 07:30:23 +00:00
root 3b0bd3c632 Update 2025-09-29 07:26:32 +00:00
root 281aeb40a6 Update 2025-09-29 07:19:01 +00:00
root 429721d9da Update 2025-09-29 07:18:22 +00:00
root 47f632f8fc Update 2025-09-29 07:17:47 +00:00
root 2e7ebfe06d Update 2025-09-29 07:17:07 +00:00
root 03b3766a22 Update 2025-09-29 07:16:21 +00:00
root d1adcb1188 Update 2025-09-28 19:47:15 +00:00
root e38269e23e Merge dev into master 2025-09-28 19:45:37 +00:00
root 81f0e7eaf0 Merge feature/first-stack-display into dev 2025-09-28 19:44:37 +00:00
root 7980837eaf Update 2025-09-28 19:43:06 +00:00
root 31b506ceb8 Update 2025-09-28 19:42:04 +00:00
root 44e3754fcb Update 2025-09-28 19:35:18 +00:00
root 73d225cc79 Merge dev into master 2025-09-28 19:22:14 +00:00
root a1e20f188e Merge feature/first-stack-display into dev 2025-09-28 19:21:28 +00:00
root 421e6e47dd Update 2025-09-28 19:20:57 +00:00
root 79e420d3e3 Merge dev into master 2025-09-26 10:10:17 +00:00
root ee3b907cd6 Merge feature/first-stack-display into dev 2025-09-26 10:07:29 +00:00
root 960832af6b Update 2025-09-26 10:06:59 +00:00
root 3489ea55a2 Update 2025-09-26 09:28:34 +00:00
root ca148247cb Update 2025-09-25 11:48:52 +00:00
root bff6521658 Update 2025-09-25 11:47:18 +00:00
root ef8920cb26 Update 2025-09-25 11:38:11 +00:00
root 279ad3ffd1 Update 2025-09-25 11:34:16 +00:00
100 changed files with 13205 additions and 555 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ frontend/dist
backend/.env backend/.env
backend/node_modules backend/node_modules
frontend/node_modules frontend/node_modules
backend/data/*.db*
+24 -1
View File
@@ -11,4 +11,27 @@ backend/node_modules
backend/.env backend/.env
frontend/node_modules frontend/node_modules
docker-compose.dev.yml docker-compose.dev.yml
scripts/docker-release.sh devscripts
backend/data/stackpulse.db
backend/data/stackpulse.db-shm
backend/data/stackpulse.db-wal
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
pnpm-lock.yaml
package-lock.json
yarn.lock
dist
dist-ssr
*.local
!.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+11
View File
@@ -22,6 +22,9 @@ RUN npm run build
# =============================== # ===============================
FROM node:20-alpine AS runtime FROM node:20-alpine AS runtime
#Install SSH-Client
RUN apk add --no-cache openssh-client
# Arbeitsverzeichnis # Arbeitsverzeichnis
WORKDIR /app/backend WORKDIR /app/backend
@@ -31,10 +34,17 @@ RUN npm ci --only=production
# Backend-Code kopieren # Backend-Code kopieren
COPY backend/ ./ COPY backend/ ./
COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
# public leeren # public leeren
RUN rm -rf ./public/* RUN rm -rf ./public/*
# frische Datenbank vorbereiten
RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data
# EntryPoint vorbereiten
RUN chmod +x ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh
# Inhalt von dist inklusive Unterordner direkt nach public kopieren # Inhalt von dist inklusive Unterordner direkt nach public kopieren
COPY --from=frontend-build /app/frontend/dist/. ./public/ COPY --from=frontend-build /app/frontend/dist/. ./public/
@@ -50,4 +60,5 @@ EXPOSE 5173
USER node USER node
# Container startet das Backend (liefert statisches Frontend) # Container startet das Backend (liefert statisches Frontend)
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "index.js"] CMD ["node", "index.js"]
+112 -15
View File
@@ -1,6 +1,9 @@
# 📦 StackPulse ![StackPulse Logo](assets/images/stackpulse.png)
# 📦 StackPulse ![Release](https://img.shields.io/badge/release-v0.2-blue.svg)
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert. **StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
Aktuell funktioniert StackPulse nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird in einem späteren Release implementiert!
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**. Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
Ziel: Ziel:
@@ -10,14 +13,90 @@ Ziel:
--- ---
## 🚀 Features (0.1 Roadmap) ## 🚀 Features & Roadmap
- [x] Projektstruktur mit Frontend & Backend <details>
- [x] Lokales Startskript (`scripts/start-dev.sh`) <summary>✅ v0.1 Initial Release</summary>
- [x] Frontend zeigt Stacks an (über Backend)
- [x] API-Verbindung zu Portainer - Projektstruktur mit Frontend & Backend
- [x] Stack Redeploy - Lokales Startskript (`scripts/start-dev.sh`)
- [x] Docker Image im ghcr zur Verfügung stellen - Frontend zeigt Stacks an (über Backend)
- API-Verbindung zu Portainer
- Stack Redeploy
- Bereitstellung eines Docker Images über GHCR
</details>
<details>
<summary>✅ v0.2 Release</summary>
### Backend
- [x] Anbindung einer SQLite-Datenbank
- [x] Logging der Redeploy-Aktionen in SQLite speichern
- [x] API-Endpunkte für Log-Abfragen
- [x] Funktionen für Pagination, Löschen und Export
### Frontend
- [x] Anzeige der Logs (inkl. Statusfarben)
- [x] UI-Komponenten für Log-Details
- [x] Filterfunktionen für die Logs
- [x] Pagination, Lösch- und Exportanzeigen
### Features
- [x] Selektive Auswahl: einzelne Stacks oder Services neu deployen
</details>
<details>
<summary>✅ v0.3 Release</summary>
### Backend
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
- [x] Erweiterung der Logs
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
### Frontend
- [x] Filter: Stacks nach Name oder Status durchsuchen
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
### Features
- [x] Frontend-Filter für schnellere Navigation bei vielen Stacks
- [x] Echtzeit-Feedback im UI (Notifications)
- [x] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
</details>
<details open>
<summary>✅ v0.4 Release</summary>
### Backend
- keine Änderungen
### Frontend
- [x] GUI komplett überarbeitet
- [x] Umstellung auf Material Tailwind
- [x] Dashboard von Creative Tim als neues Frontend
### Features
- [x] Neue, übersichtliche Oberfläche im Material Design
- [x] leicht erweiterbar durch vorgefertigte Komponenten
- [x] kleine Bugfixes
</details>
<details open>
<summary>🔮 Geplante Features (v0.5+)</summary>
- Notifications (z. B. via Webhooks oder Mail)
- Authentifizierung & Benutzerverwaltung
- Monitoring (Status, CPU/RAM)
- Export/Import von Scheduler-Jobs (z. B. JSON)
- Integration Community Edition
</details>
</details>
--- ---
@@ -26,13 +105,11 @@ Ziel:
```bash ```bash
stackpulse/ stackpulse/
├── backend/ # Node.js Backend mit Express ├── backend/ # Node.js Backend mit Express
│ └── data # SQlite Datenbank
│ └── db # Datenbank-Integration
├── frontend/ # React Frontend mit Tailwind ├── frontend/ # React Frontend mit Tailwind
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images) ├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
│ ├── git-push.sh #nicht relevant
│ └── merge-dev-to-master.sh #nicht relevant
│ └── merge-feature-to-dev.sh #nicht relevant
│ └── start-dev.sh #Skript für den lokalen Start │ └── start-dev.sh #Skript für den lokalen Start
│ └── switch-branch.sh #nicht relevant
├── Dockerfile # Multi-Stage Build für Frontend + Backend ├── Dockerfile # Multi-Stage Build für Frontend + Backend
├── docker-compose.yml ├── docker-compose.yml
└── README.md └── README.md
@@ -63,12 +140,32 @@ services:
image: ghcr.io/mboehmlaender/stackpulse image: ghcr.io/mboehmlaender/stackpulse
ports: ports:
- '4001:4001' - '4001:4001'
volumes:
- stackpulse_data:/app/backend/data
environment: environment:
- PORTAINER_URL=Deine_Portainer_Adresse/ PORTAINER_URL: "Your_Portainer_Server_Address"
- PORTAINER_API_KEY=Dein_Portainer_API_Key PORTAINER_API_KEY: "Your_Portainer_API_Key"
- PORTAINER_ENDPOINT_ID=Deine_Portainer_Endpoint_ID PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
restart: unless-stopped
volumes:
stackpulse_data:
``` ```
Die PORTAINER_ENDPOINT_ID erhältst du, wenn du die die URL im Browser ansiehst, wenn du das Dashboard in Portainer öffnest:
![PORTAINER_ENDPOINT_ID](assets/images/ENDPOINT_ID.png)
Die 3 wäre in diesem Fall Endpoint-ID.
Die STACK_SELF_ID findest du, wenn du das Frontend von StackPulse öffnest:
![SELF_STACK_ID](assets/images/SELF_STACK_ID.png)
Diese ID kann erst nach dem Deploy von Stackpulse ausgelesen werden. Vergiss daher nicht, nach dem Hinterlegen der ID in den Variablen das Stack noch einmal zu redeployen!
--- ---
## 📋 Voraussetzungen ## 📋 Voraussetzungen
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+1
View File
@@ -0,0 +1 @@
stackpulse.db
+18
View File
@@ -0,0 +1,18 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, '..', 'data');
const dbFile = path.join(dataDir, 'stackpulse.db');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(dbFile);
db.pragma('journal_mode = WAL');
export { db, dbFile };
+46
View File
@@ -0,0 +1,46 @@
import { db } from './index.js';
const createRedeployLogsTable = `
CREATE TABLE IF NOT EXISTS redeploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
stack_id TEXT NOT NULL,
stack_name TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
endpoint INTEGER,
redeploy_type TEXT
);
`;
db.exec(createRedeployLogsTable);
try {
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
if (!hasRedeployType) {
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
console.log('️ redeploy_type column hinzugefügt');
}
} catch (err) {
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
}
console.log('✅ redeploy_logs table ready');
const createSettingsTable = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
db.exec(createSettingsTable);
console.log('✅ settings table ready');
db.close();
+181
View File
@@ -0,0 +1,181 @@
import { db } from './index.js';
const valueToArray = (value) => {
if (!value && value !== 0) return [];
const base = Array.isArray(value) ? value : [value];
return base
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter(Boolean);
};
const singleValue = (value) => {
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? value[0] : value;
};
export function buildLogFilter(queryParams = {}) {
const filters = [];
const params = {};
const ids = valueToArray(queryParams.ids ?? queryParams.id)
.map((entry) => Number(entry))
.filter((value) => !Number.isNaN(value));
if (ids.length) {
const placeholders = ids.map((_, idx) => `@id${idx}`);
filters.push(`id IN (${placeholders.join(', ')})`);
ids.forEach((value, idx) => {
params[`id${idx}`] = value;
});
}
const stackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
if (stackIds.length) {
const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
filters.push(`stack_id IN (${placeholders.join(', ')})`);
stackIds.forEach((stack, idx) => {
params[`stackId${idx}`] = stack;
});
}
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
if (statuses.length) {
const placeholders = statuses.map((_, idx) => `@status${idx}`);
filters.push(`status IN (${placeholders.join(', ')})`);
statuses.forEach((entry, idx) => {
params[`status${idx}`] = entry;
});
}
const endpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
if (endpoints.length) {
const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
filters.push(`endpoint IN (${placeholders.join(', ')})`);
endpoints.forEach((entry, idx) => {
const numeric = Number(entry);
params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
});
}
const redeployTypes = valueToArray(queryParams.redeployTypes ?? queryParams.redeployType);
if (redeployTypes.length) {
const placeholders = redeployTypes.map((_, idx) => `@redeployType${idx}`);
filters.push(`redeploy_type IN (${placeholders.join(', ')})`);
redeployTypes.forEach((entry, idx) => {
params[`redeployType${idx}`] = entry;
});
}
const messageQuery = singleValue(queryParams.message);
if (messageQuery && String(messageQuery).trim()) {
filters.push('message LIKE @message');
params.message = `%${String(messageQuery).trim()}%`;
}
const from = singleValue(queryParams.from);
if (from) {
filters.push('timestamp >= @from');
params.from = from;
}
const to = singleValue(queryParams.to);
if (to) {
filters.push('timestamp <= @to');
params.to = to;
}
return {
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
params,
};
}
const insertRedeployLogStmt = db.prepare(`
INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint, redeploy_type)
VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
`);
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
try {
insertRedeployLogStmt.run({
stackId: String(stackId),
stackName: stackName ?? 'Unknown',
status,
message,
endpoint,
redeployType: redeployType ?? null
});
} catch (err) {
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
}
}
export function deleteLogById(id) {
const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
const info = stmt.run(id);
return info.changes || 0;
}
export function deleteLogsByFilters(queryParams = {}) {
const { whereClause, params } = buildLogFilter(queryParams);
const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
const info = stmt.run(params);
return info.changes || 0;
}
export function exportLogsByFilters(queryParams = {}, format = 'txt') {
const { whereClause, params } = buildLogFilter(queryParams);
const rows = db.prepare(`
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
FROM redeploy_logs
${whereClause}
ORDER BY datetime(timestamp) DESC
`).all(params);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
if (format === 'sql') {
const statements = rows.map((row) => {
const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint', 'redeploy_type'];
const values = [
row.id,
row.timestamp,
row.stackId,
row.stackName,
row.status,
row.message,
row.endpoint,
row.redeployType
].map((value) => {
if (value === null || value === undefined) return 'NULL';
return `'${String(value).replace(/'/g, "''")}'`;
});
return `INSERT INTO redeploy_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
});
return {
filename: `redeploy-logs-${timestamp}.sql`,
contentType: 'application/sql; charset=utf-8',
content: statements.join('\n')
};
}
const lines = rows.map((row) => {
const parts = [
`[${row.id}]`,
row.timestamp,
`Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
`Status: ${row.status}`,
`Endpoint: ${row.endpoint ?? '-'}`,
`Nachricht: ${row.message ?? '-'}`,
`Redeploy: ${row.redeployType ?? '-'}`
];
return parts.join(' | ');
});
return {
filename: `redeploy-logs-${timestamp}.txt`,
contentType: 'text/plain; charset=utf-8',
content: lines.join('\n')
};
}
+61
View File
@@ -0,0 +1,61 @@
import { db } from './index.js';
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
const setSettingStmt = db.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (@key, @value, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`);
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
export function getSetting(key) {
try {
return getSettingStmt.get(key) || null;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
return null;
}
}
export function setSetting(key, value) {
try {
setSettingStmt.run({ key, value });
return true;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
return false;
}
}
export function deleteSetting(key) {
try {
deleteSettingStmt.run(key);
return true;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
return false;
}
}
export function getJsonSetting(key, defaultValue = null) {
const row = getSetting(key);
if (!row || row.value === null || row.value === undefined) return defaultValue;
try {
return JSON.parse(row.value);
} catch (err) {
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
return defaultValue;
}
}
export function setJsonSetting(key, value) {
try {
const payload = value === undefined ? null : JSON.stringify(value);
return setSetting(key, payload);
} catch (err) {
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
return false;
}
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "🔧 Starte Migration (idempotent)..."
node db/migrate.js
echo "✅ Migration abgeschlossen. Starte Anwendung..."
exec "$@"
+1772 -63
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
const MAINTENANCE_KEY = 'maintenance_mode';
const DEFAULT_STATE = {
active: false,
message: null,
activatedAt: null,
updatedAt: null,
extra: null
};
let maintenanceState = loadState();
function loadState() {
const saved = getJsonSetting(MAINTENANCE_KEY, null);
if (!saved) {
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
setJsonSetting(MAINTENANCE_KEY, initial);
return initial;
}
return {
...DEFAULT_STATE,
...saved
};
}
function persistState(state) {
maintenanceState = {
...DEFAULT_STATE,
...state,
updatedAt: new Date().toISOString()
};
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
return maintenanceState;
}
export function getMaintenanceState() {
return { ...maintenanceState };
}
export function isMaintenanceModeActive() {
return Boolean(maintenanceState?.active);
}
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
const now = new Date().toISOString();
return persistState({
active: true,
message,
extra,
activatedAt: now
});
}
export function deactivateMaintenanceMode({ message = null } = {}) {
return persistState({
active: false,
message,
extra: null,
activatedAt: null
});
}
+219
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
"better-sqlite3": "^9.6.0",
"dockerode": "^4.0.7", "dockerode": "^4.0.7",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
@@ -230,6 +231,24 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"node_modules/better-sqlite3": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -441,6 +460,28 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -466,6 +507,14 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
"integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
"engines": {
"node": ">=8"
}
},
"node_modules/docker-modem": { "node_modules/docker-modem": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
@@ -686,6 +735,14 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -731,6 +788,11 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@@ -854,6 +916,11 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -951,6 +1018,11 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1039,6 +1111,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": { "node_modules/mkdirp-classic": {
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -1055,6 +1146,11 @@
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"optional": true "optional": true
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1063,6 +1159,17 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.77.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz",
"integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1114,6 +1221,31 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.5.4", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@@ -1199,6 +1331,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -1244,6 +1390,17 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.0", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@@ -1367,6 +1524,49 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/socket.io": { "node_modules/socket.io": {
"version": "4.8.1", "version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
@@ -1530,6 +1730,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
@@ -1564,6 +1772,17 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": { "node_modules/tweetnacl": {
"version": "0.14.5", "version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+17 -1
View File
@@ -1 +1,17 @@
{"name":"backend","version":"1.0.0","type":"module","scripts":{"start":"node index.js"},"dependencies":{"axios":"^1.7.0","dockerode":"^4.0.7","dotenv":"^16.0.0","express":"^4.18.0","socket.io":"^4.8.1"}} {
"name": "backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"migrate": "node db/migrate.js"
},
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^9.6.0",
"dockerode": "^4.0.7",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"socket.io": "^4.8.1"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+31 -4
View File
@@ -1,12 +1,39 @@
<!--
=========================================================
* Material Tailwind Dashboard React - v2.1.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
* Coded by Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPulse</title> <title>Material Tailwind Dashboard React | By Creative Tim</title>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-a1f09468.js"></script> <!-- Nepcha Analytics (nepcha.com) -->
<link rel="stylesheet" href="/assets/index-0842cd3a.css"> <!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-12a5cbeb.js"></script>
<link rel="stylesheet" href="/assets/index-c94d5694.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6
View File
@@ -6,8 +6,14 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "4001:4001" - "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
environment: environment:
PORTAINER_URL: "Your_Portainer_Server_Address" PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key" PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID" PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
restart: unless-stopped restart: unless-stopped
volumes:
stackpulse_data:
+30 -3
View File
@@ -1,10 +1,37 @@
<!--
=========================================================
* Material Tailwind Dashboard React - v2.1.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
* Coded by Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPulse</title> <title>Material Tailwind Dashboard React | By Creative Tim</title>
<link rel="stylesheet" href="/src/index.css" /> <link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<!-- Nepcha Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
},
},
}
+23 -11
View File
@@ -1,22 +1,34 @@
{ {
"name": "stackpulse-frontend", "name": "material-tailwind-dashboard-react",
"version": "0.1.0", "private": true,
"version": "2.1.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.5", "axios": "1.7.5",
"react": "^18.2.0", "socket.io-client": "4.8.1",
"react-dom": "^18.2.0", "@heroicons/react": "2.0.18",
"socket.io-client": "^4.8.1" "@material-tailwind/react": "2.1.4",
"apexcharts": "3.44.0",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-apexcharts": "1.4.1",
"react-dom": "18.2.0",
"react-router-dom": "6.17.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.0.0", "@types/react": "18.2.31",
"autoprefixer": "^10.4.21", "@types/react-dom": "18.2.14",
"postcss": "^8.5.6", "@vitejs/plugin-react": "4.1.0",
"tailwindcss": "^3.4.17", "autoprefixer": "10.4.16",
"vite": "^4.4.9" "postcss": "8.4.31",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "0.5.6",
"tailwindcss": "3.3.4",
"vite": "4.5.0"
} }
} }
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
tailwindConfig: "./tailwind.config.cjs",
plugins: [require("prettier-plugin-tailwindcss")],
};
+10 -12
View File
@@ -1,16 +1,14 @@
import React from "react"; import { Routes, Route, Navigate } from "react-router-dom";
import Stacks from "./Stacks.jsx"; import { Dashboard, Auth } from "@/layouts";
export default function App() { function App() {
return ( return (
<div className="min-h-screen bg-gray-900 text-white"> <Routes>
<header className="p-6 bg-gray-800 shadow-md"> <Route path="/dashboard/*" element={<Dashboard />} />
<h1 className="text-2xl font-bold text-white">StackPulse</h1> <Route path="/auth/*" element={<Auth />} />
<p className="text-gray-400 mt-1">Verwalte deine Docker Stacks</p> <Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
</header> </Routes>
<main className="p-6">
<Stacks />
</main>
</div>
); );
} }
export default App;
-112
View File
@@ -1,112 +0,0 @@
import React, { useEffect, useState } from "react";
import axios from "axios";
import { io } from "socket.io-client";
export default function Stacks() {
const [stacks, setStacks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const socket = io("/", { transports: ["websocket"] });
console.log("🔌 Socket connected");
socket.on("redeployStatus", async ({ stackId, status }) => {
console.log(`🔄 Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`);
if (!status) {
// Status nach Redeploy neu vom Server holen
try {
const res = await axios.get("/api/stacks");
setStacks(res.data.sort((a, b) => a.Name.localeCompare(b.Name)));
} catch (err) {
console.error("Fehler beim Aktualisieren nach Redeploy:", err);
}
} else {
// UI direkt auf redeploying setzen
setStacks(prev =>
prev.map(stack =>
stack.Id === stackId ? { ...stack, redeploying: true } : stack
)
);
}
});
return () => socket.disconnect();
}, []);
const fetchStacks = async () => {
setLoading(true);
try {
const res = await axios.get("/api/stacks");
setStacks(res.data.map(stack => ({ ...stack, redeploying: stack.redeploying || false })));
} catch (err) {
console.error("❌ Fehler beim Abrufen der Stacks:", err);
setError("Fehler beim Laden der Stacks");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStacks();
}, []);
const handleRedeploy = async (stackId) => {
setStacks(prev =>
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: true } : stack)
);
try {
await axios.put(`/api/stacks/${stackId}/redeploy`);
// Socket.IO Event aktualisiert Status automatisch
} catch (err) {
console.error("❌ Fehler beim Redeploy:", err);
setStacks(prev =>
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: false } : stack)
);
}
};
if (loading) return <p className="text-gray-400">Lade Stacks...</p>;
if (error) return <p className="text-red-400">{error}</p>;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
{stacks.map(stack => {
const isRedeploying = stack.redeploying;
return (
<div
key={stack.Id}
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition
${isRedeploying ? "bg-gray-700 cursor-not-allowed" : "bg-gray-800 hover:bg-gray-700"}`}
>
<div className="flex items-center space-x-4">
<div className={`w-12 h-12 flex items-center justify-center rounded-full
${stack.updateStatus === "✅" ? "bg-green-500" :
stack.updateStatus === "⚠️" ? "bg-yellow-500" :
"bg-red-500"}`}
/>
<div>
<p className="text-lg font-semibold text-white">{stack.Name}</p>
<p className="text-sm text-gray-400">ID: {stack.Id}</p>
</div>
</div>
<button
onClick={() => handleRedeploy(stack.Id)}
disabled={isRedeploying}
className={`px-5 py-2 rounded-lg font-medium transition
${isRedeploying ? "bg-orange-500 cursor-not-allowed" :
"bg-blue-500 hover:bg-blue-600"}`}
>
{isRedeploying ? "Redeploying" : "Redeploy"}
</button>
</div>
);
})}
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

@@ -0,0 +1,166 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
const MaintenanceContext = createContext(null);
const INITIAL_STATE = {
maintenance: null,
update: null,
script: null,
ssh: null,
loading: true,
error: "",
lastUpdated: null
};
export default function MaintenanceProvider({ children }) {
const [state, setState] = useState(INITIAL_STATE);
const pollingRef = useRef(null);
const wasRunningRef = useRef(false);
const applyState = useCallback((partial) => {
setState((prev) => ({
...prev,
...partial
}));
}, []);
const fetchConfig = useCallback(async () => {
applyState({ loading: true, error: "" });
try {
const response = await axios.get("/api/maintenance/config");
const data = response.data ?? {};
applyState({
maintenance: data.maintenance ?? null,
update: data.update ?? null,
script: data.script ?? null,
ssh: data.ssh ?? null,
loading: false,
error: "",
lastUpdated: new Date()
});
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
applyState({ loading: false, error: message });
}
}, [applyState]);
const refreshUpdateStatus = useCallback(async () => {
try {
const response = await axios.get("/api/maintenance/update-status");
const data = response.data ?? {};
setState((prev) => ({
...prev,
maintenance: data.maintenance ?? prev.maintenance,
update: data.update ?? prev.update,
error: ""
}));
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
setState((prev) => ({ ...prev, error: message }));
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
useEffect(() => {
if (state.update?.running) {
refreshUpdateStatus();
const intervalId = setInterval(() => {
refreshUpdateStatus();
}, 5000);
pollingRef.current = intervalId;
return () => clearInterval(intervalId);
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return undefined;
}, [state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (!currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [state.update?.running, fetchConfig]);
const triggerUpdate = useCallback(async (payload = {}) => {
await axios.post("/api/maintenance/portainer-update", payload);
await refreshUpdateStatus();
}, [refreshUpdateStatus]);
const saveScript = useCallback(async (script) => {
await axios.put("/api/maintenance/update-script", { script });
await fetchConfig();
}, [fetchConfig]);
const resetScript = useCallback(async () => {
await axios.delete("/api/maintenance/update-script");
await fetchConfig();
}, [fetchConfig]);
const saveSshConfig = useCallback(async (config) => {
await axios.put("/api/maintenance/ssh-config", config);
await fetchConfig();
}, [fetchConfig]);
const deleteSshConfig = useCallback(async () => {
await axios.delete("/api/maintenance/ssh-config");
await fetchConfig();
}, [fetchConfig]);
const setMaintenanceMode = useCallback(async ({ active, message } = {}) => {
const payload = { active: Boolean(active) };
if (message !== undefined) {
payload.message = message;
}
const response = await axios.post("/api/maintenance/mode", payload);
const nextMaintenance = response.data?.maintenance ?? null;
applyState({ maintenance: nextMaintenance, error: "" });
return nextMaintenance;
}, [applyState]);
const testSshConnection = useCallback(async (config) => {
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
return response.data;
}, []);
const value = useMemo(() => ({
maintenance: state.maintenance,
update: state.update,
script: state.script,
ssh: state.ssh,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
refreshConfig: fetchConfig,
refreshUpdateStatus,
setMaintenanceMode,
triggerUpdate,
saveScript,
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
return (
<MaintenanceContext.Provider value={value}>
{children}
</MaintenanceContext.Provider>
);
}
export function useMaintenance() {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenance must be used within a MaintenanceProvider');
}
return context;
}
+253
View File
@@ -0,0 +1,253 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
const DEFAULT_PER_PAGE_OPTIONS = [
{ value: "10", label: "10" },
{ value: "25", label: "25" },
{ value: "50", label: "50" },
{ value: "100", label: "100" },
{ value: "all", label: "Alle" }
];
const DEFAULT_PER_PAGE = "50";
const PageContext = createContext(null);
const normalizeNumber = (value, fallback) => {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric;
}
const fallbackNumeric = Number(fallback);
if (Number.isFinite(fallbackNumeric) && fallbackNumeric > 0) {
return fallbackNumeric;
}
return 1;
};
export function PageProvider({
children,
initialPage = 1,
initialPerPage = DEFAULT_PER_PAGE,
perPageOptions = DEFAULT_PER_PAGE_OPTIONS
}) {
const [page, setPage] = useState(initialPage);
const [perPage, setPerPage] = useState(initialPerPage);
const [totalItems, setTotalItems] = useState(0);
const [visibleItems, setVisibleItems] = useState(0);
const initialPageRef = useRef(initialPage);
const initialPerPageRef = useRef(initialPerPage);
const validPerPageValues = useMemo(() => new Set(
perPageOptions.map((option) => String(option.value))
), [perPageOptions]);
const perPageIsAll = perPage === "all";
const resolvePerPageNumber = useCallback(() => {
if (perPageIsAll) {
return null;
}
return normalizeNumber(perPage, initialPerPage);
}, [perPage, perPageIsAll, initialPerPage]);
const updateTotals = useCallback((total, visible) => {
setTotalItems(Math.max(0, Number(total) || 0));
setVisibleItems(Math.max(0, Number(visible) || 0));
}, []);
const resetPagination = useCallback(() => {
setPage(initialPageRef.current ?? 1);
setPerPage(initialPerPageRef.current ?? DEFAULT_PER_PAGE);
setTotalItems(0);
setVisibleItems(0);
}, []);
const setPerPageValue = useCallback((value, { resetPage = true } = {}) => {
if (value === undefined || value === null) return;
const nextValue = String(value);
if (!validPerPageValues.has(nextValue)) {
return;
}
setPerPage(nextValue);
if (resetPage) {
setPage(1);
}
}, [validPerPageValues]);
const handlePerPageChange = useCallback((eventOrValue) => {
const nextValue = typeof eventOrValue === "string"
? eventOrValue
: eventOrValue?.target?.value;
setPerPageValue(nextValue, { resetPage: true });
}, [setPerPageValue]);
const handlePageChange = useCallback((nextPage) => {
if (perPageIsAll) return;
const numericPage = Number(nextPage);
if (!Number.isFinite(numericPage) || numericPage < 1) {
return;
}
const perPageNumber = resolvePerPageNumber();
if (!perPageNumber) {
return;
}
const maxPages = Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
if (numericPage > maxPages) {
return;
}
setPage(numericPage);
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
const totalPages = useMemo(() => {
if (perPageIsAll) {
return 1;
}
const perPageNumber = resolvePerPageNumber();
if (!perPageNumber) {
return 1;
}
return Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
const rangeStart = useMemo(() => {
if (!totalItems) {
return 0;
}
if (perPageIsAll) {
return 1;
}
const perPageNumber = resolvePerPageNumber();
return (page - 1) * perPageNumber + 1;
}, [totalItems, perPageIsAll, resolvePerPageNumber, page]);
const rangeEnd = useMemo(() => {
if (!totalItems) {
return 0;
}
if (perPageIsAll) {
return totalItems;
}
const perPageNumber = resolvePerPageNumber();
const estimatedEnd = (page - 1) * perPageNumber + (visibleItems || perPageNumber);
return Math.min(totalItems, estimatedEnd);
}, [totalItems, perPageIsAll, resolvePerPageNumber, page, visibleItems]);
const summaryLabel = useMemo(() => {
if (!totalItems) {
return "Keine Einträge";
}
if (perPageIsAll) {
return `Zeige alle ${totalItems.toLocaleString()} Einträge`;
}
return `Zeige ${rangeStart.toLocaleString()} ${rangeEnd.toLocaleString()} von ${totalItems.toLocaleString()} Einträgen`;
}, [totalItems, perPageIsAll, rangeStart, rangeEnd]);
const value = useMemo(() => ({
page,
perPage,
perPageOptions,
perPageIsAll,
totalItems,
visibleItems,
totalPages,
rangeStart,
rangeEnd,
summaryLabel,
setPage,
setPerPage: setPerPageValue,
setTotals: updateTotals,
setTotalItems,
setVisibleItems,
handlePageChange,
handlePerPageChange,
validPerPageValues,
resetPagination
}), [
page,
perPage,
perPageOptions,
perPageIsAll,
totalItems,
visibleItems,
totalPages,
rangeStart,
rangeEnd,
summaryLabel,
updateTotals,
setPerPageValue,
handlePageChange,
handlePerPageChange,
validPerPageValues,
resetPagination
]);
return (
<PageContext.Provider value={value}>
{children}
</PageContext.Provider>
);
}
export function usePage() {
const context = useContext(PageContext);
if (!context) {
throw new Error("usePage must be used within a PageProvider");
}
return context;
}
export function PaginationControls({ disabled = false }) {
const {
summaryLabel,
perPageIsAll,
page,
totalPages,
handlePageChange
} = usePage();
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<span>{summaryLabel}</span>
{!perPageIsAll && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={disabled || page <= 1}
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
<path d="M11.03 3.97a.75.75 0 0 1 0 1.06l-6.22 6.22H21a.75.75 0 0 1 0 1.5H4.81l6.22 6.22a.75.75 0 1 1-1.06 1.06l-7.5-7.5a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 0 1 1.06 0Z" />
</svg>
</button>
<p className="text-slate-600">
Seite <strong className="text-slate-800">{page}</strong> /&nbsp;
<strong className="text-slate-800">{totalPages}</strong>
</p>
<button
onClick={() => handlePageChange(page + 1)}
disabled={disabled || page >= totalPages}
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
<path d="M12.97 3.97a.75.75 0 0 1 1.06 0l7.5 7.5a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 1 1-1.06-1.06l6.22-6.22H3a.75.75 0 0 1 0-1.5h16.19l-6.22-6.22a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
)}
</div>
);
}
export const PAGE_DEFAULTS = {
perPage: DEFAULT_PER_PAGE,
options: DEFAULT_PER_PAGE_OPTIONS
};
export default PageProvider;
+121
View File
@@ -0,0 +1,121 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
const ToastContext = createContext(null);
const VARIANT_STYLES = {
success: {
container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
accent: "bg-mossGreen-400"
},
warning: {
container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
accent: "bg-warmAmberGlow-400"
},
error: {
container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
accent: "bg-sunsetCoral-400"
},
info: {
container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
accent: "bg-arcticBlue-400"
}
};
const DEFAULT_DURATION = 6000;
export default function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const timeoutsRef = useRef(new Map());
const dismissToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const handle = timeoutsRef.current.get(id);
if (handle && typeof window !== "undefined") {
window.clearTimeout(handle);
}
timeoutsRef.current.delete(id);
}, []);
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
setToasts((prev) => {
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
return [
...withoutExisting,
{
id: toastId,
title,
description,
variant: normalizedVariant
}
];
});
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
const timeoutHandle = window.setTimeout(() => {
dismissToast(toastId);
}, duration);
const existingHandle = timeoutsRef.current.get(toastId);
if (existingHandle) {
window.clearTimeout(existingHandle);
}
timeoutsRef.current.set(toastId, timeoutHandle);
}
return toastId;
}, [dismissToast]);
useEffect(() => {
return () => {
if (typeof window === "undefined") return;
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
timeoutsRef.current.clear();
};
}, []);
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
return (
<ToastContext.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
{toasts.map((toast) => {
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
return (
<div
key={toast.id}
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
>
<div className="flex items-start gap-3">
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
<div className="flex-1">
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
</div>
<button
type="button"
onClick={() => dismissToast(toast.id)}
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
>
Schließen
</button>
</div>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
import PropTypes from "prop-types";
export const MaterialTailwind = React.createContext(null);
MaterialTailwind.displayName = "MaterialTailwindContext";
export function reducer(state, action) {
switch (action.type) {
case "OPEN_SIDENAV": {
return { ...state, openSidenav: action.value };
}
case "SIDENAV_TYPE": {
return { ...state, sidenavType: action.value };
}
case "SIDENAV_COLOR": {
return { ...state, sidenavColor: action.value };
}
case "TRANSPARENT_NAVBAR": {
return { ...state, transparentNavbar: action.value };
}
case "FIXED_NAVBAR": {
return { ...state, fixedNavbar: action.value };
}
case "OPEN_CONFIGURATOR": {
return { ...state, openConfigurator: action.value };
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
}
export function MaterialTailwindControllerProvider({ children }) {
const initialState = {
openSidenav: false,
sidenavColor: "gray",
sidenavType: "white",
transparentNavbar: false,
fixedNavbar: true,
openConfigurator: false,
};
const [controller, dispatch] = React.useReducer(reducer, initialState);
const value = React.useMemo(
() => [controller, dispatch],
[controller, dispatch]
);
return (
<MaterialTailwind.Provider value={value}>
{children}
</MaterialTailwind.Provider>
);
}
export function useMaterialTailwindController() {
const context = React.useContext(MaterialTailwind);
if (!context) {
throw new Error(
"useMaterialTailwindController should be used inside the MaterialTailwindControllerProvider."
);
}
return context;
}
MaterialTailwindControllerProvider.displayName = "/src/context/index.jsx";
MaterialTailwindControllerProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const setOpenSidenav = (dispatch, value) =>
dispatch({ type: "OPEN_SIDENAV", value });
export const setSidenavType = (dispatch, value) =>
dispatch({ type: "SIDENAV_TYPE", value });
export const setSidenavColor = (dispatch, value) =>
dispatch({ type: "SIDENAV_COLOR", value });
export const setTransparentNavbar = (dispatch, value) =>
dispatch({ type: "TRANSPARENT_NAVBAR", value });
export const setFixedNavbar = (dispatch, value) =>
dispatch({ type: "FIXED_NAVBAR", value });
export const setOpenConfigurator = (dispatch, value) =>
dispatch({ type: "OPEN_CONFIGURATOR", value });
+61
View File
@@ -0,0 +1,61 @@
export const chartsConfig = {
chart: {
toolbar: {
show: false,
},
},
title: {
show: "",
},
dataLabels: {
enabled: false,
},
xaxis: {
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
labels: {
style: {
colors: "#37474f",
fontSize: "13px",
fontFamily: "inherit",
fontWeight: 300,
},
},
},
yaxis: {
labels: {
style: {
colors: "#37474f",
fontSize: "13px",
fontFamily: "inherit",
fontWeight: 300,
},
},
},
grid: {
show: true,
borderColor: "#dddddd",
strokeDashArray: 5,
xaxis: {
lines: {
show: true,
},
},
padding: {
top: 5,
right: 20,
},
},
fill: {
opacity: 0.8,
},
tooltip: {
theme: "dark",
},
};
export default chartsConfig;
+1
View File
@@ -0,0 +1 @@
export * from "@/configs/charts-config";
+52
View File
@@ -0,0 +1,52 @@
export const authorsTableData = [
{
img: "/img/team-2.jpeg",
name: "John Michael",
email: "john@creative-tim.com",
job: ["Manager", "Organization"],
online: true,
date: "23/04/18",
},
{
img: "/img/team-1.jpeg",
name: "Alexa Liras",
email: "alexa@creative-tim.com",
job: ["Programator", "Developer"],
online: false,
date: "11/01/19",
},
{
img: "/img/team-4.jpeg",
name: "Laurent Perrier",
email: "laurent@creative-tim.com",
job: ["Executive", "Projects"],
online: true,
date: "19/09/17",
},
{
img: "/img/team-3.jpeg",
name: "Michael Levi",
email: "michael@creative-tim.com",
job: ["Programator", "Developer"],
online: true,
date: "24/12/08",
},
{
img: "/img/bruce-mars.jpeg",
name: "Bruce Mars",
email: "bruce@creative-tim.com",
job: ["Manager", "Executive"],
online: false,
date: "04/10/21",
},
{
img: "/img/team-2.jpeg",
name: "Alexander",
email: "alexander@creative-tim.com",
job: ["Programator", "Developer"],
online: false,
date: "14/09/20",
},
];
export default authorsTableData;
+29
View File
@@ -0,0 +1,29 @@
export const conversationsData = [
{
img: "/img/team-1.jpeg",
name: "Sophie B.",
message: "Hi! I need more information...",
},
{
img: "/img/team-2.jpeg",
name: "Alexander",
message: "Awesome work, can you...",
},
{
img: "/img/team-3.jpeg",
name: "Ivanna",
message: "About files I can...",
},
{
img: "/img/team-4.jpeg",
name: "Peterson",
message: "Have a great afternoon...",
},
{
img: "/img/bruce-mars.jpeg",
name: "Bruce Mars",
message: "Hi! I need more information...",
},
];
export default conversationsData;
+8
View File
@@ -0,0 +1,8 @@
export * from "@/data/statistics-cards-data";
export * from "@/data/statistics-charts-data";
export * from "@/data/projects-table-data";
export * from "@/data/orders-overview-data";
export * from "@/data/platform-settings-data";
export * from "@/data/conversations-data";
export * from "@/data/projects-data";
export * from "@/data/authors-table-data";
+49
View File
@@ -0,0 +1,49 @@
import {
BellIcon,
PlusCircleIcon,
ShoppingCartIcon,
CreditCardIcon,
LockOpenIcon,
BanknotesIcon,
} from "@heroicons/react/24/solid";
export const ordersOverviewData = [
{
icon: BellIcon,
color: "text-blue-gray-300",
title: "$2400, Design changes",
description: "22 DEC 7:20 PM",
},
{
icon: PlusCircleIcon,
color: "text-blue-gray-300",
title: "New order #1832412",
description: "21 DEC 11 PM",
},
{
icon: ShoppingCartIcon,
color: "text-blue-gray-300",
title: "Server payments for April",
description: "21 DEC 9:34 PM",
},
{
icon: CreditCardIcon,
color: "text-blue-gray-300",
title: "New card added for order #4395133",
description: "20 DEC 2:20 AM",
},
{
icon: LockOpenIcon,
color: "text-blue-gray-300",
title: "Unlock packages for development",
description: "18 DEC 4:54 AM",
},
{
icon: BanknotesIcon,
color: "text-blue-gray-300",
title: "New order #9583120",
description: "17 DEC",
},
];
export default ordersOverviewData;
@@ -0,0 +1,38 @@
export const platformSettingsData = [
{
title: "account",
options: [
{
checked: true,
label: "Email me when someone follows me",
},
{
checked: false,
label: "Email me when someone answers on my post",
},
{
checked: true,
label: "Email me when someone mentions me",
},
],
},
{
title: "application",
options: [
{
checked: false,
label: "New launches and projects",
},
{
checked: true,
label: "Monthly product updates",
},
{
checked: false,
label: "Subscribe to newsletter",
},
],
},
];
export default platformSettingsData;
+60
View File
@@ -0,0 +1,60 @@
export const projectsData = [
{
img: "/img/home-decor-1.jpeg",
title: "Modern",
tag: "Project #1",
description:
"As Uber works through a huge amount of internal management turmoil.",
route: "/dashboard/profile",
members: [
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
],
},
{
img: "/img/home-decor-2.jpeg",
title: "Scandinavian",
tag: "Project #2",
description:
"Music is something that every person has his or her own specific opinion about.",
route: "/dashboard/profile",
members: [
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
],
},
{
img: "/img/home-decor-3.jpeg",
title: "Minimalist",
tag: "Project #3",
description:
"Different people have different taste, and various types of music.",
route: "/dashboard/profile",
members: [
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
],
},
{
img: "/img/home-decor-4.jpeg",
title: "Gothic",
tag: "Project #4",
description:
"Why would anyone pick blue over pink? Pink is obviously a better color.",
route: "/dashboard/profile",
members: [
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
],
},
];
export default projectsData;
+65
View File
@@ -0,0 +1,65 @@
export const projectsTableData = [
{
img: "/img/logo-xd.svg",
name: "Material XD Version",
members: [
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
],
budget: "$14,000",
completion: 60,
},
{
img: "/img/logo-atlassian.svg",
name: "Add Progress Track",
members: [
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
],
budget: "$3,000",
completion: 10,
},
{
img: "/img/logo-slack.svg",
name: "Fix Platform Errors",
members: [
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
],
budget: "Not set",
completion: 100,
},
{
img: "/img/logo-spotify.svg",
name: "Launch our Mobile App",
members: [
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
],
budget: "$20,500",
completion: 100,
},
{
img: "/img/logo-jira.svg",
name: "Add the New Pricing Page",
members: [{ img: "/img/team-4.jpeg", name: "Alexander Smith" }],
budget: "$500",
completion: 25,
},
{
img: "/img/logo-invision.svg",
name: "Redesign New Online Shop",
members: [
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
],
budget: "$2,000",
completion: 40,
},
];
export default projectsTableData;
@@ -0,0 +1,55 @@
import {
BanknotesIcon,
UserPlusIcon,
UsersIcon,
ChartBarIcon,
} from "@heroicons/react/24/solid";
export const statisticsCardsData = [
{
color: "gray",
icon: BanknotesIcon,
title: "Today's Money",
value: "$53k",
footer: {
color: "text-green-500",
value: "+55%",
label: "than last week",
},
},
{
color: "gray",
icon: UsersIcon,
title: "Today's Users",
value: "2,300",
footer: {
color: "text-green-500",
value: "+3%",
label: "than last month",
},
},
{
color: "gray",
icon: UserPlusIcon,
title: "New Clients",
value: "3,462",
footer: {
color: "text-red-500",
value: "-2%",
label: "than yesterday",
},
},
{
color: "gray",
icon: ChartBarIcon,
title: "Sales",
value: "$103,430",
footer: {
color: "text-green-500",
value: "+5%",
label: "than yesterday",
},
},
];
export default statisticsCardsData;
+131
View File
@@ -0,0 +1,131 @@
import { chartsConfig } from "@/configs";
const websiteViewsChart = {
type: "bar",
height: 220,
series: [
{
name: "Views",
data: [50, 20, 10, 22, 50, 10, 40],
},
],
options: {
...chartsConfig,
colors: "#388e3c",
plotOptions: {
bar: {
columnWidth: "16%",
borderRadius: 5,
},
},
xaxis: {
...chartsConfig.xaxis,
categories: ["M", "T", "W", "T", "F", "S", "S"],
},
},
};
const dailySalesChart = {
type: "line",
height: 220,
series: [
{
name: "Sales",
data: [50, 40, 300, 320, 500, 350, 200, 230, 500],
},
],
options: {
...chartsConfig,
colors: ["#0288d1"],
stroke: {
lineCap: "round",
},
markers: {
size: 5,
},
xaxis: {
...chartsConfig.xaxis,
categories: [
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
},
},
};
const completedTaskChart = {
type: "line",
height: 220,
series: [
{
name: "Sales",
data: [50, 40, 300, 320, 500, 350, 200, 230, 500],
},
],
options: {
...chartsConfig,
colors: ["#388e3c"],
stroke: {
lineCap: "round",
},
markers: {
size: 5,
},
xaxis: {
...chartsConfig.xaxis,
categories: [
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
},
},
};
const completedTasksChart = {
...completedTaskChart,
series: [
{
name: "Tasks",
data: [50, 40, 300, 220, 500, 250, 400, 230, 500],
},
],
};
export const statisticsChartsData = [
{
color: "white",
title: "Website View",
description: "Last Campaign Performance",
footer: "campaign sent 2 days ago",
chart: websiteViewsChart,
},
{
color: "white",
title: "Daily Sales",
description: "15% increase in today sales",
footer: "updated 4 min ago",
chart: dailySalesChart,
},
{
color: "white",
title: "Completed Tasks",
description: "Last Campaign Performance",
footer: "just updated",
chart: completedTasksChart,
},
];
export default statisticsChartsData;
+52
View File
@@ -0,0 +1,52 @@
import { Routes, Route } from "react-router-dom";
import {
ChartPieIcon,
UserIcon,
UserPlusIcon,
ArrowRightOnRectangleIcon,
} from "@heroicons/react/24/solid";
import { Navbar, Footer } from "@/widgets/layout";
import routes from "@/routes";
export function Auth() {
const navbarRoutes = [
{
name: "dashboard",
path: "/dashboard/home",
icon: ChartPieIcon,
},
{
name: "profile",
path: "/dashboard/home",
icon: UserIcon,
},
{
name: "sign up",
path: "/auth/sign-up",
icon: UserPlusIcon,
},
{
name: "sign in",
path: "/auth/sign-in",
icon: ArrowRightOnRectangleIcon,
},
];
return (
<div className="relative min-h-screen w-full">
<Routes>
{routes.map(
({ layout, pages }) =>
layout === "auth" &&
pages.map(({ path, element }) => (
<Route exact path={path} element={element} />
))
)}
</Routes>
</div>
);
}
Auth.displayName = "/src/layout/Auth.jsx";
export default Auth;
+56
View File
@@ -0,0 +1,56 @@
import { Routes, Route } from "react-router-dom";
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
import { IconButton } from "@material-tailwind/react";
import {
Sidenav,
DashboardNavbar,
Configurator,
Footer,
} from "@/widgets/layout";
import routes from "@/routes";
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
export function Dashboard() {
const [controller, dispatch] = useMaterialTailwindController();
const { sidenavType } = controller;
return (
<div className="min-h-screen bg-blue-gray-50/50">
<Sidenav
routes={routes}
brandImg={
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
}
/>
<div className="p-4 xl:ml-80">
<DashboardNavbar />
<Configurator />
<IconButton
size="lg"
color="white"
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
ripple={false}
onClick={() => setOpenConfigurator(dispatch, true)}
>
<Cog6ToothIcon className="h-5 w-5" />
</IconButton>
<Routes>
{routes.map(
({ layout, pages }) =>
layout === "dashboard" &&
pages.map(({ path, element }) => (
<Route exact path={path} element={element} />
))
)}
</Routes>
<div className="text-blue-gray-600">
<Footer />
</div>
</div>
</div>
);
}
Dashboard.displayName = "/src/layout/dashboard.jsx";
export default Dashboard;
+2
View File
@@ -0,0 +1,2 @@
export * from "@/layouts/dashboard";
export * from "@/layouts/auth";
+31 -2
View File
@@ -1,10 +1,39 @@
/**
=========================================================
* Material Tailwind Dashboard React - v2.1.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
* Coded by Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App";
import './index.css'; // Tailwind CSS import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "@material-tailwind/react";
import { MaterialTailwindControllerProvider } from "@/components";
import "./tailwind.css";
import ToastProvider from "@/components/ToastProvider.jsx";
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
import PageProvider from "@/components/PageProvider.jsx";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter>
<MaintenanceProvider>
<ToastProvider>
<PageProvider>
<ThemeProvider>
<MaterialTailwindControllerProvider>
<App /> <App />
</MaterialTailwindControllerProvider>
</ThemeProvider>
</PageProvider>
</ToastProvider>
</MaintenanceProvider>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
+2
View File
@@ -0,0 +1,2 @@
export * from "@/pages/auth/sign-in";
export * from "@/pages/auth/sign-up";
+126
View File
@@ -0,0 +1,126 @@
import {
Card,
Input,
Checkbox,
Button,
Typography,
} from "@material-tailwind/react";
import { Link } from "react-router-dom";
export function SignIn() {
return (
<section className="m-8 flex gap-4">
<div className="w-full lg:w-3/5 mt-24">
<div className="text-center">
<Typography variant="h2" className="font-bold mb-4">Sign In</Typography>
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to Sign In.</Typography>
</div>
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
<div className="mb-1 flex flex-col gap-6">
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
Your email
</Typography>
<Input
size="lg"
placeholder="name@mail.com"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
/>
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
Password
</Typography>
<Input
type="password"
size="lg"
placeholder="********"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
/>
</div>
<Checkbox
label={
<Typography
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
>
I agree the&nbsp;
<a
href="#"
className="font-normal text-black transition-colors hover:text-gray-900 underline"
>
Terms and Conditions
</a>
</Typography>
}
containerProps={{ className: "-ml-2.5" }}
/>
<Button className="mt-6" fullWidth>
Sign In
</Button>
<div className="flex items-center justify-between gap-2 mt-6">
<Checkbox
label={
<Typography
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
>
Subscribe me to newsletter
</Typography>
}
containerProps={{ className: "-ml-2.5" }}
/>
<Typography variant="small" className="font-medium text-gray-900">
<a href="#">
Forgot Password
</a>
</Typography>
</div>
<div className="space-y-4 mt-8">
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_1156_824)">
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
</g>
<defs>
<clipPath id="clip0_1156_824">
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
</clipPath>
</defs>
</svg>
<span>Sign in With Google</span>
</Button>
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
<span>Sign in With Twitter</span>
</Button>
</div>
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
Not registered?
<Link to="/auth/sign-up" className="text-gray-900 ml-1">Create account</Link>
</Typography>
</form>
</div>
<div className="w-2/5 h-full hidden lg:block">
<img
src="/img/pattern.png"
className="h-full w-full object-cover rounded-3xl"
/>
</div>
</section>
);
}
export default SignIn;
+94
View File
@@ -0,0 +1,94 @@
import {
Card,
Input,
Checkbox,
Button,
Typography,
} from "@material-tailwind/react";
import { Link } from "react-router-dom";
export function SignUp() {
return (
<section className="m-8 flex">
<div className="w-2/5 h-full hidden lg:block">
<img
src="/img/pattern.png"
className="h-full w-full object-cover rounded-3xl"
/>
</div>
<div className="w-full lg:w-3/5 flex flex-col items-center justify-center">
<div className="text-center">
<Typography variant="h2" className="font-bold mb-4">Join Us Today</Typography>
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to register.</Typography>
</div>
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
<div className="mb-1 flex flex-col gap-6">
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
Your email
</Typography>
<Input
size="lg"
placeholder="name@mail.com"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
/>
</div>
<Checkbox
label={
<Typography
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
>
I agree the&nbsp;
<a
href="#"
className="font-normal text-black transition-colors hover:text-gray-900 underline"
>
Terms and Conditions
</a>
</Typography>
}
containerProps={{ className: "-ml-2.5" }}
/>
<Button className="mt-6" fullWidth>
Register Now
</Button>
<div className="space-y-4 mt-8">
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_1156_824)">
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
</g>
<defs>
<clipPath id="clip0_1156_824">
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
</clipPath>
</defs>
</svg>
<span>Sign in With Google</span>
</Button>
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
<span>Sign in With Twitter</span>
</Button>
</div>
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
Already have an account?
<Link to="/auth/sign-in" className="text-gray-900 ml-1">Sign in</Link>
</Typography>
</form>
</div>
</section>
);
}
export default SignUp;
+258
View File
@@ -0,0 +1,258 @@
import React from "react";
import {
Typography,
Card,
CardHeader,
CardBody,
IconButton,
Menu,
MenuHandler,
MenuList,
MenuItem,
Avatar,
Tooltip,
Progress,
} from "@material-tailwind/react";
import {
EllipsisVerticalIcon,
ArrowUpIcon,
} from "@heroicons/react/24/outline";
import { StatisticsCard } from "@/widgets/cards";
import { StatisticsChart } from "@/widgets/charts";
import {
statisticsCardsData,
statisticsChartsData,
projectsTableData,
ordersOverviewData,
} from "@/data";
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/24/solid";
export function Home() {
return (
<div className="mt-12">
<div className="mb-12 grid gap-y-10 gap-x-6 md:grid-cols-2 xl:grid-cols-4">
{statisticsCardsData.map(({ icon, title, footer, ...rest }) => (
<StatisticsCard
key={title}
{...rest}
title={title}
icon={React.createElement(icon, {
className: "w-6 h-6 text-white",
})}
footer={
<Typography className="font-normal text-blue-gray-600">
<strong className={footer.color}>{footer.value}</strong>
&nbsp;{footer.label}
</Typography>
}
/>
))}
</div>
<div className="mb-6 grid grid-cols-1 gap-y-12 gap-x-6 md:grid-cols-2 xl:grid-cols-3">
{statisticsChartsData.map((props) => (
<StatisticsChart
key={props.title}
{...props}
footer={
<Typography
variant="small"
className="flex items-center font-normal text-blue-gray-600"
>
<ClockIcon strokeWidth={2} className="h-4 w-4 text-blue-gray-400" />
&nbsp;{props.footer}
</Typography>
}
/>
))}
</div>
<div className="mb-4 grid grid-cols-1 gap-6 xl:grid-cols-3">
<Card className="overflow-hidden xl:col-span-2 border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
color="transparent"
className="m-0 flex items-center justify-between p-6"
>
<div>
<Typography variant="h6" color="blue-gray" className="mb-1">
Projects
</Typography>
<Typography
variant="small"
className="flex items-center gap-1 font-normal text-blue-gray-600"
>
<CheckCircleIcon strokeWidth={3} className="h-4 w-4 text-blue-gray-200" />
<strong>30 done</strong> this month
</Typography>
</div>
<Menu placement="left-start">
<MenuHandler>
<IconButton size="sm" variant="text" color="blue-gray">
<EllipsisVerticalIcon
strokeWidth={3}
fill="currenColor"
className="h-6 w-6"
/>
</IconButton>
</MenuHandler>
<MenuList>
<MenuItem>Action</MenuItem>
<MenuItem>Another Action</MenuItem>
<MenuItem>Something else here</MenuItem>
</MenuList>
</Menu>
</CardHeader>
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
<table className="w-full min-w-[640px] table-auto">
<thead>
<tr>
{["companies", "members", "budget", "completion"].map(
(el) => (
<th
key={el}
className="border-b border-blue-gray-50 py-3 px-6 text-left"
>
<Typography
variant="small"
className="text-[11px] font-medium uppercase text-blue-gray-400"
>
{el}
</Typography>
</th>
)
)}
</tr>
</thead>
<tbody>
{projectsTableData.map(
({ img, name, members, budget, completion }, key) => {
const className = `py-3 px-5 ${
key === projectsTableData.length - 1
? ""
: "border-b border-blue-gray-50"
}`;
return (
<tr key={name}>
<td className={className}>
<div className="flex items-center gap-4">
<Avatar src={img} alt={name} size="sm" />
<Typography
variant="small"
color="blue-gray"
className="font-bold"
>
{name}
</Typography>
</div>
</td>
<td className={className}>
{members.map(({ img, name }, key) => (
<Tooltip key={name} content={name}>
<Avatar
src={img}
alt={name}
size="xs"
variant="circular"
className={`cursor-pointer border-2 border-white ${
key === 0 ? "" : "-ml-2.5"
}`}
/>
</Tooltip>
))}
</td>
<td className={className}>
<Typography
variant="small"
className="text-xs font-medium text-blue-gray-600"
>
{budget}
</Typography>
</td>
<td className={className}>
<div className="w-10/12">
<Typography
variant="small"
className="mb-1 block text-xs font-medium text-blue-gray-600"
>
{completion}%
</Typography>
<Progress
value={completion}
variant="gradient"
color={completion === 100 ? "green" : "blue"}
className="h-1"
/>
</div>
</td>
</tr>
);
}
)}
</tbody>
</table>
</CardBody>
</Card>
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
color="transparent"
className="m-0 p-6"
>
<Typography variant="h6" color="blue-gray" className="mb-2">
Orders Overview
</Typography>
<Typography
variant="small"
className="flex items-center gap-1 font-normal text-blue-gray-600"
>
<ArrowUpIcon
strokeWidth={3}
className="h-3.5 w-3.5 text-green-500"
/>
<strong>24%</strong> this month
</Typography>
</CardHeader>
<CardBody className="pt-0">
{ordersOverviewData.map(
({ icon, color, title, description }, key) => (
<div key={title} className="flex items-start gap-4 py-3">
<div
className={`relative p-1 after:absolute after:-bottom-6 after:left-2/4 after:w-0.5 after:-translate-x-2/4 after:bg-blue-gray-50 after:content-[''] ${
key === ordersOverviewData.length - 1
? "after:h-0"
: "after:h-4/6"
}`}
>
{React.createElement(icon, {
className: `!w-5 !h-5 ${color}`,
})}
</div>
<div>
<Typography
variant="small"
color="blue-gray"
className="block font-medium"
>
{title}
</Typography>
<Typography
as="span"
variant="small"
className="text-xs font-medium text-blue-gray-500"
>
{description}
</Typography>
</div>
</div>
)
)}
</CardBody>
</Card>
</div>
</div>
);
}
export default Home;
+3
View File
@@ -0,0 +1,3 @@
export * from "@/pages/dashboard/stacks";
export * from "@/pages/dashboard/maintenance";
export * from "@/pages/dashboard/logs";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
import React from "react";
import {
Typography,
Alert,
Card,
CardHeader,
CardBody,
} from "@material-tailwind/react";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
export function Notifications() {
const [showAlerts, setShowAlerts] = React.useState({
blue: true,
green: true,
orange: true,
red: true,
});
const [showAlertsWithIcon, setShowAlertsWithIcon] = React.useState({
blue: true,
green: true,
orange: true,
red: true,
});
const alerts = ["gray", "green", "orange", "red"];
return (
<div className="mx-auto my-20 flex max-w-screen-lg flex-col gap-8">
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4"
>
<Typography variant="h5" color="blue-gray">
Alerts
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{alerts.map((color) => (
<Alert
key={color}
open={showAlerts[color]}
color={color}
onClose={() => setShowAlerts((current) => ({ ...current, [color]: false }))}
>
A simple {color} alert with an <a href="#">example link</a>. Give
it a click if you like.
</Alert>
))}
</CardBody>
</Card>
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4"
>
<Typography variant="h5" color="blue-gray">
Alerts with Icon
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{alerts.map((color) => (
<Alert
key={color}
open={showAlertsWithIcon[color]}
color={color}
icon={
<InformationCircleIcon strokeWidth={2} className="h-6 w-6" />
}
onClose={() => setShowAlertsWithIcon((current) => ({
...current,
[color]: false,
}))}
>
A simple {color} alert with an <a href="#">example link</a>. Give
it a click if you like.
</Alert>
))}
</CardBody>
</Card>
</div>
);
}
export default Notifications;
+221
View File
@@ -0,0 +1,221 @@
import {
Card,
CardBody,
CardHeader,
CardFooter,
Avatar,
Typography,
Tabs,
TabsHeader,
Tab,
Switch,
Tooltip,
Button,
} from "@material-tailwind/react";
import {
HomeIcon,
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
PencilIcon,
} from "@heroicons/react/24/solid";
import { Link } from "react-router-dom";
import { ProfileInfoCard, MessageCard } from "@/widgets/cards";
import { platformSettingsData, conversationsData, projectsData } from "@/data";
export function Profile() {
return (
<>
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-center">
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
</div>
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
<CardBody className="p-4">
<div className="mb-10 flex items-center justify-between flex-wrap gap-6">
<div className="flex items-center gap-6">
<Avatar
src="/img/bruce-mars.jpeg"
alt="bruce-mars"
size="xl"
variant="rounded"
className="rounded-lg shadow-lg shadow-blue-gray-500/40"
/>
<div>
<Typography variant="h5" color="blue-gray" className="mb-1">
Richard Davis
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-600"
>
CEO / Co-Founder
</Typography>
</div>
</div>
<div className="w-96">
<Tabs value="app">
<TabsHeader>
<Tab value="app">
<HomeIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
App
</Tab>
<Tab value="message">
<ChatBubbleLeftEllipsisIcon className="-mt-0.5 mr-2 inline-block h-5 w-5" />
Message
</Tab>
<Tab value="settings">
<Cog6ToothIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
Settings
</Tab>
</TabsHeader>
</Tabs>
</div>
</div>
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
<div>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
</Typography>
<div className="flex flex-col gap-12">
{platformSettingsData.map(({ title, options }) => (
<div key={title}>
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
{title}
</Typography>
<div className="flex flex-col gap-6">
{options.map(({ checked, label }) => (
<Switch
key={label}
id={label}
label={label}
defaultChecked={checked}
labelProps={{
className: "text-sm font-normal text-blue-gray-500",
}}
/>
))}
</div>
</div>
))}
</div>
</div>
<ProfileInfoCard
title="Profile Information"
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
details={{
"first name": "Alec M. Thompson",
mobile: "(44) 123 1234 123",
email: "alecthompson@mail.com",
location: "USA",
social: (
<div className="flex items-center gap-4">
<i className="fa-brands fa-facebook text-blue-700" />
<i className="fa-brands fa-twitter text-blue-400" />
<i className="fa-brands fa-instagram text-purple-500" />
</div>
),
}}
action={
<Tooltip content="Edit Profile">
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
</Tooltip>
}
/>
<div>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
</Typography>
<ul className="flex flex-col gap-6">
{conversationsData.map((props) => (
<MessageCard
key={props.name}
{...props}
action={
<Button variant="text" size="sm">
reply
</Button>
}
/>
))}
</ul>
</div>
</div>
<div className="px-4 pb-4">
<Typography variant="h6" color="blue-gray" className="mb-2">
Projects
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
Architects design houses
</Typography>
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
{projectsData.map(
({ img, title, description, tag, route, members }) => (
<Card key={title} color="transparent" shadow={false}>
<CardHeader
floated={false}
color="gray"
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
>
<img
src={img}
alt={title}
className="h-full w-full object-cover"
/>
</CardHeader>
<CardBody className="py-0 px-1">
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{tag}
</Typography>
<Typography
variant="h5"
color="blue-gray"
className="mt-1 mb-2"
>
{title}
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{description}
</Typography>
</CardBody>
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
<Link to={route}>
<Button variant="outlined" size="sm">
view project
</Button>
</Link>
<div>
{members.map(({ img, name }, key) => (
<Tooltip key={name} content={name}>
<Avatar
src={img}
alt={name}
size="xs"
variant="circular"
className={`cursor-pointer border-2 border-white ${
key === 0 ? "" : "-ml-2.5"
}`}
/>
</Tooltip>
))}
</div>
</CardFooter>
</Card>
)
)}
</div>
</div>
</CardBody>
</Card>
</>
);
}
export default Profile;
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import {
Square3Stack3DIcon,
WrenchScrewdriverIcon,
ListBulletIcon
} from "@heroicons/react/24/solid";
import { Stacks, Maintenance, Logs } from "@/pages/dashboard";
import { SignIn, SignUp } from "@/pages/auth";
const icon = {
className: "w-5 h-5 text-inherit",
};
export const routes = [
{
layout: "dashboard",
pages: [
{
icon: <Square3Stack3DIcon {...icon} />,
name: "stacks",
path: "/stacks",
element: <Stacks />,
},
{
icon: <WrenchScrewdriverIcon {...icon} />,
name: "wartung",
path: "/maintenance",
element: <Maintenance />,
},
{
icon: <ListBulletIcon {...icon} />,
name: "logs",
path: "/logs",
element: <Logs />,
},
],
},
// {
// title: "auth pages",
// layout: "auth",
// pages: [
// {
// icon: <ServerStackIcon {...icon} />,
// name: "sign in",
// path: "/sign-in",
// element: <SignIn />,
// },
// {
// icon: <RectangleStackIcon {...icon} />,
// name: "sign up",
// path: "/sign-up",
// element: <SignUp />,
// },
// ],
// },
];
export default routes;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+3
View File
@@ -0,0 +1,3 @@
export * from "@/widgets/cards/statistics-card";
export * from "@/widgets/cards/profile-info-card";
export * from "@/widgets/cards/message-card";
@@ -0,0 +1,45 @@
import PropTypes from "prop-types";
import { Avatar, Typography } from "@material-tailwind/react";
export function MessageCard({ img, name, message, action }) {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Avatar
src={img}
alt={name}
variant="rounded"
className="shadow-lg shadow-blue-gray-500/25"
/>
<div>
<Typography
variant="small"
color="blue-gray"
className="mb-1 font-semibold"
>
{name}
</Typography>
<Typography className="text-xs font-normal text-blue-gray-400">
{message}
</Typography>
</div>
</div>
{action}
</div>
);
}
MessageCard.defaultProps = {
action: null,
};
MessageCard.propTypes = {
img: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
message: PropTypes.node.isRequired,
action: PropTypes.node,
};
MessageCard.displayName = "/src/widgets/cards/message-card.jsx";
export default MessageCard;
@@ -0,0 +1,79 @@
import PropTypes from "prop-types";
import {
Card,
CardHeader,
CardBody,
Typography,
} from "@material-tailwind/react";
export function ProfileInfoCard({ title, description, details, action }) {
return (
<Card color="transparent" shadow={false}>
<CardHeader
color="transparent"
shadow={false}
floated={false}
className="mx-0 mt-0 mb-4 flex items-center justify-between gap-4"
>
<Typography variant="h6" color="blue-gray">
{title}
</Typography>
{action}
</CardHeader>
<CardBody className="p-0">
{description && (
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{description}
</Typography>
)}
{description && details ? (
<hr className="my-8 border-blue-gray-50" />
) : null}
{details && (
<ul className="flex flex-col gap-4 p-0">
{Object.keys(details).map((el, key) => (
<li key={key} className="flex items-center gap-4">
<Typography
variant="small"
color="blue-gray"
className="font-semibold capitalize"
>
{el}:
</Typography>
{typeof details[el] === "string" ? (
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{details[el]}
</Typography>
) : (
details[el]
)}
</li>
))}
</ul>
)}
</CardBody>
</Card>
);
}
ProfileInfoCard.defaultProps = {
action: null,
description: null,
details: {},
};
ProfileInfoCard.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.node,
details: PropTypes.object,
};
ProfileInfoCard.displayName = "/src/widgets/cards/profile-info-card.jsx";
export default ProfileInfoCard;
@@ -0,0 +1,75 @@
import {
Card,
CardHeader,
CardBody,
CardFooter,
Typography,
} from "@material-tailwind/react";
import PropTypes from "prop-types";
export function StatisticsCard({ color, icon, title, value, footer }) {
return (
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader
variant="gradient"
color={color}
floated={false}
shadow={false}
className="absolute grid h-12 w-12 place-items-center"
>
{icon}
</CardHeader>
<CardBody className="p-4 text-right">
<Typography variant="small" className="font-normal text-blue-gray-600">
{title}
</Typography>
<Typography variant="h4" color="blue-gray">
{value}
</Typography>
</CardBody>
{footer && (
<CardFooter className="border-t border-blue-gray-50 p-4">
{footer}
</CardFooter>
)}
</Card>
);
}
StatisticsCard.defaultProps = {
color: "blue",
footer: null,
};
StatisticsCard.propTypes = {
color: PropTypes.oneOf([
"white",
"blue-gray",
"gray",
"brown",
"deep-orange",
"orange",
"amber",
"yellow",
"lime",
"light-green",
"green",
"teal",
"cyan",
"light-blue",
"blue",
"indigo",
"deep-purple",
"purple",
"pink",
"red",
]),
icon: PropTypes.node.isRequired,
title: PropTypes.node.isRequired,
value: PropTypes.node.isRequired,
footer: PropTypes.node,
};
StatisticsCard.displayName = "/src/widgets/cards/statistics-card.jsx";
export default StatisticsCard;
+1
View File
@@ -0,0 +1 @@
export * from "@/widgets/charts/statistics-chart";
@@ -0,0 +1,70 @@
import {
Card,
CardHeader,
CardBody,
CardFooter,
Typography,
} from "@material-tailwind/react";
import PropTypes from "prop-types";
import Chart from "react-apexcharts";
export function StatisticsChart({ color, chart, title, description, footer }) {
return (
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader variant="gradient" color={color} floated={false} shadow={false}>
<Chart {...chart} />
</CardHeader>
<CardBody className="px-6 pt-0">
<Typography variant="h6" color="blue-gray">
{title}
</Typography>
<Typography variant="small" className="font-normal text-blue-gray-600">
{description}
</Typography>
</CardBody>
{footer && (
<CardFooter className="border-t border-blue-gray-50 px-6 py-5">
{footer}
</CardFooter>
)}
</Card>
);
}
StatisticsChart.defaultProps = {
color: "blue",
footer: null,
};
StatisticsChart.propTypes = {
color: PropTypes.oneOf([
"white",
"blue-gray",
"gray",
"brown",
"deep-orange",
"orange",
"amber",
"yellow",
"lime",
"light-green",
"green",
"teal",
"cyan",
"light-blue",
"blue",
"indigo",
"deep-purple",
"purple",
"pink",
"red",
]),
chart: PropTypes.object.isRequired,
title: PropTypes.node.isRequired,
description: PropTypes.node.isRequired,
footer: PropTypes.node,
};
StatisticsChart.displayName = "/src/widgets/charts/statistics-chart.jsx";
export default StatisticsChart;
@@ -0,0 +1,127 @@
import React from "react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
Button,
IconButton,
Switch,
Typography,
Chip,
} from "@material-tailwind/react";
import {
useMaterialTailwindController,
setOpenConfigurator,
setSidenavColor,
setSidenavType,
setFixedNavbar,
} from "@/components";
function formatNumber(number, decPlaces) {
decPlaces = Math.pow(10, decPlaces);
const abbrev = ["K", "M", "B", "T"];
for (let i = abbrev.length - 1; i >= 0; i--) {
var size = Math.pow(10, (i + 1) * 3);
if (size <= number) {
number = Math.round((number * decPlaces) / size) / decPlaces;
if (number == 1000 && i < abbrev.length - 1) {
number = 1;
i++;
}
number += abbrev[i];
break;
}
}
return number;
}
export function Configurator() {
const [controller, dispatch] = useMaterialTailwindController();
const { openConfigurator, sidenavColor, sidenavType, fixedNavbar } =
controller;
const [stars, setStars] = React.useState(0);
const sidenavColors = {
dark: "from-black to-black border-gray-200",
green: "from-green-400 to-green-600",
orange: "from-orange-400 to-orange-600",
red: "from-red-400 to-red-600",
pink: "from-pink-400 to-pink-600",
};
React.useEffect(() => {
const stars = fetch(
"https://api.github.com/repos/creativetimofficial/material-tailwind-dashboard-react"
)
.then((response) => response.json())
.then((data) => setStars(formatNumber(data.stargazers_count, 1)));
}, []);
return (
<aside
className={`fixed top-0 right-0 z-50 h-screen w-96 bg-white px-2.5 shadow-lg transition-transform duration-300 ${
openConfigurator ? "translate-x-0" : "translate-x-96"
}`}
>
<div className="flex items-start justify-between px-6 pt-8 pb-6">
<div>
<Typography variant="h5" color="blue-gray">
Dashboard Configurator
</Typography>
<Typography className="font-normal text-blue-gray-600">
See our dashboard options.
</Typography>
</div>
<IconButton
variant="text"
color="blue-gray"
onClick={() => setOpenConfigurator(dispatch, false)}
>
<XMarkIcon strokeWidth={2.5} className="h-5 w-5" />
</IconButton>
</div>
<div className="py-4 px-6">
<div className="mb-12">
<Typography variant="h6" color="blue-gray">
Sidenav Colors
</Typography>
<div className="mt-3 flex items-center gap-2">
{Object.keys(sidenavColors).map((color) => (
<span
key={color}
className={`h-6 w-6 cursor-pointer rounded-full border bg-gradient-to-br transition-transform hover:scale-105 ${
sidenavColors[color]
} ${
sidenavColor === color ? "border-black" : "border-transparent"
}`}
onClick={() => setSidenavColor(dispatch, color)}
/>
))}
</div>
</div>
<div className="mb-12">
<hr />
<div className="flex items-center justify-between py-5">
<Typography variant="h6" color="blue-gray">
Navbar Fixed
</Typography>
<Switch defaultChecked
id="navbar-fixed"
value={fixedNavbar}
onChange={() => setFixedNavbar(dispatch, !fixedNavbar)}
/>
</div>
</div>
</div>
</aside>
);
}
Configurator.displayName = "/src/widgets/layout/configurator.jsx";
export default Configurator;
@@ -0,0 +1,169 @@
import { useLocation, Link } from "react-router-dom";
import {
Navbar,
Typography,
Button,
IconButton,
Breadcrumbs,
Menu,
MenuHandler,
MenuList,
MenuItem,
Avatar,
} from "@material-tailwind/react";
import {
UserCircleIcon,
Cog6ToothIcon,
BellIcon,
ClockIcon,
CreditCardIcon,
Bars3Icon,
} from "@heroicons/react/24/solid";
import {
useMaterialTailwindController,
setOpenConfigurator,
setOpenSidenav,
} from "@/components";
export function DashboardNavbar() {
const [controller, dispatch] = useMaterialTailwindController();
const { fixedNavbar, openSidenav } = controller;
const { pathname } = useLocation();
const [layout, page] = pathname.split("/").filter((el) => el !== "");
return (
<Navbar
color={fixedNavbar ? "white" : "transparent"}
className={`rounded-xl transition-all ${
fixedNavbar
? "sticky top-4 z-40 py-3 shadow-md shadow-blue-gray-500/5"
: "px-0 py-1"
}`}
fullWidth
blurred={fixedNavbar}
>
<div className="flex justify-between gap-6 flex-row items-center">
<div className="capitalize">
</div>
<div className="flex items-center">
<IconButton
variant="text"
color="blue-gray"
className="grid xl:hidden"
onClick={() => setOpenSidenav(dispatch, !openSidenav)}
>
<Bars3Icon strokeWidth={3} className="h-6 w-6 text-blue-gray-500" />
</IconButton>
{/* <Link to="/auth/sign-in">
<Button
variant="text"
color="blue-gray"
className="hidden items-center gap-1 px-4 xl:flex normal-case"
>
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
Sign In
</Button>
<IconButton
variant="text"
color="blue-gray"
className="grid xl:hidden"
>
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
</IconButton>
</Link>
<Menu>
<MenuHandler>
<IconButton variant="text" color="blue-gray">
<BellIcon className="h-5 w-5 text-blue-gray-500" />
</IconButton>
</MenuHandler>
<MenuList className="w-max border-0">
<MenuItem className="flex items-center gap-3">
<Avatar
src="https://demos.creative-tim.com/material-dashboard/assets/img/team-2.jpg"
alt="item-1"
size="sm"
variant="circular"
/>
<div>
<Typography
variant="small"
color="blue-gray"
className="mb-1 font-normal"
>
<strong>New message</strong> from Laur
</Typography>
<Typography
variant="small"
color="blue-gray"
className="flex items-center gap-1 text-xs font-normal opacity-60"
>
<ClockIcon className="h-3.5 w-3.5" /> 13 minutes ago
</Typography>
</div>
</MenuItem>
<MenuItem className="flex items-center gap-4">
<Avatar
src="https://demos.creative-tim.com/material-dashboard/assets/img/small-logos/logo-spotify.svg"
alt="item-1"
size="sm"
variant="circular"
/>
<div>
<Typography
variant="small"
color="blue-gray"
className="mb-1 font-normal"
>
<strong>New album</strong> by Travis Scott
</Typography>
<Typography
variant="small"
color="blue-gray"
className="flex items-center gap-1 text-xs font-normal opacity-60"
>
<ClockIcon className="h-3.5 w-3.5" /> 1 day ago
</Typography>
</div>
</MenuItem>
<MenuItem className="flex items-center gap-4">
<div className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-tr from-blue-gray-800 to-blue-gray-900">
<CreditCardIcon className="h-4 w-4 text-white" />
</div>
<div>
<Typography
variant="small"
color="blue-gray"
className="mb-1 font-normal"
>
Payment successfully completed
</Typography>
<Typography
variant="small"
color="blue-gray"
className="flex items-center gap-1 text-xs font-normal opacity-60"
>
<ClockIcon className="h-3.5 w-3.5" /> 2 days ago
</Typography>
</div>
</MenuItem>
</MenuList>
</Menu> */}
<IconButton
variant="text"
color="blue-gray"
onClick={() => setOpenConfigurator(dispatch, true)}
>
<Cog6ToothIcon className="h-5 w-5 text-blue-gray-500 " />
</IconButton>
</div>
</div>
</Navbar>
);
}
DashboardNavbar.displayName = "/src/widgets/layout/dashboard-navbar.jsx";
export default DashboardNavbar;
+20
View File
@@ -0,0 +1,20 @@
import PropTypes from "prop-types";
import { Typography } from "@material-tailwind/react";
import { HeartIcon } from "@heroicons/react/24/solid";
export function Footer() {
const year = new Date().getFullYear();
return (
<footer>
<div className="flex w-full flex-wrap items-center justify-center md:justify-end">
<Typography variant="small" className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">
StackPulse: &copy; {year} by Michael Böhmländer
</Typography>
</div>
</footer>
);
}
Footer.displayName = "/src/widgets/layout/footer.jsx";
export default Footer;
+5
View File
@@ -0,0 +1,5 @@
export * from "@/widgets/layout/sidenav";
export * from "@/widgets/layout/dashboard-navbar";
export * from "@/widgets/layout/configurator";
export * from "@/widgets/layout/footer";
export * from "@/widgets/layout/navbar";
+107
View File
@@ -0,0 +1,107 @@
import React from "react";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
import {
Navbar as MTNavbar,
Collapse,
Typography,
Button,
IconButton,
} from "@material-tailwind/react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
export function Navbar({ brandName, routes, action }) {
const [openNav, setOpenNav] = React.useState(false);
React.useEffect(() => {
window.addEventListener(
"resize",
() => window.innerWidth >= 960 && setOpenNav(false)
);
}, []);
const navList = (
<ul className="mb-4 mt-2 flex flex-col gap-2 lg:mb-0 lg:mt-0 lg:flex-row lg:items-center lg:gap-6">
{routes.map(({ name, path, icon }) => (
<Typography
key={name}
as="li"
variant="small"
color="blue-gray"
className="capitalize"
>
<Link to={path} className="flex items-center gap-1 p-1 font-normal">
{icon &&
React.createElement(icon, {
className: "w-[18px] h-[18px] opacity-50 mr-1",
})}
{name}
</Link>
</Typography>
))}
</ul>
);
return (
<MTNavbar className="p-3">
<div className="container mx-auto flex items-center justify-between text-blue-gray-900">
<Link to="/">
<Typography
variant="small"
className="mr-4 ml-2 cursor-pointer py-1.5 font-bold"
>
{brandName}
</Typography>
</Link>
<div className="hidden lg:block">{navList}</div>
{React.cloneElement(action, {
className: "hidden lg:inline-block",
})}
<IconButton
variant="text"
size="sm"
className="hml-auto text-inherit hover:bg-transparent focus:bg-transparent active:bg-transparent lg:hidden"
onClick={() => setOpenNav(!openNav)}
>
{openNav ? (
<XMarkIcon strokeWidth={2} className="h-6 w-6" />
) : (
<Bars3Icon strokeWidth={2} className="h-6 w-6" />
)}
</IconButton>
</div>
<Collapse open={openNav}>
<div className="container mx-auto">
{navList}
{React.cloneElement(action, {
className: "w-full block lg:hidden",
})}
</div>
</Collapse>
</MTNavbar>
);
}
Navbar.defaultProps = {
brandName: "Material Tailwind React",
action: (
<a
href="https://www.creative-tim.com/product/material-tailwind-dashboard-react"
target="_blank"
>
<Button variant="gradient" size="sm" fullWidth>
free download
</Button>
</a>
),
};
Navbar.propTypes = {
brandName: PropTypes.string,
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
action: PropTypes.node,
};
Navbar.displayName = "/src/widgets/layout/navbar.jsx";
export default Navbar;
+102
View File
@@ -0,0 +1,102 @@
import PropTypes from "prop-types";
import { Link, NavLink } from "react-router-dom";
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
Avatar,
Button,
IconButton,
Typography,
} from "@material-tailwind/react";
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
import logo from "@/assets/images/stackpulse.png";
export function Sidenav({ brandImg, brandName, routes }) {
const [controller, dispatch] = useMaterialTailwindController();
const { sidenavColor, sidenavType, openSidenav } = controller;
const sidenavTypes = {
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
white: "bg-white shadow-sm",
transparent: "bg-transparent",
};
return (
<aside
className={`${sidenavTypes[sidenavType]} ${openSidenav ? "translate-x-0" : "-translate-x-80"
} fixed inset-0 z-50 my-4 ml-4 h-[calc(100vh-32px)] w-72 rounded-xl transition-transform duration-300 xl:translate-x-0 border border-blue-gray-100`}
>
<div className="relative py-6 px-8 text-right">
<img src={logo} alt="StackPulse" className="w-auto" /><span className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">v0.4</span>
<IconButton
variant="text"
size="sm"
ripple={false}
className="absolute right-0 top-0 grid rounded-br-none rounded-tl-none"
onClick={() => setOpenSidenav(dispatch, false)}
>
<XMarkIcon strokeWidth={2.5} className="h-5 w-5 text-black" />
</IconButton>
</div>
<div className="m-4">
{routes.map(({ layout, title, pages }, key) => (
<ul key={key} className="mb-4 flex flex-col gap-1">
{title && (
<li className="mx-3.5 mt-4 mb-2">
<Typography
variant="small"
color={sidenavType === "dark" ? "white" : "blue-gray"}
className="font-black uppercase opacity-75"
>
{title}
</Typography>
</li>
)}
{pages.map(({ icon, name, path }) => (
<li key={name}>
<NavLink to={`/${layout}${path}`}>
{({ isActive }) => (
<Button
variant={isActive ? "gradient" : "text"}
color={
isActive
? sidenavColor
: sidenavType === "dark"
? "white"
: "blue-gray"
}
className="flex items-center gap-4 px-4 capitalize"
fullWidth
>
{icon}
<Typography
color="inherit"
className="font-medium capitalize"
>
{name}
</Typography>
</Button>
)}
</NavLink>
</li>
))}
</ul>
))}
</div>
</aside>
);
}
Sidenav.defaultProps = {
brandImg: "/img/logo-ct.png",
brandName: "Material Tailwind React",
};
Sidenav.propTypes = {
brandImg: PropTypes.string,
brandName: PropTypes.string,
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
};
Sidenav.displayName = "/src/widgets/layout/sidnave.jsx";
export default Sidenav;
+38
View File
@@ -0,0 +1,38 @@
const withMT = require("@material-tailwind/react/utils/withMT");
const colors = require("tailwindcss/colors");
module.exports = withMT({
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
sky: colors.sky,
green: colors.green,
slate: colors.slate,
orange: colors.orange, // falls du z.B. text-orange-300 brauchst
auroraTeal: {50:'#f0fafb',100:'#e0f5f7',200:'#c3ecef',300:'#9de0e4',400:'#6ad3d8',500:'#38c6cc',600:'#2aa9b0',700:'#1f8d94',800:'#166e76',900:'#0c4f58'},
sunsetCoral: {50:'#fff6f5',100:'#ffece9',200:'#ffd7d3',300:'#ffb8b5',400:'#ff9189',500:'#ff6a5d',600:'#e65048',700:'#b93d38',800:'#8a2c28',900:'#5c1c1a'},
lavenderSmoke: {50:'#faf8fc',100:'#f5f1f9',200:'#ece2f3',300:'#dac4e6',400:'#b88acf',500:'#9670b8',600:'#7a5894',700:'#5d4373',800:'#412f52',900:'#291e38'},
mossGreen: {50:'#f5faf2',100:'#ebf5e5',200:'#d9ebca',300:'#b8d99e',400:'#8cc56f',500:'#60b240',600:'#4d8f33',700:'#3b6b27',800:'#2a491d',900:'#1c3012'},
deepOceanBlue: {50:'#f4faff',100:'#e9f5ff',200:'#cfe6ff',300:'#add1ff',400:'#7ab9ff',500:'#479fff',600:'#3c86e6',700:'#305eaf',800:'#23437f',900:'#162a50'},
warmAmberGlow: {50:'#fff9f2',100:'#fff3e5',200:'#ffe6cc',300:'#ffcc99',400:'#ffad4d',500:'#ff8f00',600:'#e67600',700:'#b35a00',800:'#804000',900:'#4d2600'},
peachMist: {50:'#fff8f7',100:'#fff0ed',200:'#ffe1db',300:'#ffc2b7',400:'#ff9a87',500:'#ff7257',600:'#e65f4b',700:'#b34938',800:'#803622',900:'#4d230f'},
slatePurple: {50:'#f7f5fa',100:'#efe9f5',200:'#ded1ea',300:'#bdb3d5',400:'#9584b5',500:'#6d5795',600:'#5c4a7c',700:'#473861',800:'#322648',900:'#1f1530'},
mintTea: {50:'#f2fcfa',100:'#e5f9f6',200:'#ccefee',300:'#99dfdc',400:'#66cfc9',500:'#33bfb6',600:'#2aa395',700:'#228174',800:'#195953',900:'#103330'},
copperRust: {50:'#faf7f6',100:'#f5efee',200:'#ebe0dc',300:'#d6c1b8',400:'#b28a86',500:'#8d4d55',600:'#7a444b',700:'#5f3338',800:'#442426',900:'#2d1718'},
// Neue 10
arcticBlue: {50:'#f2f9fd',100:'#e6f3fb',200:'#cce6f7',300:'#99ceef',400:'#66b6e7',500:'#339ede',600:'#1f86c3',700:'#16699b',800:'#0f4b6f',900:'#082d43'},
roseQuartz: {50:'#fdf7f9',100:'#faeff3',200:'#f5dfe6',300:'#ecbfd0',400:'#e08dad',500:'#d45b89',600:'#b94771',700:'#8e3556',800:'#63233b',900:'#391324'},
citrusPunch: {50:'#fffdf6',100:'#fffbe9',200:'#fff5cc',300:'#ffeb99',400:'#ffe066',500:'#ffd633',600:'#e6bd00',700:'#b39300',800:'#806900',900:'#4d3f00'},
midnightPlum: {50:'#faf7fb',100:'#f4ecf6',200:'#e4cfee',300:'#cba0de',400:'#a869cc',500:'#8431b9',600:'#6d299b',700:'#521f74',800:'#38154d',900:'#1f0b28'},
sandstone: {50:'#fbf9f7',100:'#f6f2ee',200:'#ece3da',300:'#d8c6b3',400:'#c0a27f',500:'#a87e4b',600:'#906a3e',700:'#6b4f2f',800:'#47341f',900:'#241a10'},
emeraldMist: {50:'#f3faf7',100:'#e7f6ef',200:'#ccecdc',300:'#9ddabf',400:'#6ec8a3',500:'#3fb586',600:'#359771',700:'#2a765a',800:'#1f5442',900:'#13332a'},
velvetRed: {50:'#fdf7f7',100:'#faeeee',200:'#f4d6d6',300:'#e7a8a8',400:'#d56666',500:'#c32424',600:'#a61e1e',700:'#801717',800:'#590f0f',900:'#330808'},
stormGrey: {50:'#f7f8fa',100:'#eef0f3',200:'#d9dce2',300:'#b6bbc8',400:'#8b91a4',500:'#606780',600:'#4f556b',700:'#3e4456',800:'#2c313f',900:'#1b1e29'},
neonLime: {50:'#f7ffe6',100:'#eeffc9',200:'#dbff96',300:'#c2ff52',400:'#a8ff0e',500:'#8de600',600:'#74bf00',700:'#5b9900',800:'#426f00',900:'#294600'},
skyMauve: {50:'#faf8fc',100:'#f4f0f9',200:'#e5dbf2',300:'#c9b2e6',400:'#a27dd4',500:'#7b48c2',600:'#633aa0',700:'#4b2b7c',800:'#341d58',900:'#1e1035'},
},
},
},
plugins: [],
});
+5 -2
View File
@@ -1,8 +1,11 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: [{ find: "@", replacement: "/src" }],
},
server: { server: {
host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig
port: 5173, // optional, Standardport für Vite port: 5173, // optional, Standardport für Vite
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPulse</title>
<script type="module" crossorigin src="/assets/index-e96128a6.js"></script>
<link rel="stylesheet" href="/assets/index-736c8c6c.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPulse</title>
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+623 -74
View File
@@ -8,17 +8,27 @@
"name": "stackpulse-frontend", "name": "stackpulse-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"axios": "^1.7.5", "@heroicons/react": "2.0.18",
"react": "^18.2.0", "@material-tailwind/react": "2.1.4",
"react-dom": "^18.2.0", "apexcharts": "3.44.0",
"socket.io-client": "^4.8.1" "axios": "1.7.5",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-apexcharts": "1.4.1",
"react-dom": "18.2.0",
"react-router-dom": "6.17.0",
"socket.io-client": "4.8.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.0.0", "@types/react": "18.2.31",
"autoprefixer": "^10.4.21", "@types/react-dom": "18.2.14",
"postcss": "^8.5.6", "@vitejs/plugin-react": "4.1.0",
"tailwindcss": "^3.4.17", "autoprefixer": "10.4.16",
"vite": "^4.4.9" "postcss": "8.4.31",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "0.5.6",
"tailwindcss": "3.3.4",
"vite": "4.5.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -309,6 +319,21 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emotion/is-prop-valid": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"optional": true,
"dependencies": {
"@emotion/memoize": "0.7.4"
}
},
"node_modules/@emotion/memoize": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.18.20", "version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@@ -661,6 +686,62 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.0.tgz",
"integrity": "sha512-fgYvN4ksCi5OvmPXkyOT8o5a8PSKHMzPHt+9mR6KYWdF16IAjWRLZPAAziI2sznaWT23drRFrYw64wdvYqqaQw==",
"dependencies": {
"@floating-ui/react-dom": "^1.2.2",
"aria-hidden": "^1.1.3",
"tabbable": "^6.0.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
"integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
"dependencies": {
"@floating-ui/dom": "^1.2.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@heroicons/react": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.18.tgz",
"integrity": "sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==",
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -713,6 +794,84 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@material-tailwind/react": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.4.tgz",
"integrity": "sha512-dRHnC+Ka0IJ8KSfToBmlJRy9gAPv6F1oVZb+ABOPcIR8LxBhAXXhMAjtQf9ilG0nq2VK9AISsYFtyWBsr8Y88A==",
"dependencies": {
"@floating-ui/react": "0.19.0",
"classnames": "2.3.2",
"deepmerge": "4.2.2",
"framer-motion": "6.5.1",
"material-ripple-effects": "2.0.1",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwind-merge": "1.8.1"
},
"peerDependencies": {
"react": "^16 || ^17 || ^18",
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/@motionone/animation": {
"version": "10.18.0",
"resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz",
"integrity": "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==",
"dependencies": {
"@motionone/easing": "^10.18.0",
"@motionone/types": "^10.17.1",
"@motionone/utils": "^10.18.0",
"tslib": "^2.3.1"
}
},
"node_modules/@motionone/dom": {
"version": "10.12.0",
"resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.12.0.tgz",
"integrity": "sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw==",
"dependencies": {
"@motionone/animation": "^10.12.0",
"@motionone/generators": "^10.12.0",
"@motionone/types": "^10.12.0",
"@motionone/utils": "^10.12.0",
"hey-listen": "^1.0.8",
"tslib": "^2.3.1"
}
},
"node_modules/@motionone/easing": {
"version": "10.18.0",
"resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.18.0.tgz",
"integrity": "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==",
"dependencies": {
"@motionone/utils": "^10.18.0",
"tslib": "^2.3.1"
}
},
"node_modules/@motionone/generators": {
"version": "10.18.0",
"resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.18.0.tgz",
"integrity": "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==",
"dependencies": {
"@motionone/types": "^10.17.1",
"@motionone/utils": "^10.18.0",
"tslib": "^2.3.1"
}
},
"node_modules/@motionone/types": {
"version": "10.17.1",
"resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.17.1.tgz",
"integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A=="
},
"node_modules/@motionone/utils": {
"version": "10.18.0",
"resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.18.0.tgz",
"integrity": "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==",
"dependencies": {
"@motionone/types": "^10.17.1",
"hey-listen": "^1.0.8",
"tslib": "^2.3.1"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -758,11 +917,13 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@remix-run/router": {
"version": "1.0.0-beta.27", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.10.0.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "integrity": "sha512-Lm+fYpMfZoEucJ7cMxgt4dYt8jLfbpwRCzAjm9UgSLOkmlqo9gupxt6YX3DY0Fk155NT9l17d/ydi+964uS9Lw==",
"dev": true "engines": {
"node": ">=14.0.0"
}
}, },
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
@@ -810,26 +971,62 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@types/prop-types": {
"version": "4.7.0", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true
},
"node_modules/@types/react": {
"version": "18.2.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.31.tgz",
"integrity": "sha512-c2UnPv548q+5DFh03y8lEDeMfDwBn9G3dRwfkrxQMo/dOtRHUUO57k6pHvBIfH/VF4Nh+98mZ5aaSe+2echD5g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.28.0", "@types/prop-types": "*",
"@babel/plugin-transform-react-jsx-self": "^7.27.1", "@types/scheduler": "*",
"@babel/plugin-transform-react-jsx-source": "^7.27.1", "csstype": "^3.0.2"
"@rolldown/pluginutils": "1.0.0-beta.27", }
"@types/babel__core": "^7.20.5", },
"react-refresh": "^0.17.0" "node_modules/@types/react-dom": {
"version": "18.2.14",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.14.tgz",
"integrity": "sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.1.0.tgz",
"integrity": "sha512-rM0SqazU9iqPUraQ2JlIvReeaxOoRj6n+PzB1C0cBzIbd8qP336nC39/R9yPi3wVcah7E7j/kdU1uCUqMEU4OQ==",
"dev": true,
"dependencies": {
"@babel/core": "^7.22.20",
"@babel/plugin-transform-react-jsx-self": "^7.22.5",
"@babel/plugin-transform-react-jsx-source": "^7.22.5",
"@types/babel__core": "^7.20.2",
"react-refresh": "^0.14.0"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0"
} }
}, },
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@@ -873,21 +1070,46 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/apexcharts": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.44.0.tgz",
"integrity": "sha512-u7Xzrbcxc2yWznN78Jh5NMCYVAsWDfBjRl5ea++rVzFAqjU2hLz4RgKIFwYOBDRQtW1e/Qz8azJTqIJ1+Vu9Qg==",
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3",
"svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3",
"svg.select.js": "^3.0.1"
}
},
"node_modules/arg": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true "dev": true
}, },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.16",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -904,11 +1126,11 @@
} }
], ],
"dependencies": { "dependencies": {
"browserslist": "^4.24.4", "browserslist": "^4.21.10",
"caniuse-lite": "^1.0.30001702", "caniuse-lite": "^1.0.30001538",
"fraction.js": "^4.3.7", "fraction.js": "^4.3.6",
"normalize-range": "^0.1.2", "normalize-range": "^0.1.2",
"picocolors": "^1.1.1", "picocolors": "^1.0.0",
"postcss-value-parser": "^4.2.0" "postcss-value-parser": "^4.2.0"
}, },
"bin": { "bin": {
@@ -922,12 +1144,12 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.11.0", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.4", "form-data": "^4.0.0",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -1079,6 +1301,11 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1149,6 +1376,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -1166,6 +1399,14 @@
} }
} }
}, },
"node_modules/deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1452,6 +1693,34 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz",
"integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==",
"dependencies": {
"@motionone/dom": "10.12.0",
"framesync": "6.0.1",
"hey-listen": "^1.0.8",
"popmotion": "11.0.3",
"style-value-types": "5.0.0",
"tslib": "^2.1.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
},
"peerDependencies": {
"react": ">=16.8 || ^17.0.0 || ^18.0.0",
"react-dom": ">=16.8 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/framesync": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz",
"integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1597,6 +1866,11 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1760,6 +2034,11 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/material-ripple-effects": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/material-ripple-effects/-/material-ripple-effects-2.0.1.tgz",
"integrity": "sha512-hHlUkZAuXbP94lu02VgrPidbZ3hBtgXBtjlwR8APNqOIgDZMV8MCIcsclL8FmGJQHvnORyvoQgC965vPsiyXLQ=="
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1895,7 +2174,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -1988,10 +2266,21 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/popmotion": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz",
"integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==",
"dependencies": {
"framesync": "6.0.1",
"hey-listen": "^1.0.8",
"style-value-types": "5.0.0",
"tslib": "^2.1.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2008,9 +2297,9 @@
} }
], ],
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.6",
"picocolors": "^1.1.1", "picocolors": "^1.0.0",
"source-map-js": "^1.2.1" "source-map-js": "^1.0.2"
}, },
"engines": { "engines": {
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
@@ -2131,6 +2420,103 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true "dev": true
}, },
"node_modules/prettier": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.6.tgz",
"integrity": "sha512-2Xgb+GQlkPAUCFi3sV+NOYcSI5XgduvDBL2Zt/hwJudeKXkyvRS65c38SB0yb9UB40+1rL83I6m0RtlOQ8eHdg==",
"dev": true,
"engines": {
"node": ">=14.21.3"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@shufo/prettier-plugin-blade": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-import-sort": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-style-order": "*",
"prettier-plugin-svelte": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@shufo/prettier-plugin-blade": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-import-sort": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-marko": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-style-order": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
},
"prettier-plugin-twig-melody": {
"optional": true
}
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2157,9 +2543,9 @@
] ]
}, },
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -2167,27 +2553,74 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-apexcharts": {
"version": "18.3.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.4.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-G14nVaD64Bnbgy8tYxkjuXEUp/7h30Q0U33xc3AwtGFijJB9nHqOt1a6eG0WBn055RgRg+NwqbKGtqPxy15d0Q==",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "prop-types": "^15.8.1"
"scheduler": "^0.23.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "apexcharts": "^3.41.0",
"react": ">=0.13"
} }
}, },
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.17.0.tgz",
"integrity": "sha512-YJR3OTJzi3zhqeJYADHANCGPUu9J+6fT5GLv82UWRGSxu6oJYCKVmxUcaBQuGm9udpWmPsvpme/CdHumqgsoaA==",
"dependencies": {
"@remix-run/router": "1.10.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.17.0.tgz",
"integrity": "sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ==",
"dependencies": {
"@remix-run/router": "1.10.0",
"react-router": "6.17.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2491,6 +2924,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/style-value-types": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz",
"integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==",
"dependencies": {
"hey-listen": "^1.0.8",
"tslib": "^2.1.0"
}
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -2525,34 +2967,127 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
"dependencies": {
"svg.js": "^2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.easing.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
"integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==",
"dependencies": {
"svg.js": ">=2.3.x"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.filter.js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
"integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==",
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA=="
},
"node_modules/svg.pathmorphing.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
"dependencies": {
"svg.js": "^2.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
"dependencies": {
"svg.js": "^2.6.5",
"svg.select.js": "^2.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js/node_modules/svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.select.js": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"dependencies": {
"svg.js": "^2.6.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
},
"node_modules/tailwind-merge": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.8.1.tgz",
"integrity": "sha512-+fflfPxvHFr81hTJpQ3MIwtqgvefHZFUHFiIHpVIRXvG/nX9+gu2P7JNlFu2bfDMJ+uHhi/pUgzaYacMoXv+Ww=="
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.17", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.4.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-JXZNOkggUAc9T5E7nCrimoXHcSf9h3NWFe5sh36CGD/3M5TRLuQeFnQoDsit2uVTqgoOZHLx5rTykLUu16vsMQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
"chokidar": "^3.6.0", "chokidar": "^3.5.3",
"didyoumean": "^1.2.2", "didyoumean": "^1.2.2",
"dlv": "^1.1.3", "dlv": "^1.1.3",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.0",
"glob-parent": "^6.0.2", "glob-parent": "^6.0.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
"jiti": "^1.21.6", "jiti": "^1.19.1",
"lilconfig": "^3.1.3", "lilconfig": "^2.1.0",
"micromatch": "^4.0.8", "micromatch": "^4.0.5",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"picocolors": "^1.1.1", "picocolors": "^1.0.0",
"postcss": "^8.4.47", "postcss": "^8.4.23",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-js": "^4.0.1", "postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2", "postcss-load-config": "^4.0.1",
"postcss-nested": "^6.2.0", "postcss-nested": "^6.0.1",
"postcss-selector-parser": "^6.1.2", "postcss-selector-parser": "^6.0.11",
"resolve": "^1.22.8", "resolve": "^1.22.2",
"sucrase": "^3.35.0" "sucrase": "^3.32.0"
}, },
"bin": { "bin": {
"tailwind": "lib/cli.js", "tailwind": "lib/cli.js",
@@ -2562,6 +3097,15 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tailwindcss/node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -2601,6 +3145,11 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "dev": true
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -2638,9 +3187,9 @@
"dev": true "dev": true
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "4.5.14", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.18.10", "esbuild": "^0.18.10",
+33
View File
@@ -0,0 +1,33 @@
{
"name": "stackpulse-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "1.7.5",
"socket.io-client": "4.8.1",
"@heroicons/react": "2.0.18",
"@material-tailwind/react": "2.1.4",
"apexcharts": "3.44.0",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-apexcharts": "1.4.1",
"react-dom": "18.2.0",
"react-router-dom": "6.17.0"
},
"devDependencies": {
"@types/react": "18.2.31",
"@types/react-dom": "18.2.14",
"@vitejs/plugin-react": "4.1.0",
"autoprefixer": "10.4.16",
"postcss": "8.4.31",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "0.5.6",
"tailwindcss": "3.3.4",
"vite": "4.5.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+65
View File
@@ -0,0 +1,65 @@
import React from "react";
import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx";
import Logs from "./Logs.jsx";
import Maintenance from "./Maintenance.jsx";
import logo from "./assets/images/stackpulse.png";
import { useMaintenance } from "./context/MaintenanceContext.jsx";
const navLinkBase =
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
const getNavClass = ({ isActive }) =>
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
export default function App() {
const { maintenance, update } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-md">
<div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col items-end gap-1">
<span className="text-xs text-gray-500">v0.3</span>
{maintenanceActive && (
<span className="flex items-center gap-1 text-xs text-amber-300">
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
{maintenanceLabel}
</span>
)}
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
</div>
<nav className="flex gap-2 items-end">
<NavLink to="/" end className={getNavClass}>
Stacks
</NavLink>
<NavLink to="/maintenance" className={getNavClass}>
<span className="flex items-center gap-2">
Wartung
{maintenanceActive && (
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
)}
</span>
</NavLink>
<NavLink to="/logs" className={getNavClass}>
Logs
</NavLink>
</nav>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Stacks />} />
<Route path="/maintenance" element={<Maintenance />} />
<Route path="/logs" element={<Logs />} />
<Route path="*" element={<Stacks />} />
</Routes>
</main>
</div>
);
}
+935
View File
@@ -0,0 +1,935 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
const STATUS_COLORS = {
success: "text-green-400",
warning: "text-yellow-400",
error: "text-red-400",
started: "text-blue-300"
};
const formatTimestamp = (value) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
};
const normalizeDateParam = (value) => {
if (!value) return undefined;
return value.replace("T", " ");
};
const FILTER_STORAGE_KEY = "redeployLogFilters";
const ALL_OPTION_VALUE = "__all__";
const ALL_OPTION_LABEL = "- Alle -";
const PER_PAGE_DEFAULT = "50";
const PER_PAGE_OPTIONS = [
{ value: "10", label: "10" },
{ value: "25", label: "25" },
{ value: "50", label: "50" },
{ value: "100", label: "100" },
{ value: "all", label: "Alle" }
];
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value));
const REDEPLOY_TYPE_LABELS = {
Einzeln: "Einzeln",
Alle: "Alle",
Auswahl: "Auswahl",
single: "Einzeln",
all: "Alle",
selection: "Auswahl"
};
const hasActiveFilters = (filters) => Boolean(
(filters.stacks && filters.stacks.length) ||
(filters.statuses && filters.statuses.length) ||
(filters.endpoints && filters.endpoints.length) ||
(filters.redeployTypes && filters.redeployTypes.length) ||
(filters.message && filters.message.trim()) ||
(filters.from && filters.from.trim()) ||
(filters.to && filters.to.trim())
);
export default function Logs() {
const [logs, setLogs] = useState([]);
const [totalLogs, setTotalLogs] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const [stackOptions, setStackOptions] = useState([]);
const [statusOptions, setStatusOptions] = useState([]);
const [endpointOptions, setEndpointOptions] = useState([]);
const [redeployTypeOptions, setRedeployTypeOptions] = useState([]);
const [selectedStacks, setSelectedStacks] = useState([]);
const [selectedStatuses, setSelectedStatuses] = useState([]);
const [selectedEndpoints, setSelectedEndpoints] = useState([]);
const [selectedRedeployTypes, setSelectedRedeployTypes] = useState([]);
const [messageQuery, setMessageQuery] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [filtersReady, setFiltersReady] = useState(false);
const [optionsInitialized, setOptionsInitialized] = useState(false);
const [refreshSignal, setRefreshSignal] = useState(0);
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
const [page, setPage] = useState(1);
const updateFilterOptions = useCallback((payload) => {
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
const stackMap = new Map();
const statusSet = new Set();
const endpointSet = new Set();
const redeployTypeSet = new Set();
logsPayload.forEach((log) => {
if (log.stackId) {
const value = String(log.stackId);
const label = log.stackName || `Stack ${value}`;
stackMap.set(value, label);
}
if (log.status) {
statusSet.add(log.status);
}
if (log.endpoint !== null && log.endpoint !== undefined && log.endpoint !== "") {
endpointSet.add(String(log.endpoint));
}
if (log.redeployType) {
redeployTypeSet.add(log.redeployType);
}
});
setStackOptions(Array.from(stackMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label)));
setStatusOptions(Array.from(statusSet).sort());
setEndpointOptions(Array.from(endpointSet)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
setRedeployTypeOptions(Array.from(redeployTypeSet).sort());
setOptionsInitialized(true);
}, []);
const stackLabelMap = useMemo(() => {
const map = new Map();
stackOptions.forEach((option) => {
map.set(option.value, option.label);
});
return map;
}, [stackOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedStacks((prev) => {
const valid = prev.filter((value) => stackOptions.some((option) => option.value === value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, stackOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedStatuses((prev) => {
const valid = prev.filter((value) => statusOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, statusOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedEndpoints((prev) => {
const valid = prev.filter((value) => endpointOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, endpointOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedRedeployTypes((prev) => {
const valid = prev.filter((value) => redeployTypeOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, redeployTypeOptions]);
useEffect(() => {
if (typeof window === "undefined") {
setFiltersReady(true);
return;
}
try {
const storedValue = window.localStorage.getItem(FILTER_STORAGE_KEY);
if (storedValue) {
const parsed = JSON.parse(storedValue);
const storedFilters = parsed?.filters ?? parsed ?? {};
const storedPagination = parsed?.pagination ?? {};
setSelectedStacks(storedFilters.stacks || []);
setSelectedStatuses(storedFilters.statuses || []);
setSelectedEndpoints(storedFilters.endpoints || []);
setSelectedRedeployTypes(storedFilters.redeployTypes || []);
setMessageQuery(storedFilters.message || "");
setFromDate(storedFilters.from || "");
setToDate(storedFilters.to || "");
setFiltersOpen(hasActiveFilters(storedFilters));
const rawPerPage = storedPagination.perPage;
if (rawPerPage !== undefined) {
const parsedPerPage = String(rawPerPage);
if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
setPerPage(parsedPerPage);
}
}
const rawPage = storedPagination.page;
if (typeof rawPage === 'number' && rawPage > 0) {
setPage(rawPage);
}
}
} catch (storageError) {
console.error("❌ Fehler beim Laden der gespeicherten Log-Filter:", storageError);
} finally {
setFiltersReady(true);
}
}, []);
const buildFilterParams = useCallback(() => {
const params = {};
if (selectedStacks.length) {
params.stackIds = selectedStacks.join(",");
}
if (selectedStatuses.length) {
params.statuses = selectedStatuses.join(",");
}
if (selectedEndpoints.length) {
params.endpoints = selectedEndpoints.join(",");
}
if (selectedRedeployTypes.length) {
params.redeployTypes = selectedRedeployTypes.join(",");
}
if (messageQuery.trim()) {
params.message = messageQuery.trim();
}
const fromParam = normalizeDateParam(fromDate);
if (fromParam) {
params.from = fromParam;
}
const toParam = normalizeDateParam(toDate);
if (toParam) {
params.to = toParam;
}
return params;
}, [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => {
if (!filtersReady) return;
let cancelled = false;
axios.get("/api/logs", { params: { perPage: 'all', page: 1 } })
.then((response) => {
if (cancelled) return;
updateFilterOptions(response.data);
})
.catch((err) => {
if (cancelled) return;
console.error("⚠️ Konnte Filteroptionen nicht aktualisieren:", err);
});
return () => {
cancelled = true;
};
}, [filtersReady, updateFilterOptions, refreshSignal]);
const currentFilters = useMemo(() => ({
stacks: selectedStacks,
statuses: selectedStatuses,
endpoints: selectedEndpoints,
redeployTypes: selectedRedeployTypes,
message: messageQuery,
from: fromDate,
to: toDate
}), [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => {
if (!filtersReady) return;
const params = { ...buildFilterParams() };
if (perPage === 'all') {
params.perPage = 'all';
params.page = 1;
} else {
params.perPage = perPage;
params.page = page;
}
let cancelled = false;
setLoading(true);
setError("");
axios.get("/api/logs", { params })
.then((response) => {
if (cancelled) return;
const data = response.data || {};
const items = Array.isArray(data) ? data : data.items ?? [];
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
setLogs(items);
setTotalLogs(total);
if (perPage === 'all') {
if (page !== 1) setPage(1);
} else {
const totalPages = Math.max(1, Math.ceil((total || 0) / Number(perPage)));
const nextPage = Math.min(Math.max(page, 1), totalPages);
if (nextPage !== page) {
setPage(nextPage);
}
}
})
.catch((err) => {
if (cancelled) return;
console.error("❌ Fehler beim Laden der Logs:", err);
setError("Fehler beim Laden der Redeploy-Logs");
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page]);
useEffect(() => {
if (!filtersReady) return;
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
FILTER_STORAGE_KEY,
JSON.stringify({
filters: currentFilters,
pagination: {
perPage,
page
}
})
);
} catch (storageError) {
console.error("⚠️ Konnte Filter nicht speichern:", storageError);
}
}, [filtersReady, currentFilters, perPage, page]);
const handleMultiSelectChange = (setter) => (event) => {
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
if (values.includes(ALL_OPTION_VALUE)) {
setter([]);
} else {
setter(values);
}
setPage(1);
};
const handleOptionMouseDown = (event, currentValues, setter) => {
event.preventDefault();
event.stopPropagation();
const { value } = event.target;
if (value === ALL_OPTION_VALUE) {
if (currentValues.length) {
setter([]);
setPage(1);
}
return;
}
const nextValues = currentValues.includes(value)
? currentValues.filter((entry) => entry !== value)
: [...currentValues, value];
setter(nextValues);
setPage(1);
};
const handleResetFilters = () => {
setSelectedStacks([]);
setSelectedStatuses([]);
setSelectedEndpoints([]);
setSelectedRedeployTypes([]);
setMessageQuery("");
setFromDate("");
setToDate("");
setFiltersOpen(false);
setPage(1);
};
const handleToggleFilters = () => {
setFiltersOpen((prev) => !prev);
};
const handleRefresh = () => {
setRefreshSignal((prev) => prev + 1);
};
const handlePerPageChange = (event) => {
const value = event.target.value;
if (!VALID_PER_PAGE_VALUES.has(value)) return;
setPerPage(value);
setPage(1);
};
const handlePageChange = (nextPage) => {
if (perPage === 'all') return;
if (nextPage < 1) return;
const totalPages = Math.max(1, Math.ceil((totalLogs || 0) / Number(perPage)));
if (nextPage > totalPages) return;
setPage(nextPage);
};
const stackSelectOptions = useMemo(() => {
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [stackOptions]);
const statusSelectOptions = useMemo(() => {
const entries = statusOptions
.filter((status) => status !== ALL_OPTION_VALUE)
.map((status) => ({ value: status, label: status }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [statusOptions]);
const endpointSelectOptions = useMemo(() => {
const entries = endpointOptions
.filter((endpoint) => endpoint !== ALL_OPTION_VALUE)
.map((endpoint) => ({ value: endpoint, label: endpoint }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [endpointOptions]);
const redeployTypeSelectOptions = useMemo(() => {
const entries = redeployTypeOptions
.filter((type) => type !== ALL_OPTION_VALUE)
.map((type) => ({ value: type, label: REDEPLOY_TYPE_LABELS[type] ?? type }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [redeployTypeOptions]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (selectedStacks.length) count += selectedStacks.length;
if (selectedStatuses.length) count += selectedStatuses.length;
if (selectedEndpoints.length) count += selectedEndpoints.length;
if (selectedRedeployTypes.length) count += selectedRedeployTypes.length;
if (messageQuery.trim()) count += 1;
if (fromDate) count += 1;
if (toDate) count += 1;
return count;
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
const totalPages = useMemo(() => {
if (perPage === 'all') return 1;
const numeric = Number(perPage) || Number(PER_PAGE_DEFAULT);
return Math.max(1, Math.ceil((totalLogs || 0) / numeric));
}, [perPage, totalLogs]);
const rangeStart = useMemo(() => {
if (totalLogs === 0) return 0;
if (perPage === 'all') return 1;
return (page - 1) * Number(perPage) + 1;
}, [totalLogs, perPage, page]);
const rangeEnd = useMemo(() => {
if (totalLogs === 0) return 0;
if (perPage === 'all') return totalLogs;
return Math.min(totalLogs, (page - 1) * Number(perPage) + logs.length);
}, [totalLogs, perPage, page, logs.length]);
const handleDeleteLog = async (id) => {
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
setActionLoading(true);
setError("");
try {
await axios.delete(`/api/logs/${id}`);
setRefreshSignal((prev) => prev + 1);
} catch (err) {
console.error("❌ Fehler beim Löschen des Logs:", err);
setError("Fehler beim Löschen des Redeploy-Logs");
} finally {
setActionLoading(false);
}
};
const handleDeleteFiltered = async () => {
if (!logs.length) return;
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
setActionLoading(true);
setError("");
try {
const params = {
...buildFilterParams(),
ids: logs.map((log) => log.id).join(',')
};
await axios.delete("/api/logs", { params });
setRefreshSignal((prev) => prev + 1);
} catch (err) {
console.error("❌ Fehler beim Löschen der gefilterten Logs:", err);
setError("Fehler beim Löschen der gefilterten Logs");
} finally {
setActionLoading(false);
}
};
const handleExport = async (format) => {
setActionLoading(true);
setError("");
try {
const response = await axios.get("/api/logs/export", {
params: { ...buildFilterParams(), format },
responseType: "blob"
});
const contentType = response.headers["content-type"] || (format === "sql" ? "application/sql" : "text/plain");
const disposition = response.headers["content-disposition"] || "";
const match = disposition.match(/filename="?([^";]+)"?/i);
const filename = match?.[1] || `redeploy-logs.${format}`;
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("❌ Fehler beim Export der Logs:", err);
setError("Fehler beim Export der Redeploy-Logs");
} finally {
setActionLoading(false);
}
};
return (
<div className="mx-auto max-w-6xl space-y-4 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Export TXT
</button>
<button
onClick={() => handleExport('sql')}
disabled={actionLoading || loading}
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Export SQL
</button>
<button
onClick={handleDeleteFiltered}
disabled={actionLoading || loading || logs.length === 0}
className="rounded-md border border-red-600 px-3 py-1.5 text-sm text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
>
Angezeigte löschen
</button>
{loading && <span className="text-sm text-gray-400">Aktualisiere</span>}
<button
onClick={handleRefresh}
disabled={actionLoading}
className="px-4 py-2 rounded-md font-medium transition bg-purple-500 hover:bg-purple-600 disabled:opacity-60"
>
Aktualisieren
</button>
</div>
</div>
{error && (
<div className="bg-red-900/20 border border-red-500/40 text-red-300 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="rounded-xl border border-gray-700 bg-gray-800/60">
<button
onClick={handleToggleFilters}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-200 hover:bg-gray-700/40"
>
<span>
Filter {activeFilterCount > 0 && (
<span className="ml-2 rounded-full bg-purple-500/20 px-2 py-0.5 text-xs text-purple-300">
{activeFilterCount} aktiv
</span>
)}
</span>
<span className="text-xs uppercase tracking-wide text-gray-400">
{filtersOpen ? "Ausblenden" : "Anzeigen"}
</span>
</button>
{filtersOpen && (
<div className="space-y-4 border-t border-gray-700 px-4 py-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Stack</label>
<select
multiple
value={selectedStacks}
onChange={handleMultiSelectChange(setSelectedStacks)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{stackSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedStacks, setSelectedStacks)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedStacks.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Stacks
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedStacks.map((stackId) => (
<span
key={stackId}
className="rounded-full bg-purple-500/20 px-2 py-0.5 text-purple-200"
>
{stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
</span>
))}
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Status</label>
<select
multiple
value={selectedStatuses}
onChange={handleMultiSelectChange(setSelectedStatuses)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{statusSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedStatuses, setSelectedStatuses)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedStatuses.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Status
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedStatuses.map((status) => (
<span
key={status}
className="rounded-full bg-amber-500/20 px-2 py-0.5 text-amber-200"
>
{status}
</span>
))}
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Endpoint</label>
<select
multiple
value={selectedEndpoints}
onChange={handleMultiSelectChange(setSelectedEndpoints)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{endpointSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedEndpoints, setSelectedEndpoints)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedEndpoints.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Endpoints
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedEndpoints.map((endpoint) => (
<span
key={endpoint}
className="rounded-full bg-blue-500/20 px-2 py-0.5 text-blue-200"
>
{endpoint}
</span>
))}
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Redeploy-Typ</label>
<select
multiple
value={selectedRedeployTypes}
onChange={handleMultiSelectChange(setSelectedRedeployTypes)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{redeployTypeSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedRedeployTypes, setSelectedRedeployTypes)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedRedeployTypes.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Typen
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedRedeployTypes.map((type) => (
<span
key={type}
className="rounded-full bg-teal-500/20 px-2 py-0.5 text-teal-200"
>
{REDEPLOY_TYPE_LABELS[type] ?? type}
</span>
))}
</div>
)}
</div>
</div>
<div className="md:col-span-2 lg:col-span-4">
<label className="mb-2 block text-sm font-medium text-gray-300">Nachricht (Freitext)</label>
<input
type="text"
value={messageQuery}
onChange={(event) => {
setMessageQuery(event.target.value);
setPage(1);
}}
placeholder="Textsuche in Log-Nachrichten..."
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Von</label>
<input
type="datetime-local"
value={fromDate}
onChange={(event) => {
setFromDate(event.target.value);
setPage(1);
}}
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Bis</label>
<input
type="datetime-local"
value={toDate}
onChange={(event) => {
setToDate(event.target.value);
setPage(1);
}}
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button
onClick={handleResetFilters}
className="rounded-md border border-gray-600 px-4 py-2 text-gray-200 transition hover:bg-gray-700"
>
Zurücksetzen
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 text-sm text-gray-300">
<span>Einträge pro Seite</span>
<select
value={perPage}
onChange={handlePerPageChange}
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
>
{PER_PAGE_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-800">
<tr className="text-left text-sm uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Zeitpunkt</th>
<th className="px-4 py-3">Stack</th>
<th className="px-4 py-3">Art</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Nachricht</th>
<th className="px-4 py-3">Endpoint</th>
<th className="px-4 py-3 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 text-sm">
{logs.length === 0 && !loading && (
<tr>
<td colSpan="7" className="px-4 py-6 text-center text-gray-400">
Keine Logs vorhanden.
</td>
</tr>
)}
{logs.map((log) => {
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
const stackDisplayName = log.stackName || "Unbekannt";
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
const redeployTypeLabel = log.redeployType
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
: '---';
return (
<tr key={log.id} className="hover:bg-gray-700/40">
<td className="px-4 py-3 whitespace-nowrap text-gray-300">
{formatTimestamp(log.timestamp)}
</td>
<td className="px-4 py-3 text-gray-200">
<div className="flex flex-col">
<span className="font-medium">{stackDisplayName}</span>
{showStackId && (
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-300">
{redeployTypeLabel}
</td>
<td className={`px-4 py-3 font-semibold ${statusClass}`}>
{log.status}
</td>
<td className="px-4 py-3 text-gray-300">
{log.message || "-"}
</td>
<td className="px-4 py-3 text-gray-400">
{log.endpoint ?? "-"}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
className="rounded-md border border-red-600 px-3 py-1 text-xs text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
>
Löschen
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-300">
<span>
{totalLogs === 0
? "Keine Einträge"
: perPage === 'all'
? `Zeige alle ${totalLogs} Einträge`
: `Zeige ${rangeStart.toLocaleString()} ${rangeEnd.toLocaleString()} von ${totalLogs.toLocaleString()} Einträgen`}
</span>
{perPage !== 'all' && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || actionLoading}
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Zurück
</button>
<span className="text-gray-400">
Seite {page} / {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages || actionLoading}
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Weiter
</button>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,121 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
const ToastContext = createContext(null);
const VARIANT_STYLES = {
success: {
container: "border-green-500/80 bg-green-950/90 text-green-100",
accent: "bg-green-400"
},
warning: {
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
accent: "bg-yellow-400"
},
error: {
container: "border-red-500/80 bg-red-950/90 text-red-100",
accent: "bg-red-400"
},
info: {
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
accent: "bg-sky-400"
}
};
const DEFAULT_DURATION = 6000;
export default function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const timeoutsRef = useRef(new Map());
const dismissToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const handle = timeoutsRef.current.get(id);
if (handle && typeof window !== "undefined") {
window.clearTimeout(handle);
}
timeoutsRef.current.delete(id);
}, []);
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
setToasts((prev) => {
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
return [
...withoutExisting,
{
id: toastId,
title,
description,
variant: normalizedVariant
}
];
});
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
const timeoutHandle = window.setTimeout(() => {
dismissToast(toastId);
}, duration);
const existingHandle = timeoutsRef.current.get(toastId);
if (existingHandle) {
window.clearTimeout(existingHandle);
}
timeoutsRef.current.set(toastId, timeoutHandle);
}
return toastId;
}, [dismissToast]);
useEffect(() => {
return () => {
if (typeof window === "undefined") return;
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
timeoutsRef.current.clear();
};
}, []);
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
return (
<ToastContext.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
{toasts.map((toast) => {
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
return (
<div
key={toast.id}
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
>
<div className="flex items-start gap-3">
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
<div className="flex-1">
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
</div>
<button
type="button"
onClick={() => dismissToast(toast.id)}
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
>
Schließen
</button>
</div>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
@@ -0,0 +1,153 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
const MaintenanceContext = createContext(null);
const INITIAL_STATE = {
maintenance: null,
update: null,
script: null,
ssh: null,
loading: true,
error: "",
lastUpdated: null
};
export default function MaintenanceProvider({ children }) {
const [state, setState] = useState(INITIAL_STATE);
const pollingRef = useRef(null);
const wasRunningRef = useRef(false);
const applyState = useCallback((partial) => {
setState((prev) => ({
...prev,
...partial
}));
}, []);
const fetchConfig = useCallback(async () => {
applyState({ loading: true, error: "" });
try {
const response = await axios.get("/api/maintenance/config");
const data = response.data ?? {};
applyState({
maintenance: data.maintenance ?? null,
update: data.update ?? null,
script: data.script ?? null,
ssh: data.ssh ?? null,
loading: false,
error: "",
lastUpdated: new Date()
});
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
applyState({ loading: false, error: message });
}
}, [applyState]);
const refreshUpdateStatus = useCallback(async () => {
try {
const response = await axios.get("/api/maintenance/update-status");
const data = response.data ?? {};
setState((prev) => ({
...prev,
maintenance: data.maintenance ?? prev.maintenance,
update: data.update ?? prev.update,
error: ""
}));
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
setState((prev) => ({ ...prev, error: message }));
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
useEffect(() => {
if (state.update?.running) {
refreshUpdateStatus();
const intervalId = setInterval(() => {
refreshUpdateStatus();
}, 5000);
pollingRef.current = intervalId;
return () => clearInterval(intervalId);
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return undefined;
}, [state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (!currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [state.update?.running, fetchConfig]);
const triggerUpdate = useCallback(async (payload = {}) => {
await axios.post("/api/maintenance/portainer-update", payload);
await refreshUpdateStatus();
}, [refreshUpdateStatus]);
const saveScript = useCallback(async (script) => {
await axios.put("/api/maintenance/update-script", { script });
await fetchConfig();
}, [fetchConfig]);
const resetScript = useCallback(async () => {
await axios.delete("/api/maintenance/update-script");
await fetchConfig();
}, [fetchConfig]);
const saveSshConfig = useCallback(async (config) => {
await axios.put("/api/maintenance/ssh-config", config);
await fetchConfig();
}, [fetchConfig]);
const deleteSshConfig = useCallback(async () => {
await axios.delete("/api/maintenance/ssh-config");
await fetchConfig();
}, [fetchConfig]);
const testSshConnection = useCallback(async (config) => {
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
return response.data;
}, []);
const value = useMemo(() => ({
maintenance: state.maintenance,
update: state.update,
script: state.script,
ssh: state.ssh,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
refreshConfig: fetchConfig,
refreshUpdateStatus,
triggerUpdate,
saveScript,
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
return (
<MaintenanceContext.Provider value={value}>
{children}
</MaintenanceContext.Provider>
);
}
export function useMaintenance() {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenance must be used within a MaintenanceProvider');
}
return context;
}
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import ToastProvider from "./components/ToastProvider.jsx";
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
import './index.css'; // Tailwind CSS
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<MaintenanceProvider>
<ToastProvider>
<App />
</ToastProvider>
</MaintenanceProvider>
</BrowserRouter>
</React.StrictMode>
);
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig
port: 5173, // optional, Standardport für Vite
proxy: {
'/api': {
target: 'http://127.0.0.1:4001', // Backend lokal auf Port 4001
changeOrigin: true
},
'/socket.io': {
target: 'http://127.0.0.1:4001', // WebSocket-Verbindungen über Proxy weiterleiten
ws: true,
changeOrigin: true
}
}
}
});
-48
View File
@@ -1,48 +0,0 @@
#!/bin/bash
set -e
# Aktuellen Branch herausfinden
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Branch bestätigen
read -p "Aktueller Branch: $BRANCH. Ist das korrekt? (y/n): " CONFIRM
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
echo "Abgebrochen."
exit 1
fi
# Commit-Nachricht
read -p "Bitte Commit-Nachricht eingeben (default: 'Update'): " COMMIT_MSG
COMMIT_MSG=${COMMIT_MSG:-Update}
# Änderungen stagen und committen
echo "Änderungen werden gestaged..."
git add .
echo "Commit wird erstellt..."
git commit -m "$COMMIT_MSG" || echo "Nichts zu committen"
# Prüfen, ob Branch master ist
if [[ "$BRANCH" == "master" ]]; then
# Versionsnummer verpflichtend abfragen
while true; do
read -p "Bitte Versionsnummer für Master-Release Tag eingeben (z.B. v0.2.0): " VERSION_TAG
if [[ -n "$VERSION_TAG" ]]; then
break
else
echo "Versionsnummer darf nicht leer sein. Bitte eingeben."
fi
done
fi
# Push Branch
echo "Push nach origin/$BRANCH..."
git push origin "$BRANCH"
# Tag setzen und pushen, nur bei master
if [[ "$BRANCH" == "master" ]]; then
git tag -a "$VERSION_TAG" -m "Release $VERSION_TAG"
git push origin "$VERSION_TAG"
echo "Tag $VERSION_TAG gesetzt und gepusht."
fi
echo "Push abgeschlossen."
-22
View File
@@ -1,22 +0,0 @@
#!/bin/bash
# Skript: Dev in Master mergen
DEV_BRANCH="dev"
MASTER_BRANCH="master"
# Auf master wechseln und aktuell holen
git checkout $MASTER_BRANCH
git pull origin $MASTER_BRANCH
# Dev aktuell holen
git checkout $DEV_BRANCH
git pull origin $DEV_BRANCH
# Merge Dev in Master
git checkout $MASTER_BRANCH
git merge --no-ff $DEV_BRANCH -m "Merge $DEV_BRANCH into $MASTER_BRANCH"
# Push Master auf Remote
git push origin $MASTER_BRANCH
echo "Branch $DEV_BRANCH wurde in $MASTER_BRANCH gemerged."
-41
View File
@@ -1,41 +0,0 @@
#!/bin/bash
# Skript: Feature in Dev mergen mit Auswahl der Feature-Branches
DEV_BRANCH="dev"
# Auflisten aller Feature-Branches (lokal und remote)
echo "Verfügbare Feature-Branches:"
FEATURE_BRANCHES=$(git branch -r | grep 'origin/feature/' | sed 's|origin/||')
PS3="Bitte wähle einen Feature-Branch zum Mergen: "
# in ein Array konvertieren
BRANCH_ARRAY=($FEATURE_BRANCHES)
# Auswahlmenü anzeigen
select CHOSEN_BRANCH in "${BRANCH_ARRAY[@]}"; do
if [[ -n "$CHOSEN_BRANCH" ]]; then
FEATURE_BRANCH=$CHOSEN_BRANCH
break
else
echo "Ungültige Auswahl. Bitte erneut versuchen."
fi
done
echo "Ausgewählter Feature-Branch: $FEATURE_BRANCH"
# Dev aktuell holen
git checkout $DEV_BRANCH
git pull origin $DEV_BRANCH
# Feature-Branch aktuell holen
git checkout $FEATURE_BRANCH
git pull origin $FEATURE_BRANCH
# Merge Feature in Dev
git checkout $DEV_BRANCH
git merge --no-ff $FEATURE_BRANCH -m "Merge $FEATURE_BRANCH into $DEV_BRANCH"
# Push Dev auf Remote
git push origin $DEV_BRANCH
echo "Feature $FEATURE_BRANCH wurde erfolgreich in $DEV_BRANCH gemerged."
+1
View File
@@ -6,6 +6,7 @@ echo "🚀 Starte StackPulse Dev-Umgebung..."
# --- Backend --- # --- Backend ---
cd backend cd backend
npm install npm install
npm run migrate
npm start & npm start &
BACK_PID=$! BACK_PID=$!
cd .. cd ..
-87
View File
@@ -1,87 +0,0 @@
#!/bin/bash
# ===============================
# Dateien sichern, die nicht gepusht werden
# ===============================
UNVERSIONED_FILES=("scripts/docker-release.sh")
for f in "${UNVERSIONED_FILES[@]}"; do
if [[ -f $f ]]; then
mkdir -p /tmp/git_safe_backup
cp "$f" "/tmp/git_safe_backup/$(basename "$f")"
fi
done
# ===============================
# Alle Branches sammeln
# ===============================
# Alle lokalen Branches holen, führende Sternchen und Leerzeichen entfernen
LOCAL_BRANCHES=$(git branch | sed 's/* //' | sed 's/^[[:space:]]*//')
# Alle Remote-Branches holen, 'origin/' entfernen, HEAD ignorieren, führende Leerzeichen entfernen
REMOTE_BRANCHES=$(git branch -r | grep -v 'HEAD' | sed 's|origin/||' | sed 's/^[[:space:]]*//')
# Beide Listen zusammenführen und Duplikate entfernen
ALL_BRANCHES=$(echo -e "$LOCAL_BRANCHES\n$REMOTE_BRANCHES" | sort -u)
# Master und dev extrahieren, restliche feature-Branches alphabetisch
MASTER_BRANCH=$(echo "$ALL_BRANCHES" | grep -x 'master')
DEV_BRANCH=$(echo "$ALL_BRANCHES" | grep -x 'dev')
FEATURE_BRANCHES=$(echo "$ALL_BRANCHES" | grep '^feature/' | sort)
# Zusammenführen
SORTED_BRANCHES=()
[[ -n "$MASTER_BRANCH" ]] && SORTED_BRANCHES+=("$MASTER_BRANCH")
[[ -n "$DEV_BRANCH" ]] && SORTED_BRANCHES+=("$DEV_BRANCH")
for branch in $FEATURE_BRANCHES; do
SORTED_BRANCHES+=("$branch")
done
# Branches nummerieren
echo "Verfügbare Branches:"
i=1
declare -A BRANCH_MAP
for branch in "${SORTED_BRANCHES[@]}"; do
echo "$i) $branch"
BRANCH_MAP[$i]=$branch
((i++))
done
# Auswahl abfragen
read -p "Wähle einen Branch (Nummer): " choice
# Validierung
if [[ -z "${BRANCH_MAP[$choice]}" ]]; then
echo "Ungültige Auswahl!"
exit 1
fi
SELECTED_BRANCH=${BRANCH_MAP[$choice]}
echo "Wechsle zu Branch: $SELECTED_BRANCH"
# Remote-Stand holen
git fetch origin
# Branch wechseln (lokal erstellen, falls nur remote vorhanden)
if git show-ref --verify --quiet refs/heads/$SELECTED_BRANCH; then
git checkout $SELECTED_BRANCH
else
git checkout -b $SELECTED_BRANCH origin/$SELECTED_BRANCH
fi
# Arbeitsverzeichnis exakt auf Remote-Branch zurücksetzen
git reset --hard origin/$SELECTED_BRANCH
git clean -fd
# ===============================
# Gesicherte unversionierte Dateien zurückkopieren
# ===============================
for f in "${UNVERSIONED_FILES[@]}"; do
if [[ -f "/tmp/git_safe_backup/$(basename "$f")" ]]; then
mkdir -p "$(dirname "$f")"
mv "/tmp/git_safe_backup/$(basename "$f")" "$f"
fi
done
rm -rf /tmp/git_safe_backup
echo "Branch '$SELECTED_BRANCH' ist nun aktiv. Arbeitsverzeichnis entspricht exakt dem Remote-Stand."
echo "Unversionierte Dateien wurden wiederhergestellt."