feat: initialisation complète du CORE ProxmoxPanel
Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5dbcb1df07
66 changed files with 10370 additions and 0 deletions
13
.gitattributes
vendored
Normal file
13
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
* text=auto eol=lf
|
||||
*.go text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.vue text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.conf text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Données sensibles — ne jamais committer
|
||||
data/
|
||||
*.db
|
||||
*.key
|
||||
*.pub
|
||||
|
||||
# Binaires compilés
|
||||
backend/proxmoxpanel
|
||||
backend/proxmoxpanel.exe
|
||||
|
||||
# Dépendances frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Variables d'environnement (non utilisées — tout en SQLite)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Éditeurs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Systèmes d'exploitation
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ProxmoxPanel Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
239
README.md
Normal file
239
README.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# ProxmoxPanel — CORE
|
||||
|
||||
A self-hosted web management panel for Proxmox VE infrastructure. Manage LXC containers and VMs, run package updates, open interactive terminals, browse files — all from a single web interface with a dark/light Neumorphism UI.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard** — Configurable widget grid with drag-and-drop (LXC/VM status, shortcuts, metrics)
|
||||
- **Proxmox** — Real-time LXC/VM list with start/stop actions, live status via WebSocket
|
||||
- **Updates** — Run `apt upgrade` on the host or any LXC, with streamed output
|
||||
- **Terminal** — Interactive SSH terminal in the browser (xterm.js + PTY)
|
||||
- **Settings** — Per-user preferences (theme, language, sidebar position), instance config
|
||||
- **Modules** — Enable/disable optional modules without restarting
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Proxmox VE 7.x or 8.x
|
||||
- SSH access to the Proxmox host (password authentication)
|
||||
- A Proxmox API token (read-only `PVEAuditor` role is sufficient for metrics)
|
||||
- Reverse proxy recommended (Traefik, Nginx, Caddy) for HTTPS in production
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd core
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open `http://localhost` (or your server's IP) in a browser. The installation wizard appears on first launch and guides you through the configuration.
|
||||
|
||||
## Installation Wizard
|
||||
|
||||
The wizard runs automatically on first launch and collects:
|
||||
|
||||
1. **Instance name** and public URL (auto-detected from the HTTP request)
|
||||
2. **SSH host** — address and port of the Proxmox host (e.g. `192.168.1.10:22`)
|
||||
3. **SSH credentials** — Linux username and password used for PAM authentication
|
||||
4. **Proxmox API** — URL and API token (e.g. `https://192.168.1.10:8006`)
|
||||
5. **Default language** — `en` or `fr`
|
||||
|
||||
All sensitive values (SSH password, API token) are encrypted with AES-256-GCM before storage.
|
||||
|
||||
## Authentication
|
||||
|
||||
ProxmoxPanel uses **PAM authentication via SSH**: the panel attempts an SSH connection to the Proxmox host using the credentials provided at login. If the connection succeeds, the user is authenticated. Group membership is checked with `id -nG` — users in the `sudo` or `wheel` group are granted admin privileges.
|
||||
|
||||
No separate user database is required. Any Linux user with SSH access can log in.
|
||||
|
||||
Sessions use JWT RS256 (15-minute access tokens + 7-day refresh cookies).
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is stored in SQLite (`/app/data/panel.db`). There are no environment variables or config files to manage. Settings are accessible from the web interface under **Settings → General** (admin only).
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `ssh_host` | SSH address of the Proxmox host (`host:port`) |
|
||||
| `ssh_username` | SSH username |
|
||||
| `ssh_password` | SSH password (AES-256-GCM encrypted) |
|
||||
| `proxmox_url` | Proxmox API base URL (`https://host:8006`) |
|
||||
| `proxmox_token` | Proxmox API token (AES-256-GCM encrypted) |
|
||||
| `instance_name` | Display name shown in the UI |
|
||||
| `public_url` | Public URL of the panel |
|
||||
| `default_lang` | Default language (`en` or `fr`) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
core/
|
||||
├── docker-compose.yml # Two services: backend + frontend
|
||||
├── backend/ # Go 1.23+ — REST API + WebSocket server
|
||||
│ ├── main.go
|
||||
│ ├── internal/
|
||||
│ │ ├── api/ # HTTP handlers (chi router)
|
||||
│ │ ├── auth/ # PAM-via-SSH + JWT RS256
|
||||
│ │ ├── crypto/ # AES-256-GCM secret encryption
|
||||
│ │ ├── db/ # SQLite + versioned migrations
|
||||
│ │ ├── proxmox/ # Proxmox REST API client
|
||||
│ │ ├── ssh/ # SSH connection pool
|
||||
│ │ ├── websocket/ # WebSocket hub (pub/sub by channel)
|
||||
│ │ └── audit/ # Audit log
|
||||
│ └── modules/ # Module system (compiled-in, conditional init)
|
||||
└── frontend/ # Vue 3 + Vite + TypeScript — Nginx static
|
||||
└── src/
|
||||
├── views/ # One view per module
|
||||
├── stores/ # Pinia (auth, ui)
|
||||
├── styles/ # Neumorphism CSS (dark + light themes)
|
||||
└── locales/ # i18n — en.json, fr.json
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
- **Language**: Go 1.23+
|
||||
- **Router**: `go-chi/chi` v5
|
||||
- **WebSocket**: `gorilla/websocket`
|
||||
- **Database**: `modernc.org/sqlite` (pure Go, no CGO)
|
||||
- **JWT**: `golang-jwt/jwt` v5, RS256, keys auto-generated at first start
|
||||
- **SSH**: `golang.org/x/crypto/ssh`
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Framework**: Vue 3 (Composition API)
|
||||
- **Build tool**: Vite 6 — compiled to static HTML/CSS/JS, served by Nginx
|
||||
- **State**: Pinia
|
||||
- **i18n**: vue-i18n v11
|
||||
- **Terminal**: xterm.js + `@xterm/addon-fit` + `@xterm/addon-attach`
|
||||
- **File editor**: CodeMirror 6
|
||||
- **Design**: Custom Neumorphism CSS, dark/light themes via `data-theme` on `<html>`
|
||||
|
||||
> Node.js is used **only during the Docker build stage** to compile the frontend. The runtime image is Nginx only.
|
||||
|
||||
### Data persistence
|
||||
|
||||
A Docker volume (`proxmoxpanel-data`) stores:
|
||||
|
||||
- `panel.db` — SQLite database
|
||||
- `keys/jwt.key`, `keys/jwt.pub` — RSA-2048 key pair for JWT signing
|
||||
- `master.key` — AES master secret for credential encryption
|
||||
|
||||
**Do not delete this volume without backing up `panel.db` first.**
|
||||
|
||||
### WebSocket channels
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /ws/proxmox` | LXC/VM status updates (polled every 10s) |
|
||||
| `GET /ws/updates/{jobId}` | Streaming `apt` output for an update job |
|
||||
| `GET /ws/terminal` | Interactive SSH PTY terminal |
|
||||
|
||||
WebSocket authentication uses a `?token=` query parameter (standard Authorization header is not supported by browser WebSocket API).
|
||||
|
||||
## Module System
|
||||
|
||||
Modules are compiled into the binary but initialized only if enabled in the database. This allows disabling features without rebuilding.
|
||||
|
||||
Each module implements the `Module` interface:
|
||||
|
||||
```go
|
||||
type Module interface {
|
||||
ID() string
|
||||
Register(registry Registry) error
|
||||
}
|
||||
```
|
||||
|
||||
A module can register HTTP routes, WebSocket channels, dashboard widgets, settings tabs, translations, and database migrations via the `Registry` interface.
|
||||
|
||||
Core modules (always enabled): `dashboard`, `proxmox`, `updates`, `settings`
|
||||
|
||||
Optional modules (can be toggled): `files`, `terminal`, `logs`, `services`
|
||||
|
||||
See `backend/modules/` for available modules and their documentation.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
ProxmoxPanel listens on port 80 (HTTP). In production, place it behind a reverse proxy for TLS termination.
|
||||
|
||||
Example Traefik dynamic configuration:
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
proxmoxpanel:
|
||||
rule: "Host(`panel.example.com`)"
|
||||
entryPoints: [websecure]
|
||||
service: proxmoxpanel
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
proxmoxpanel:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://<panel-host>:80"
|
||||
healthCheck:
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All API routes are prefixed with `/api/`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/health` | — | Health check |
|
||||
| GET | `/api/install/check` | — | Installation status |
|
||||
| GET | `/api/install/status` | — | Detected URL, pre-fill wizard |
|
||||
| POST | `/api/install/test-ssh` | — | Test SSH connectivity |
|
||||
| POST | `/api/install/configure` | — | Save initial configuration |
|
||||
| POST | `/api/auth/login` | — | Login (PAM via SSH) |
|
||||
| POST | `/api/auth/logout` | JWT | Logout |
|
||||
| POST | `/api/auth/refresh` | Cookie | Refresh access token |
|
||||
| GET | `/api/auth/me` | JWT | Current user profile |
|
||||
| PATCH | `/api/auth/preferences` | JWT | Update user preferences |
|
||||
| GET | `/api/proxmox/resources` | JWT | All Proxmox resources |
|
||||
| GET | `/api/proxmox/lxc` | JWT | LXC list |
|
||||
| POST | `/api/proxmox/lxc/{vmid}/start` | JWT+Admin | Start LXC |
|
||||
| POST | `/api/proxmox/lxc/{vmid}/stop` | JWT+Admin | Stop LXC |
|
||||
| POST | `/api/updates/run` | JWT+Admin | Start update job |
|
||||
| GET | `/api/updates/history` | JWT | Update history |
|
||||
| GET | `/api/settings` | JWT+Admin | All settings |
|
||||
| PUT | `/api/settings/{key}` | JWT+Admin | Update a setting |
|
||||
| GET | `/api/settings/audit` | JWT+Admin | Audit log |
|
||||
| GET | `/api/modules` | JWT | Module list |
|
||||
| POST | `/api/modules/{id}/enable` | JWT+Admin | Enable a module |
|
||||
| POST | `/api/modules/{id}/disable` | JWT+Admin | Disable a module |
|
||||
|
||||
## Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go run .
|
||||
# or
|
||||
go build -o proxmoxpanel . && ./proxmoxpanel
|
||||
```
|
||||
|
||||
Environment variables (all optional):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATA_DIR` | `/app/data` | Path to persistent data directory |
|
||||
| `LISTEN_ADDR` | `:3001` | HTTP listen address |
|
||||
| `APP_ENV` | `production` | Set to `development` for verbose logs |
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server with proxy to backend at localhost:3001
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE)
|
||||
48
backend/Dockerfile
Normal file
48
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# ── Étape 1 : Build du binaire Go ──────────────────────────────────────────
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
# Dépendances de compilation (git pour les modules Go)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copier les fichiers de dépendances en premier (optimise le cache Docker)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copier tout le code source
|
||||
COPY . .
|
||||
|
||||
# Compiler le binaire de façon statique
|
||||
# -ldflags="-s -w" : supprime les infos de debug pour réduire la taille
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o /bin/proxmoxpanel ./
|
||||
|
||||
# ── Étape 2 : Image finale minimale ────────────────────────────────────────
|
||||
FROM alpine:3.20
|
||||
|
||||
# Certificats CA pour les requêtes HTTPS vers l'API Proxmox
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Créer un utilisateur non-root pour la sécurité
|
||||
RUN addgroup -g 1001 pxp && adduser -u 1001 -G pxp -s /bin/sh -D pxp
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copier le binaire compilé
|
||||
COPY --from=builder /bin/proxmoxpanel /app/proxmoxpanel
|
||||
|
||||
# Créer les répertoires de données avec les bonnes permissions
|
||||
RUN mkdir -p /app/data && chown -R pxp:pxp /app
|
||||
|
||||
USER pxp
|
||||
|
||||
# Port d'écoute du backend
|
||||
EXPOSE 3001
|
||||
|
||||
# Variables d'environnement par défaut
|
||||
ENV DATA_DIR=/app/data \
|
||||
LISTEN_ADDR=:3001 \
|
||||
APP_ENV=production
|
||||
|
||||
CMD ["/app/proxmoxpanel"]
|
||||
23
backend/go.mod
Normal file
23
backend/go.mod
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
module git.geronzi.fr/proxmoxPanel/core/backend
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/crypto v0.49.0
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
61
backend/go.sum
Normal file
61
backend/go.sum
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
297
backend/internal/api/auth.go
Normal file
297
backend/internal/api/auth.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// Handlers d'authentification : login PAM, logout, refresh token, profil utilisateur.
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
)
|
||||
|
||||
// AuthHandler contient les handlers d'authentification.
|
||||
type AuthHandler struct {
|
||||
db *db.DB
|
||||
jwtManager *auth.JWTManager
|
||||
sshAuth *auth.SSHAuthenticator
|
||||
auditLogger *audit.Logger
|
||||
authLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// NewAuthHandler crée un AuthHandler.
|
||||
func NewAuthHandler(database *db.DB, jwtMgr *auth.JWTManager, sshAuth *auth.SSHAuthenticator, auditLog *audit.Logger) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
db: database,
|
||||
jwtManager: jwtMgr,
|
||||
sshAuth: sshAuth,
|
||||
auditLogger: auditLog,
|
||||
authLimiter: NewRateLimiter(5, time.Minute), // 5 tentatives par minute par IP
|
||||
}
|
||||
}
|
||||
|
||||
// Login authentifie un utilisateur via ses credentials Linux (PAM via SSH).
|
||||
// POST /api/auth/login
|
||||
// Body: { "username": "enzo", "password": "..." }
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
|
||||
// Rate limiting sur le login
|
||||
if !h.authLimiter.Allow(ip) {
|
||||
h.auditLogger.Log(nil, "?", "login_rate_limited", "", nil, ip)
|
||||
JSONError(w, "Trop de tentatives de connexion, veuillez patienter", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Username == "" || body.Password == "" {
|
||||
JSONError(w, "Nom d'utilisateur et mot de passe requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Authentification PAM via SSH
|
||||
userInfo, err := h.sshAuth.Authenticate(body.Username, body.Password)
|
||||
if err != nil {
|
||||
h.auditLogger.Log(nil, body.Username, "login_failed", "", map[string]string{"error": err.Error()}, ip)
|
||||
JSONError(w, "Identifiants invalides", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour le profil utilisateur en SQLite
|
||||
userID, err := h.upsertUser(userInfo)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur création profil utilisateur", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les tokens JWT
|
||||
accessToken, err := h.jwtManager.GenerateAccessToken(userID, userInfo.Username, userInfo.IsAdmin)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur génération token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := h.jwtManager.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur génération refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker le hash du refresh token en base pour permettre la révocation
|
||||
tokenHash := hashToken(refreshToken)
|
||||
expiry := time.Now().Add(auth.RefreshTokenDuration())
|
||||
h.db.Exec(`
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)
|
||||
`, userID, tokenHash, expiry)
|
||||
|
||||
// Mettre à jour la date de dernier login
|
||||
h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
|
||||
|
||||
// Cookie httpOnly pour le refresh token
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pxp_refresh",
|
||||
Value: refreshToken,
|
||||
Path: "/api/auth/refresh",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiry,
|
||||
})
|
||||
|
||||
h.auditLogger.Log(&userID, userInfo.Username, "login_success", "", nil, ip)
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"access_token": accessToken,
|
||||
"expires_in": 900, // 15 minutes en secondes
|
||||
"user": map[string]any{
|
||||
"id": userID,
|
||||
"username": userInfo.Username,
|
||||
"is_admin": userInfo.IsAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Logout invalide la session de l'utilisateur.
|
||||
// POST /api/auth/logout
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
// Supprimer tous les refresh tokens de cet utilisateur
|
||||
if claims != nil {
|
||||
h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID)
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r))
|
||||
}
|
||||
|
||||
// Effacer le cookie de refresh
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pxp_refresh",
|
||||
Value: "",
|
||||
Path: "/api/auth/refresh",
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// Refresh renouvelle l'access token via le refresh token (cookie httpOnly).
|
||||
// POST /api/auth/refresh
|
||||
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("pxp_refresh")
|
||||
if err != nil {
|
||||
JSONError(w, "Refresh token manquant", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value)
|
||||
if err != nil {
|
||||
JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le token est en base (non révoqué)
|
||||
tokenHash := hashToken(cookie.Value)
|
||||
var count int
|
||||
h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, userID, tokenHash).Scan(&count)
|
||||
if count == 0 {
|
||||
JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les infos utilisateur
|
||||
var username string
|
||||
var isAdmin int
|
||||
err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin)
|
||||
if err != nil {
|
||||
JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := h.jwtManager.GenerateAccessToken(userID, username, isAdmin == 1)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur génération token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"access_token": accessToken,
|
||||
"expires_in": 900,
|
||||
})
|
||||
}
|
||||
|
||||
// Me retourne le profil de l'utilisateur connecté.
|
||||
// GET /api/auth/me
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
if claims == nil {
|
||||
JSONError(w, "Non authentifié", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var lang, theme, sidebarPos string
|
||||
var lastLogin sql.NullTime
|
||||
err := h.db.QueryRow(`SELECT lang, theme, sidebar_position, last_login_at FROM users WHERE id = ?`, claims.UserID).
|
||||
Scan(&lang, &theme, &sidebarPos, &lastLogin)
|
||||
if err != nil {
|
||||
JSONError(w, "Profil introuvable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"is_admin": claims.IsAdmin,
|
||||
"lang": lang,
|
||||
"theme": theme,
|
||||
"sidebar_position": sidebarPos,
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
resp["last_login_at"] = lastLogin.Time
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdatePreferences met à jour les préférences de l'utilisateur connecté.
|
||||
// PATCH /api/auth/preferences
|
||||
func (h *AuthHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
var body struct {
|
||||
Lang *string `json:"lang"`
|
||||
Theme *string `json:"theme"`
|
||||
SidebarPosition *string `json:"sidebar_position"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Lang != nil {
|
||||
if !isValidLang(*body.Lang) {
|
||||
JSONError(w, "Langue non supportée", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.db.Exec(`UPDATE users SET lang = ? WHERE id = ?`, *body.Lang, claims.UserID)
|
||||
}
|
||||
if body.Theme != nil {
|
||||
if *body.Theme != "dark" && *body.Theme != "light" {
|
||||
JSONError(w, "Thème invalide (dark ou light)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.db.Exec(`UPDATE users SET theme = ? WHERE id = ?`, *body.Theme, claims.UserID)
|
||||
}
|
||||
if body.SidebarPosition != nil {
|
||||
if *body.SidebarPosition != "left" && *body.SidebarPosition != "right" {
|
||||
JSONError(w, "Position sidebar invalide (left ou right)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.db.Exec(`UPDATE users SET sidebar_position = ? WHERE id = ?`, *body.SidebarPosition, claims.UserID)
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Préférences mises à jour"})
|
||||
}
|
||||
|
||||
// upsertUser crée ou met à jour le profil utilisateur en SQLite.
|
||||
// Retourne l'ID de l'utilisateur.
|
||||
func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) {
|
||||
isAdmin := 0
|
||||
if info.IsAdmin {
|
||||
isAdmin = 1
|
||||
}
|
||||
|
||||
// Mise à jour du statut admin à chaque connexion (peut changer côté Linux)
|
||||
result, err := h.db.Exec(`
|
||||
INSERT INTO users (username, is_admin) VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin
|
||||
`, info.Username, isAdmin)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Tenter de récupérer l'ID (insertions ou update)
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil || id == 0 {
|
||||
// En cas de ON CONFLICT DO UPDATE, LastInsertId peut retourner 0
|
||||
err = h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// hashToken crée un hash SHA-256 d'un token pour le stockage en base.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
16
backend/internal/api/helpers.go
Normal file
16
backend/internal/api/helpers.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Fonctions utilitaires partagées entre les handlers API.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// decodeJSON décode le corps JSON d'une requête dans dest.
|
||||
// Retourne une erreur si le corps est invalide ou manquant.
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
if r.Body == nil {
|
||||
return json.NewDecoder(r.Body).Decode(dest)
|
||||
}
|
||||
return json.NewDecoder(r.Body).Decode(dest)
|
||||
}
|
||||
233
backend/internal/api/install.go
Normal file
233
backend/internal/api/install.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Handlers pour la page d'installation — premier lancement uniquement.
|
||||
// Ces routes sont accessibles sans authentification mais bloquées après installation.
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
)
|
||||
|
||||
// InstallHandler contient les handlers d'installation.
|
||||
type InstallHandler struct {
|
||||
db *db.DB
|
||||
encryptor *crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewInstallHandler crée un InstallHandler.
|
||||
func NewInstallHandler(database *db.DB, enc *crypto.Encryptor) *InstallHandler {
|
||||
return &InstallHandler{db: database, encryptor: enc}
|
||||
}
|
||||
|
||||
// GetStatus retourne l'état d'installation et les valeurs pré-remplies.
|
||||
// GET /api/install/status
|
||||
func (h *InstallHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
installed, err := h.db.IsInstalled()
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur base de données", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Pré-remplir l'URL publique depuis le header Host
|
||||
detectedURL := detectPublicURL(r)
|
||||
detectedPort := detectPort(r)
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"installed": installed,
|
||||
"detected_url": detectedURL,
|
||||
"detected_port": detectedPort,
|
||||
})
|
||||
}
|
||||
|
||||
// TestSSH teste la connexion SSH vers le host Proxmox.
|
||||
// POST /api/install/test-ssh
|
||||
// Body: { "host": "10.0.0.1:2244", "username": "enzo", "password": "..." }
|
||||
func (h *InstallHandler) TestSSH(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Host == "" || body.Username == "" || body.Password == "" {
|
||||
JSONError(w, "Paramètres host, username et password requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Valider le format host:port
|
||||
if _, _, err := net.SplitHostPort(body.Host); err != nil {
|
||||
JSONError(w, "Format host invalide (attendu: host:port)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Test de connectivité réseau d'abord
|
||||
if err := auth.TestConnectivity(body.Host, 5*time.Second); err != nil {
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Impossible de joindre %s : %v", body.Host, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Test d'authentification SSH
|
||||
if err := auth.TestSSHAuth(body.Host, body.Username, body.Password); err != nil {
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Connexion SSH réussie",
|
||||
})
|
||||
}
|
||||
|
||||
// TestProxmoxToken teste le token API Proxmox.
|
||||
// POST /api/install/test-proxmox
|
||||
// Body: { "url": "https://10.0.0.1:8006", "token": "PVEAPIToken=..." }
|
||||
func (h *InstallHandler) TestProxmoxToken(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Import dynamique évité — on laisse le handler proxmox gérer ça plus tard
|
||||
// Pour l'installation, on fait un test simple via HTTP
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Token enregistré (validation au prochain démarrage)",
|
||||
})
|
||||
}
|
||||
|
||||
// Configure enregistre la configuration initiale et marque l'app comme installée.
|
||||
// POST /api/install/configure
|
||||
func (h *InstallHandler) Configure(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
PublicURL string `json:"public_url"`
|
||||
DefaultLang string `json:"default_lang"`
|
||||
SSHHost string `json:"ssh_host"`
|
||||
SSHUsername string `json:"ssh_username"`
|
||||
SSHPassword string `json:"ssh_password"`
|
||||
ProxmoxURL string `json:"proxmox_url"`
|
||||
ProxmoxToken string `json:"proxmox_token"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validation basique
|
||||
if body.InstanceName == "" {
|
||||
JSONError(w, "Le nom de l'instance est requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.SSHHost == "" || body.SSHUsername == "" || body.SSHPassword == "" {
|
||||
JSONError(w, "Les paramètres SSH sont requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.DefaultLang == "" {
|
||||
body.DefaultLang = "en"
|
||||
}
|
||||
if !isValidLang(body.DefaultLang) {
|
||||
JSONError(w, "Langue non supportée (en ou fr)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres non-sensibles en clair
|
||||
settings := map[string]string{
|
||||
"instance_name": body.InstanceName,
|
||||
"public_url": body.PublicURL,
|
||||
"default_lang": body.DefaultLang,
|
||||
"proxmox_url": body.ProxmoxURL,
|
||||
"ssh_host": body.SSHHost,
|
||||
"ssh_username": body.SSHUsername,
|
||||
}
|
||||
for key, value := range settings {
|
||||
if err := h.db.SetSetting(key, value, false); err != nil {
|
||||
JSONError(w, "Erreur sauvegarde configuration : "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Chiffrer et sauvegarder les secrets sensibles
|
||||
if body.SSHPassword != "" {
|
||||
encrypted, err := h.encryptor.Encrypt(body.SSHPassword)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur chiffrement mot de passe SSH : "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.db.SetSetting("ssh_password", encrypted, true)
|
||||
}
|
||||
if body.ProxmoxToken != "" {
|
||||
encrypted, err := h.encryptor.Encrypt(body.ProxmoxToken)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur chiffrement token Proxmox : "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.db.SetSetting("proxmox_token", encrypted, true)
|
||||
}
|
||||
|
||||
// Marquer l'application comme installée
|
||||
if err := h.db.SetSetting("installed", "true", false); err != nil {
|
||||
JSONError(w, "Erreur finalisation installation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Installation terminée avec succès",
|
||||
})
|
||||
}
|
||||
|
||||
// detectPublicURL inférer l'URL publique depuis les headers de la requête entrante.
|
||||
func detectPublicURL(r *http.Request) string {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
proto := "https"
|
||||
if r.Header.Get("X-Forwarded-Proto") == "http" || (!strings.Contains(host, ".") && !strings.Contains(host, ":")) {
|
||||
proto = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", proto, host)
|
||||
}
|
||||
|
||||
// detectPort extrait le port depuis le header ou l'adresse de connexion.
|
||||
func detectPort(r *http.Request) string {
|
||||
host := r.Host
|
||||
if _, port, err := net.SplitHostPort(host); err == nil {
|
||||
return port
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
// isValidLang vérifie que le code langue est supporté.
|
||||
func isValidLang(lang string) bool {
|
||||
supported := []string{"en", "fr"}
|
||||
for _, l := range supported {
|
||||
if l == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
185
backend/internal/api/middleware.go
Normal file
185
backend/internal/api/middleware.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// Package api contient tous les handlers HTTP et les middlewares de ProxmoxPanel.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
)
|
||||
|
||||
// Clés de contexte pour transmettre les claims JWT aux handlers.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ClaimsKey contextKey = "claims"
|
||||
)
|
||||
|
||||
// RateLimiter est un simple rate limiter par IP basé sur un token bucket.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
maxReq int
|
||||
window time.Duration
|
||||
cleanTicker *time.Ticker
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter crée un rate limiter avec maxReq requêtes par fenêtre temporelle.
|
||||
func NewRateLimiter(maxReq int, window time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
maxReq: maxReq,
|
||||
window: window,
|
||||
cleanTicker: time.NewTicker(5 * time.Minute),
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
// Allow vérifie si une IP peut effectuer une requête supplémentaire.
|
||||
func (rl *RateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
b, exists := rl.buckets[ip]
|
||||
if !exists || time.Now().After(b.resetAt) {
|
||||
rl.buckets[ip] = &bucket{count: 1, resetAt: time.Now().Add(rl.window)}
|
||||
return true
|
||||
}
|
||||
if b.count >= rl.maxReq {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for range rl.cleanTicker.C {
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for ip, b := range rl.buckets {
|
||||
if now.After(b.resetAt) {
|
||||
delete(rl.buckets, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware sécurité : headers HTTP protecteurs.
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// CSP assez souple pour permettre les WebSockets et les assets locaux
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAuth est le middleware d'authentification JWT.
|
||||
// Il extrait et valide le Bearer token depuis l'en-tête Authorization.
|
||||
func RequireAuth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := extractBearerToken(r)
|
||||
if tokenStr == "" {
|
||||
JSONError(w, "Token d'authentification manquant", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
JSONError(w, "Token invalide ou expiré", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Injecter les claims dans le contexte
|
||||
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin vérifie que l'utilisateur connecté est administrateur.
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
if claims == nil || !claims.IsAdmin {
|
||||
JSONError(w, "Accès réservé aux administrateurs", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimit crée un middleware de rate limiting pour les endpoints sensibles.
|
||||
func RateLimit(limiter *RateLimiter) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if !limiter.Allow(ip) {
|
||||
JSONError(w, "Trop de requêtes, veuillez patienter", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetClaims extrait les claims JWT du contexte de la requête.
|
||||
func GetClaims(r *http.Request) *auth.Claims {
|
||||
claims, _ := r.Context().Value(ClaimsKey).(*auth.Claims)
|
||||
return claims
|
||||
}
|
||||
|
||||
// extractBearerToken extrait le token JWT depuis l'en-tête Authorization.
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
header := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(header, "Bearer ") {
|
||||
return strings.TrimPrefix(header, "Bearer ")
|
||||
}
|
||||
// Fallback sur le query param (pour les WebSockets qui ne supportent pas les headers custom)
|
||||
return r.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
// clientIP extrait l'IP réelle du client (en tenant compte des proxys).
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// JSONResponse envoie une réponse JSON avec le code HTTP donné.
|
||||
func JSONResponse(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// JSONError envoie une réponse d'erreur JSON standardisée.
|
||||
func JSONError(w http.ResponseWriter, message string, status int) {
|
||||
JSONResponse(w, status, map[string]string{"error": message})
|
||||
}
|
||||
217
backend/internal/api/proxmox.go
Normal file
217
backend/internal/api/proxmox.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// Handlers pour l'API Proxmox : liste LXC/VM, démarrage/arrêt, WebSocket temps réel.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/proxmox"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var upgrader = gorillaws.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
// ProxmoxHandler contient les handlers Proxmox.
|
||||
type ProxmoxHandler struct {
|
||||
db *db.DB
|
||||
hub *websocket.Hub
|
||||
auditLogger *audit.Logger
|
||||
encryptor *crypto.Encryptor
|
||||
client *proxmox.Client // Peut être nil si pas encore configuré
|
||||
}
|
||||
|
||||
// NewProxmoxHandler crée un ProxmoxHandler.
|
||||
func NewProxmoxHandler(database *db.DB, hub *websocket.Hub, auditLog *audit.Logger, enc *crypto.Encryptor) *ProxmoxHandler {
|
||||
h := &ProxmoxHandler{
|
||||
db: database,
|
||||
hub: hub,
|
||||
auditLogger: auditLog,
|
||||
encryptor: enc,
|
||||
}
|
||||
// Initialiser le client Proxmox depuis la config SQLite
|
||||
h.initClient()
|
||||
return h
|
||||
}
|
||||
|
||||
// initClient recharge le client Proxmox depuis les settings SQLite.
|
||||
func (h *ProxmoxHandler) initClient() {
|
||||
url, _, _ := h.db.GetSetting("proxmox_url")
|
||||
encryptedToken, _, _ := h.db.GetSetting("proxmox_token")
|
||||
if url == "" || encryptedToken == "" {
|
||||
return
|
||||
}
|
||||
token, err := h.encryptor.Decrypt(encryptedToken)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.client = proxmox.NewClient(url, token)
|
||||
}
|
||||
|
||||
// GetResources retourne la liste de toutes les ressources Proxmox (LXC + VM + nodes).
|
||||
// GET /api/proxmox/resources
|
||||
func (h *ProxmoxHandler) GetResources(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
if h.client == nil {
|
||||
JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
resources, err := h.client.GetResources("")
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur API Proxmox : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, resources)
|
||||
}
|
||||
|
||||
// GetLXC retourne uniquement les conteneurs LXC.
|
||||
// GET /api/proxmox/lxc
|
||||
func (h *ProxmoxHandler) GetLXC(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
if h.client == nil {
|
||||
JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
lxcs, err := h.client.GetLXCList()
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur API Proxmox : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, lxcs)
|
||||
}
|
||||
|
||||
// StartLXC démarre un conteneur LXC.
|
||||
// POST /api/proxmox/lxc/{vmid}/start
|
||||
func (h *ProxmoxHandler) StartLXC(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
vmid, node, err := h.extractVMID(r)
|
||||
if err != nil {
|
||||
JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
if h.client == nil {
|
||||
JSONError(w, "Proxmox non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.StartLXC(node, vmid); err != nil {
|
||||
JSONError(w, "Erreur démarrage LXC : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "lxc_start", strconv.Itoa(vmid), nil, clientIP(r))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "LXC démarré"})
|
||||
}
|
||||
|
||||
// StopLXC arrête un conteneur LXC.
|
||||
// POST /api/proxmox/lxc/{vmid}/stop
|
||||
func (h *ProxmoxHandler) StopLXC(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
vmid, node, err := h.extractVMID(r)
|
||||
if err != nil {
|
||||
JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
}
|
||||
|
||||
if err := h.client.StopLXC(node, vmid); err != nil {
|
||||
JSONError(w, "Erreur arrêt LXC : "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "lxc_stop", strconv.Itoa(vmid), nil, clientIP(r))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "LXC arrêté"})
|
||||
}
|
||||
|
||||
// WebSocket retourne un WebSocket qui envoie les mises à jour Proxmox en temps réel.
|
||||
// GET /ws/proxmox
|
||||
func (h *ProxmoxHandler) WebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
claims := GetClaims(r)
|
||||
var userID int64
|
||||
if claims != nil {
|
||||
userID = claims.UserID
|
||||
}
|
||||
|
||||
client := h.hub.NewClient(conn, userID)
|
||||
client.Subscribe("proxmox")
|
||||
}
|
||||
|
||||
// StartPolling démarre le polling périodique de l'API Proxmox et publie les updates via WebSocket.
|
||||
// À appeler au démarrage du serveur.
|
||||
func (h *ProxmoxHandler) StartPolling() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if h.client == nil {
|
||||
h.initClient()
|
||||
if h.client == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
resources, err := h.client.GetResources("")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
h.hub.Publish("proxmox", "resources_update", resources)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// extractVMID extrait l'ID de VM et le nom du nœud depuis l'URL et les query params.
|
||||
func (h *ProxmoxHandler) extractVMID(r *http.Request) (int, string, error) {
|
||||
vmidStr := chi.URLParam(r, "vmid")
|
||||
vmid, err := strconv.Atoi(vmidStr)
|
||||
if err != nil {
|
||||
return 0, "", &invalidParamError{param: "vmid", value: vmidStr}
|
||||
}
|
||||
|
||||
node := r.URL.Query().Get("node")
|
||||
if node == "" {
|
||||
node = "pve" // Nœud par défaut
|
||||
}
|
||||
|
||||
return vmid, node, nil
|
||||
}
|
||||
|
||||
type invalidParamError struct {
|
||||
param string
|
||||
value string
|
||||
}
|
||||
|
||||
func (e *invalidParamError) Error() string {
|
||||
return "Paramètre invalide : " + e.param + " = " + e.value
|
||||
}
|
||||
206
backend/internal/api/settings.go
Normal file
206
backend/internal/api/settings.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
// Handlers pour la page paramètres : lecture/écriture de la configuration globale.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// SettingsHandler contient les handlers de configuration.
|
||||
type SettingsHandler struct {
|
||||
db *db.DB
|
||||
auditLogger *audit.Logger
|
||||
}
|
||||
|
||||
// NewSettingsHandler crée un SettingsHandler.
|
||||
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger) *SettingsHandler {
|
||||
return &SettingsHandler{db: database, auditLogger: auditLog}
|
||||
}
|
||||
|
||||
// paramètres publics (non-sensibles) accessibles par les admins.
|
||||
var publicSettings = []string{
|
||||
"instance_name",
|
||||
"public_url",
|
||||
"default_lang",
|
||||
"proxmox_url",
|
||||
"ssh_host",
|
||||
"ssh_username",
|
||||
}
|
||||
|
||||
// GetAll retourne tous les paramètres publics de l'application.
|
||||
// GET /api/settings
|
||||
func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) {
|
||||
result := make(map[string]string)
|
||||
for _, key := range publicSettings {
|
||||
value, _, err := h.db.GetSetting(key)
|
||||
if err == nil {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
JSONResponse(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// UpdateSetting met à jour un paramètre spécifique.
|
||||
// PUT /api/settings/{key}
|
||||
// Body: { "value": "..." }
|
||||
func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
if key == "" {
|
||||
JSONError(w, "Clé de paramètre manquante", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que la clé est modifiable
|
||||
allowed := false
|
||||
for _, k := range publicSettings {
|
||||
if k == key {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
JSONError(w, "Paramètre non modifiable via l'API", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
JSONError(w, "Corps de requête invalide", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.SetSetting(key, body.Value, false); err != nil {
|
||||
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key,
|
||||
map[string]string{"key": key}, clientIP(r))
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
|
||||
}
|
||||
|
||||
// GetModules retourne la liste de tous les modules et leur état.
|
||||
// GET /api/modules
|
||||
func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.db.Query(`
|
||||
SELECT id, name, description, version, is_core, is_enabled, installed_at
|
||||
FROM modules ORDER BY is_core DESC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur lecture modules", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type module struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
IsCore bool `json:"is_core"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
InstalledAt *string `json:"installed_at,omitempty"`
|
||||
}
|
||||
|
||||
var modules []module
|
||||
for rows.Next() {
|
||||
var m module
|
||||
var isCore, isEnabled int
|
||||
var installedAt *string
|
||||
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &installedAt)
|
||||
m.IsCore = isCore == 1
|
||||
m.IsEnabled = isEnabled == 1
|
||||
m.InstalledAt = installedAt
|
||||
modules = append(modules, m)
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, modules)
|
||||
}
|
||||
|
||||
// EnableModule active un module.
|
||||
// POST /api/modules/{id}/enable
|
||||
func (h *SettingsHandler) EnableModule(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
result, err := h.db.Exec(`UPDATE modules SET is_enabled = 1 WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur activation module", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
JSONError(w, "Module introuvable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "module_enable", id, nil, clientIP(r))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module activé (redémarrage requis pour prendre effet)"})
|
||||
}
|
||||
|
||||
// DisableModule désactive un module (ne peut pas désactiver les modules CORE).
|
||||
// POST /api/modules/{id}/disable
|
||||
func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Vérifier que ce n'est pas un module CORE
|
||||
var isCore int
|
||||
if err := h.db.QueryRow(`SELECT is_core FROM modules WHERE id = ?`, id).Scan(&isCore); err != nil {
|
||||
JSONError(w, "Module introuvable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if isCore == 1 {
|
||||
JSONError(w, "Les modules CORE ne peuvent pas être désactivés", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
h.db.Exec(`UPDATE modules SET is_enabled = 0 WHERE id = ?`, id)
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "module_disable", id, nil, clientIP(r))
|
||||
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"})
|
||||
}
|
||||
|
||||
// GetAuditLog retourne le journal d'audit paginé.
|
||||
// GET /api/settings/audit
|
||||
func (h *SettingsHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.db.Query(`
|
||||
SELECT id, username, action, resource, details, ip, created_at
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT 100
|
||||
`)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur lecture audit", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Action string `json:"action"`
|
||||
Resource *string `json:"resource,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
IP *string `json:"ip,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var entries []entry
|
||||
for rows.Next() {
|
||||
var e entry
|
||||
var resource, details, ip *string
|
||||
rows.Scan(&e.ID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt)
|
||||
e.Resource = resource
|
||||
e.Details = details
|
||||
e.IP = ip
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, entries)
|
||||
}
|
||||
151
backend/internal/api/terminal.go
Normal file
151
backend/internal/api/terminal.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// Handler pour le terminal SSH interactif via WebSocket + PTY.
|
||||
// Utilise golang.org/x/crypto/ssh pour la connexion et gorilla/websocket pour le transport.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// TerminalHandler gère les sessions de terminal SSH interactif.
|
||||
type TerminalHandler struct {
|
||||
db *db.DB
|
||||
auditLogger *audit.Logger
|
||||
encryptor *crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewTerminalHandler crée un TerminalHandler.
|
||||
func NewTerminalHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *TerminalHandler {
|
||||
return &TerminalHandler{db: database, auditLogger: auditLog, encryptor: enc}
|
||||
}
|
||||
|
||||
// terminalCmd représente un message de contrôle envoyé via WebSocket.
|
||||
type terminalCmd struct {
|
||||
Type string `json:"type"` // "resize" | "data"
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocket ouvre un terminal SSH interactif via WebSocket.
|
||||
// GET /ws/terminal
|
||||
// Query params: host (optionnel, défaut = ssh_host depuis config)
|
||||
func (h *TerminalHandler) WebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
// Connexion WebSocket
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Récupérer les params SSH
|
||||
sshHost := r.URL.Query().Get("host")
|
||||
if sshHost == "" {
|
||||
sshHost, _, _ = h.db.GetSetting("ssh_host")
|
||||
}
|
||||
sshUser, _, _ := h.db.GetSetting("ssh_username")
|
||||
encryptedPass, _, _ := h.db.GetSetting("ssh_password")
|
||||
sshPass, _ := h.encryptor.Decrypt(encryptedPass)
|
||||
|
||||
if sshHost == "" {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte("\r\nErreur : SSH non configuré\r\n"))
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_open", sshHost, nil, clientIP(r))
|
||||
|
||||
// Établir la connexion SSH
|
||||
sshConfig := &gossh.ClientConfig{
|
||||
User: sshUser,
|
||||
Auth: []gossh.AuthMethod{
|
||||
gossh.Password(sshPass),
|
||||
},
|
||||
Timeout: 15 * time.Second,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
sshClient, err := gossh.Dial("tcp", sshHost, sshConfig)
|
||||
if err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur SSH : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
// Créer une session SSH avec pseudo-terminal
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur session SSH : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Configurer le PTY (terminal 80x24 par défaut)
|
||||
modes := gossh.TerminalModes{
|
||||
gossh.ECHO: 1,
|
||||
gossh.TTY_OP_ISPEED: 14400,
|
||||
gossh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur PTY : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Pipes stdin/stdout entre WebSocket et SSH
|
||||
stdinPipe, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stdoutPipe, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
conn.WriteMessage(gorillaws.TextMessage, []byte(fmt.Sprintf("\r\nErreur shell : %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Goroutine : SSH stdout → WebSocket
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := stdoutPipe.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
conn.WriteMessage(gorillaws.BinaryMessage, buf[:n])
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Boucle principale : WebSocket → SSH stdin
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Détecter les messages de contrôle JSON (ex: resize)
|
||||
if len(msg) > 0 && msg[0] == '{' {
|
||||
var cmd terminalCmd
|
||||
if json.Unmarshal(msg, &cmd) == nil && cmd.Type == "resize" && cmd.Cols > 0 && cmd.Rows > 0 {
|
||||
session.WindowChange(cmd.Rows, cmd.Cols)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Données brutes → stdin SSH
|
||||
stdinPipe.Write(msg)
|
||||
}
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "terminal_close", sshHost, nil, clientIP(r))
|
||||
}
|
||||
196
backend/internal/api/updates.go
Normal file
196
backend/internal/api/updates.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Handlers pour les mises à jour de paquets apt.
|
||||
// Supporte : host Proxmox, un LXC spécifique, ou tous les LXC.
|
||||
// La sortie est streamée ligne par ligne via WebSocket.
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// UpdatesHandler contient les handlers de mises à jour.
|
||||
type UpdatesHandler struct {
|
||||
db *db.DB
|
||||
sshPool *ssh.Pool
|
||||
hub *websocket.Hub
|
||||
auditLogger *audit.Logger
|
||||
encryptor *crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewUpdatesHandler crée un UpdatesHandler.
|
||||
func NewUpdatesHandler(database *db.DB, sshPool *ssh.Pool, hub *websocket.Hub, auditLog *audit.Logger, enc *crypto.Encryptor) *UpdatesHandler {
|
||||
return &UpdatesHandler{
|
||||
db: database,
|
||||
sshPool: sshPool,
|
||||
hub: hub,
|
||||
auditLogger: auditLog,
|
||||
encryptor: enc,
|
||||
}
|
||||
}
|
||||
|
||||
// RunUpdate lance une mise à jour apt sur la cible spécifiée.
|
||||
// POST /api/updates/run
|
||||
// Body: { "target": "host" | "lxc:100" | "all" }
|
||||
func (h *UpdatesHandler) RunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil || body.Target == "" {
|
||||
JSONError(w, "Paramètre 'target' requis (host, lxc:ID, ou all)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les credentials SSH depuis les settings
|
||||
sshHost, _, _ := h.db.GetSetting("ssh_host")
|
||||
sshUser, _, _ := h.db.GetSetting("ssh_username")
|
||||
encryptedPass, _, _ := h.db.GetSetting("ssh_password")
|
||||
sshPass, _ := h.encryptor.Decrypt(encryptedPass)
|
||||
if sshHost == "" || sshUser == "" || sshPass == "" {
|
||||
JSONError(w, "SSH non configuré", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Générer un ID de job unique
|
||||
jobID := generateJobID()
|
||||
|
||||
// Enregistrer le job en base
|
||||
h.db.Exec(`
|
||||
INSERT INTO update_history (job_id, target, status, started_by) VALUES (?, ?, 'running', ?)
|
||||
`, jobID, body.Target, claims.UserID)
|
||||
|
||||
h.auditLogger.Log(&claims.UserID, claims.Username, "update_start", body.Target, nil, clientIP(r))
|
||||
|
||||
// Lancer la mise à jour en arrière-plan
|
||||
go h.executeUpdate(jobID, body.Target, sshHost, sshUser, sshPass, claims.UserID)
|
||||
|
||||
JSONResponse(w, http.StatusAccepted, map[string]string{
|
||||
"job_id": jobID,
|
||||
"message": "Mise à jour démarrée",
|
||||
})
|
||||
}
|
||||
|
||||
// GetHistory retourne l'historique des mises à jour.
|
||||
// GET /api/updates/history
|
||||
func (h *UpdatesHandler) GetHistory(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.db.Query(`
|
||||
SELECT job_id, target, status, output, started_at, finished_at
|
||||
FROM update_history
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50
|
||||
`)
|
||||
if err != nil {
|
||||
JSONError(w, "Erreur lecture historique", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type entry struct {
|
||||
JobID string `json:"job_id"`
|
||||
Target string `json:"target"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
var entries []entry
|
||||
for rows.Next() {
|
||||
var e entry
|
||||
var finishedAt *string
|
||||
rows.Scan(&e.JobID, &e.Target, &e.Status, &e.Output, &e.StartedAt, &finishedAt)
|
||||
e.FinishedAt = finishedAt
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// WebSocketUpdate ouvre un WebSocket pour suivre un job de mise à jour en temps réel.
|
||||
// GET /ws/updates/{jobId}
|
||||
func (h *UpdatesHandler) WebSocketUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
claims := GetClaims(r)
|
||||
var userID int64
|
||||
if claims != nil {
|
||||
userID = claims.UserID
|
||||
}
|
||||
|
||||
jobID := chi.URLParam(r, "jobId")
|
||||
wsClient := h.hub.NewClient(conn, userID)
|
||||
wsClient.Subscribe("update:" + jobID)
|
||||
}
|
||||
|
||||
// executeUpdate exécute la commande apt et streame la sortie via WebSocket.
|
||||
func (h *UpdatesHandler) executeUpdate(jobID, target, sshHost, sshUser, sshPass string, userID int64) {
|
||||
outputChan := make(chan string, 100)
|
||||
var command string
|
||||
|
||||
switch {
|
||||
case target == "host":
|
||||
command = "DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y"
|
||||
|
||||
case len(target) > 4 && target[:4] == "lxc:":
|
||||
lxcID := target[4:]
|
||||
command = fmt.Sprintf(
|
||||
"pct exec %s -- bash -c 'DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y'",
|
||||
lxcID,
|
||||
)
|
||||
|
||||
case target == "all":
|
||||
command = `for ct in $(pct list | awk 'NR>1 {print $1}'); do
|
||||
echo "=== LXC $ct ==="
|
||||
pct exec $ct -- bash -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y' 2>/dev/null || echo "SKIP LXC $ct"
|
||||
done`
|
||||
|
||||
default:
|
||||
h.db.Exec(`UPDATE update_history SET status='error', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`,
|
||||
"Cible invalide : "+target, jobID)
|
||||
return
|
||||
}
|
||||
|
||||
// Lancer le streaming SSH
|
||||
err := h.sshPool.StreamCommand(sshHost, sshUser, sshPass, command, outputChan)
|
||||
if err != nil {
|
||||
h.db.Exec(`UPDATE update_history SET status='error', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`,
|
||||
"Erreur SSH : "+err.Error(), jobID)
|
||||
h.hub.Publish("update:"+jobID, "update_error", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Collecter la sortie et la publier ligne par ligne
|
||||
var fullOutput string
|
||||
for chunk := range outputChan {
|
||||
fullOutput += chunk
|
||||
h.hub.Publish("update:"+jobID, "update_output", map[string]string{"chunk": chunk})
|
||||
}
|
||||
|
||||
// Finaliser le job
|
||||
h.db.Exec(`UPDATE update_history SET status='success', output=?, finished_at=CURRENT_TIMESTAMP WHERE job_id=?`,
|
||||
fullOutput, jobID)
|
||||
h.hub.Publish("update:"+jobID, "update_done", map[string]string{"job_id": jobID})
|
||||
}
|
||||
|
||||
// generateJobID génère un identifiant unique pour un job de mise à jour.
|
||||
func generateJobID() string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return fmt.Sprintf("%d-%s", time.Now().Unix(), string(b))
|
||||
}
|
||||
81
backend/internal/audit/audit.go
Normal file
81
backend/internal/audit/audit.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Package audit fournit le journal d'audit de ProxmoxPanel.
|
||||
// Toutes les actions sensibles (connexion, mises à jour, modifications config) y sont tracées.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry représente une entrée dans le journal d'audit.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Logger est le service d'audit.
|
||||
type Logger struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New crée un nouveau Logger d'audit.
|
||||
func New(db *sql.DB) *Logger {
|
||||
return &Logger{db: db}
|
||||
}
|
||||
|
||||
// Log enregistre une action dans le journal d'audit.
|
||||
func (l *Logger) Log(userID *int64, username, action, resource string, details any, ip string) {
|
||||
var detailsStr string
|
||||
if details != nil {
|
||||
if s, ok := details.(string); ok {
|
||||
detailsStr = s
|
||||
} else if data, err := json.Marshal(details); err == nil {
|
||||
detailsStr = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Insertion non bloquante — on ignore les erreurs pour ne pas perturber le flux principal
|
||||
l.db.Exec(`
|
||||
INSERT INTO audit_log (user_id, username, action, resource, details, ip, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, userID, username, action, resource, detailsStr, ip)
|
||||
}
|
||||
|
||||
// GetEntries retourne les dernières entrées du journal, paginées.
|
||||
func (l *Logger) GetEntries(limit, offset int) ([]Entry, error) {
|
||||
rows, err := l.db.Query(`
|
||||
SELECT id, user_id, username, action, resource, details, ip, created_at
|
||||
FROM audit_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []Entry
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var userID sql.NullInt64
|
||||
var resource, details, ip sql.NullString
|
||||
if err := rows.Scan(&e.ID, &userID, &e.Username, &e.Action, &resource, &details, &ip, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if userID.Valid {
|
||||
e.UserID = &userID.Int64
|
||||
}
|
||||
e.Resource = resource.String
|
||||
e.Details = details.String
|
||||
e.IP = ip.String
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
178
backend/internal/auth/jwt.go
Normal file
178
backend/internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// Package auth gère les tokens JWT RS256 pour les sessions utilisateurs.
|
||||
// Les clés RSA sont générées automatiquement au premier démarrage et stockées sur disque.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
rsaKeySize = 2048
|
||||
)
|
||||
|
||||
// Claims représente le contenu d'un JWT d'accès ProxmoxPanel.
|
||||
type Claims struct {
|
||||
UserID int64 `json:"uid"`
|
||||
Username string `json:"sub"`
|
||||
IsAdmin bool `json:"admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JWTManager gère la signature et la vérification des tokens JWT.
|
||||
type JWTManager struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
// NewJWTManager charge ou génère les clés RSA, et retourne un JWTManager prêt à l'emploi.
|
||||
func NewJWTManager(dataDir string) (*JWTManager, error) {
|
||||
keysDir := filepath.Join(dataDir, "keys")
|
||||
if err := os.MkdirAll(keysDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire clés : %w", err)
|
||||
}
|
||||
|
||||
privPath := filepath.Join(keysDir, "jwt.key")
|
||||
pubPath := filepath.Join(keysDir, "jwt.pub")
|
||||
|
||||
var privKey *rsa.PrivateKey
|
||||
|
||||
if _, err := os.Stat(privPath); os.IsNotExist(err) {
|
||||
// Générer une paire de clés RSA-2048
|
||||
privKey, err = rsa.GenerateKey(rand.Reader, rsaKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("génération clés RSA : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder la clé privée en PEM
|
||||
privPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
if err := os.WriteFile(privPath, privPEM, 0600); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé privée : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder la clé publique en PEM
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export clé publique : %w", err)
|
||||
}
|
||||
pubPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pubBytes,
|
||||
})
|
||||
if err := os.WriteFile(pubPath, pubPEM, 0644); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé publique : %w", err)
|
||||
}
|
||||
} else {
|
||||
// Charger la clé privée existante
|
||||
privPEM, err := os.ReadFile(privPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture clé privée : %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(privPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("clé privée invalide (PEM)")
|
||||
}
|
||||
privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing clé privée : %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &JWTManager{
|
||||
privateKey: privKey,
|
||||
publicKey: &privKey.PublicKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateAccessToken crée un JWT d'accès signé RS256 (durée : 15 min).
|
||||
func (m *JWTManager) GenerateAccessToken(userID int64, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "proxmoxpanel",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(m.privateKey)
|
||||
}
|
||||
|
||||
// GenerateRefreshToken crée un token de renouvellement (durée : 7 jours).
|
||||
// Ce token est plus simple — il ne contient que le userID et l'expiration.
|
||||
func (m *JWTManager) GenerateRefreshToken(userID int64) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprintf("%d", userID),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "proxmoxpanel-refresh",
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(m.privateKey)
|
||||
}
|
||||
|
||||
// ValidateAccessToken vérifie et décode un JWT d'accès.
|
||||
func (m *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validation token : %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("token invalide")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken vérifie un token de renouvellement et retourne le userID.
|
||||
func (m *JWTManager) ValidateRefreshToken(tokenStr string) (int64, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("validation refresh token : %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, errors.New("refresh token invalide")
|
||||
}
|
||||
|
||||
if claims.Issuer != "proxmoxpanel-refresh" {
|
||||
return 0, errors.New("émetteur token invalide")
|
||||
}
|
||||
|
||||
var userID int64
|
||||
fmt.Sscanf(claims.Subject, "%d", &userID)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// RefreshTokenDuration retourne la durée de validité du refresh token.
|
||||
func RefreshTokenDuration() time.Duration {
|
||||
return refreshTokenDuration
|
||||
}
|
||||
129
backend/internal/auth/ssh_auth.go
Normal file
129
backend/internal/auth/ssh_auth.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Package auth — Authentification PAM via SSH.
|
||||
// Au lieu de monter les fichiers système du host (/etc/shadow), on tente une connexion
|
||||
// SSH avec les credentials de l'utilisateur. Si elle réussit, les credentials sont valides.
|
||||
// L'appartenance au groupe sudo/wheel détermine le niveau admin.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// UserInfo contient les informations d'un utilisateur authentifié.
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// SSHAuthenticator gère l'authentification des utilisateurs via SSH vers le host Proxmox.
|
||||
type SSHAuthenticator struct {
|
||||
host string // ex: "10.0.0.1:2244"
|
||||
}
|
||||
|
||||
// NewSSHAuthenticator crée un authentificateur SSH pour le host donné.
|
||||
func NewSSHAuthenticator(host string) *SSHAuthenticator {
|
||||
return &SSHAuthenticator{host: host}
|
||||
}
|
||||
|
||||
// Authenticate tente une connexion SSH avec les credentials fournis.
|
||||
// Si la connexion réussit, retourne les informations de l'utilisateur.
|
||||
// Vérifie l'appartenance au groupe sudo ou wheel pour déterminer le niveau admin.
|
||||
func (a *SSHAuthenticator) Authenticate(username, password string) (*UserInfo, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
// Timeout court pour l'authentification
|
||||
Timeout: 10 * time.Second,
|
||||
// Accepter n'importe quelle clé host (le host est sur le réseau interne de confiance)
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
// Tentative de connexion SSH
|
||||
client, err := ssh.Dial("tcp", a.host, config)
|
||||
if err != nil {
|
||||
// Distinguer les erreurs d'authentification des erreurs réseau
|
||||
if strings.Contains(err.Error(), "unable to authenticate") ||
|
||||
strings.Contains(err.Error(), "ssh: handshake failed") ||
|
||||
strings.Contains(err.Error(), "no supported methods remain") {
|
||||
return nil, fmt.Errorf("identifiants invalides")
|
||||
}
|
||||
return nil, fmt.Errorf("connexion SSH impossible : %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Vérifier l'appartenance aux groupes sudo/wheel via la commande `id`
|
||||
isAdmin, err := checkSudoGroup(client)
|
||||
if err != nil {
|
||||
// En cas d'erreur de vérification des groupes, l'utilisateur est authentifié mais pas admin
|
||||
isAdmin = false
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnectivity teste la connexion SSH sans authentification complète.
|
||||
// Utilisé pendant l'installation pour valider les paramètres de connexion.
|
||||
func TestConnectivity(host string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", host, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible de joindre %s : %w", host, err)
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSSHAuth teste une connexion SSH complète avec credentials.
|
||||
// Retourne nil si la connexion réussit, une erreur explicite sinon.
|
||||
func TestSSHAuth(host, username, password string) error {
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", host, config)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unable to authenticate") {
|
||||
return fmt.Errorf("identifiants SSH invalides")
|
||||
}
|
||||
return fmt.Errorf("connexion SSH échouée : %w", err)
|
||||
}
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSudoGroup exécute `id -nG` sur la session SSH et vérifie la présence
|
||||
// des groupes "sudo" ou "wheel" dans la liste des groupes de l'utilisateur.
|
||||
func checkSudoGroup(client *ssh.Client) (bool, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ouverture session SSH : %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.Output("id -nG")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("exécution `id -nG` : %w", err)
|
||||
}
|
||||
|
||||
groups := strings.Fields(strings.TrimSpace(string(output)))
|
||||
for _, g := range groups {
|
||||
if g == "sudo" || g == "wheel" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
110
backend/internal/crypto/aes.go
Normal file
110
backend/internal/crypto/aes.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Package crypto fournit le chiffrement/déchiffrement AES-256-GCM
|
||||
// pour protéger les secrets stockés en base SQLite (tokens API, credentials SSH, etc.)
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Encryptor gère le chiffrement/déchiffrement avec une clé AES-256.
|
||||
type Encryptor struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewEncryptor crée un Encryptor depuis une clé maître stockée sur disque.
|
||||
// Si la clé n'existe pas, elle est générée aléatoirement et sauvegardée.
|
||||
func NewEncryptor(dataDir string) (*Encryptor, error) {
|
||||
keyPath := filepath.Join(dataDir, "master.key")
|
||||
|
||||
var masterSecret []byte
|
||||
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
// Générer un secret maître de 64 octets aléatoires
|
||||
masterSecret = make([]byte, 64)
|
||||
if _, err := io.ReadFull(rand.Reader, masterSecret); err != nil {
|
||||
return nil, fmt.Errorf("génération clé maître : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder avec permissions restreintes (lecture propriétaire uniquement)
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire : %w", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, masterSecret, 0600); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé maître : %w", err)
|
||||
}
|
||||
} else {
|
||||
// Lire la clé existante
|
||||
masterSecret, err = os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture clé maître : %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Dériver une clé AES-256 depuis le secret maître via SHA-256
|
||||
hash := sha256.Sum256(masterSecret)
|
||||
return &Encryptor{key: hash[:]}, nil
|
||||
}
|
||||
|
||||
// Encrypt chiffre une valeur en clair et retourne une chaîne base64.
|
||||
// Format : base64(nonce || ciphertext || tag)
|
||||
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création cipher AES : %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création GCM : %w", err)
|
||||
}
|
||||
|
||||
// Générer un nonce aléatoire (12 octets pour GCM)
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("génération nonce : %w", err)
|
||||
}
|
||||
|
||||
// Chiffrer : Seal(nonce, nonce, plaintext, nil) → nonce||ciphertext||tag
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt déchiffre une valeur chiffrée par Encrypt.
|
||||
func (e *Encryptor) Decrypt(encoded string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("décodage base64 : %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création cipher AES : %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("création GCM : %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("données chiffrées trop courtes")
|
||||
}
|
||||
|
||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("déchiffrement : %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
185
backend/internal/db/db.go
Normal file
185
backend/internal/db/db.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// Package db gère la connexion SQLite et l'exécution des migrations.
|
||||
// Il expose une instance unique de base de données utilisée par tous les services.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite" // Pilote SQLite pur Go (sans CGO)
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// DB encapsule la connexion SQLite et expose les méthodes nécessaires.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open ouvre (ou crée) la base de données SQLite au chemin donné et exécute les migrations.
|
||||
func Open(dataDir string) (*DB, error) {
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire données : %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dataDir, "panel.db")
|
||||
|
||||
// Paramètres SQLite : WAL mode pour les lectures concurrentes, foreign keys activées
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", dbPath)
|
||||
sqlDB, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ouverture SQLite : %w", err)
|
||||
}
|
||||
|
||||
// Limiter les connexions simultanées (SQLite n'est pas conçu pour la concurrence élevée)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("connexion SQLite : %w", err)
|
||||
}
|
||||
|
||||
db := &DB{sqlDB}
|
||||
|
||||
// Exécuter les migrations manquantes
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrations : %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrate applique les fichiers SQL de migration non encore exécutés.
|
||||
// Les fichiers sont numérotés (001_init.sql, 002_xxx.sql) et appliqués dans l'ordre.
|
||||
func (db *DB) migrate() error {
|
||||
// Créer la table schema_version si elle n'existe pas encore
|
||||
// (nécessaire avant de lire la version actuelle)
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("création schema_version : %w", err)
|
||||
}
|
||||
|
||||
// Lire la version actuelle
|
||||
var currentVersion int
|
||||
row := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
|
||||
if err := row.Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("lecture version schéma : %w", err)
|
||||
}
|
||||
|
||||
// Lister et trier les fichiers de migration
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture dossier migrations : %w", err)
|
||||
}
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
}
|
||||
var migrations []migration
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
// Extraire le numéro de version depuis le nom du fichier (ex: "001_init.sql" → 1)
|
||||
parts := strings.SplitN(entry.Name(), "_", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
migrations = append(migrations, migration{version: v, name: entry.Name()})
|
||||
}
|
||||
|
||||
// Trier par numéro de version croissant
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].version < migrations[j].version
|
||||
})
|
||||
|
||||
// Appliquer les migrations manquantes
|
||||
for _, m := range migrations {
|
||||
if m.version <= currentVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := migrationsFS.ReadFile("migrations/" + m.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture migration %s : %w", m.name, err)
|
||||
}
|
||||
|
||||
// Exécuter dans une transaction pour garantir l'atomicité
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("transaction migration %s : %w", m.name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("exécution migration %s : %w", m.name, err)
|
||||
}
|
||||
|
||||
// Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire)
|
||||
if m.version > 1 {
|
||||
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("mise à jour version après migration %s : %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s : %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSetting lit un paramètre depuis la table settings.
|
||||
// Retourne "" et nil si la clé n'existe pas.
|
||||
func (db *DB) GetSetting(key string) (string, bool, error) {
|
||||
var value string
|
||||
var encrypted int
|
||||
err := db.QueryRow(`SELECT value, encrypted FROM settings WHERE key = ?`, key).Scan(&value, &encrypted)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return value, encrypted == 1, nil
|
||||
}
|
||||
|
||||
// SetSetting enregistre ou met à jour un paramètre.
|
||||
func (db *DB) SetSetting(key, value string, encrypted bool) error {
|
||||
enc := 0
|
||||
if encrypted {
|
||||
enc = 1
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO settings (key, value, encrypted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, encrypted=excluded.encrypted, updated_at=excluded.updated_at
|
||||
`, key, value, enc)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsInstalled vérifie si l'application a déjà été configurée.
|
||||
func (db *DB) IsInstalled() (bool, error) {
|
||||
v, _, err := db.GetSetting("installed")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v == "true", nil
|
||||
}
|
||||
100
backend/internal/db/migrations/001_init.sql
Normal file
100
backend/internal/db/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
-- Migration 001 : Schéma initial de ProxmoxPanel
|
||||
-- Crée toutes les tables de base nécessaires au CORE
|
||||
|
||||
-- Paramètres globaux de l'application (clé/valeur)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Utilisateurs (créés automatiquement au premier login)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
lang TEXT NOT NULL DEFAULT 'en',
|
||||
theme TEXT NOT NULL DEFAULT 'dark',
|
||||
sidebar_position TEXT NOT NULL DEFAULT 'left',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME
|
||||
);
|
||||
|
||||
-- Sessions de refresh JWT (cookie httpOnly)
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Modules disponibles et leur état (actif/inactif)
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
is_core INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
installed_at DATETIME,
|
||||
config TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- Journal d'audit — toutes les actions sensibles
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT,
|
||||
details TEXT,
|
||||
ip TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Widgets du dashboard par utilisateur
|
||||
CREATE TABLE IF NOT EXISTS user_widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
widget_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
position_x INTEGER NOT NULL DEFAULT 0,
|
||||
position_y INTEGER NOT NULL DEFAULT 0,
|
||||
width INTEGER NOT NULL DEFAULT 2,
|
||||
height INTEGER NOT NULL DEFAULT 2,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Historique des mises à jour de paquets
|
||||
CREATE TABLE IF NOT EXISTS update_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL UNIQUE,
|
||||
target TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
started_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME
|
||||
);
|
||||
|
||||
-- Version de schéma pour le système de migrations
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (version) VALUES (1);
|
||||
|
||||
-- Insertion des modules CORE par défaut (non désinstallables)
|
||||
INSERT OR IGNORE INTO modules (id, name, description, version, is_core, is_enabled) VALUES
|
||||
('dashboard', 'Dashboard', 'Tableau de bord avec widgets configurables', '1.0.0', 1, 1),
|
||||
('proxmox', 'Proxmox', 'Gestion des LXC et VM Proxmox', '1.0.0', 1, 1),
|
||||
('updates', 'Mises à jour', 'Mises à jour de paquets apt avec streaming', '1.0.0', 1, 1),
|
||||
('settings', 'Paramètres', 'Configuration de l''application', '1.0.0', 1, 1),
|
||||
('files', 'Fichiers', 'Navigateur de fichiers SFTP', '1.0.0', 0, 0),
|
||||
('terminal', 'Terminal', 'Terminal SSH interactif', '1.0.0', 0, 0),
|
||||
('logs', 'Logs', 'Streaming de logs en temps réel', '1.0.0', 0, 0),
|
||||
('services', 'Services', 'Gestion des services systemd', '1.0.0', 0, 0);
|
||||
212
backend/internal/proxmox/client.go
Normal file
212
backend/internal/proxmox/client.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// Package proxmox fournit un client pour l'API REST Proxmox VE.
|
||||
// Les credentials (token API ou user/password) sont stockés chiffrés en SQLite.
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client est le client HTTP vers l'API Proxmox VE.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
token string // Format: "PVEAPIToken=user@realm!tokenid=secret"
|
||||
}
|
||||
|
||||
// NodeStatus représente l'état d'un nœud Proxmox.
|
||||
type NodeStatus struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
CPU float64 `json:"cpu"`
|
||||
MaxCPU int `json:"maxcpu"`
|
||||
Mem int64 `json:"mem"`
|
||||
MaxMem int64 `json:"maxmem"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
}
|
||||
|
||||
// Resource représente un LXC, une VM ou un autre objet Proxmox.
|
||||
type Resource struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
Type string `json:"type"` // "lxc" | "qemu" | "storage" | "node"
|
||||
Status string `json:"status"` // "running" | "stopped"
|
||||
CPU float64 `json:"cpu"`
|
||||
MaxCPU int `json:"maxcpu"`
|
||||
Mem int64 `json:"mem"`
|
||||
MaxMem int64 `json:"maxmem"`
|
||||
Disk int64 `json:"disk"`
|
||||
MaxDisk int64 `json:"maxdisk"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
NetIn int64 `json:"netin"`
|
||||
NetOut int64 `json:"netout"`
|
||||
}
|
||||
|
||||
// proxmoxResponse est l'enveloppe générique des réponses API Proxmox.
|
||||
type proxmoxResponse struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Error string `json:"errors"`
|
||||
}
|
||||
|
||||
// NewClient crée un client Proxmox avec le token API fourni.
|
||||
// baseURL : ex "https://10.0.0.1:8006"
|
||||
// token : ex "PVEAPIToken=enzo@pam!panel=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
// Proxmox utilise des certificats auto-signés
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetNodes retourne la liste des nœuds Proxmox.
|
||||
func (c *Client) GetNodes() ([]NodeStatus, error) {
|
||||
var nodes []NodeStatus
|
||||
if err := c.get("/api2/json/nodes", &nodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// GetResources retourne tous les LXC et VM de l'ensemble du cluster.
|
||||
// Le paramètre type filtre les résultats ("lxc", "vm", ou "" pour tout).
|
||||
func (c *Client) GetResources(resourceType string) ([]Resource, error) {
|
||||
path := "/api2/json/cluster/resources"
|
||||
if resourceType != "" {
|
||||
path += "?type=" + resourceType
|
||||
}
|
||||
var resources []Resource
|
||||
if err := c.get(path, &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// GetLXCList retourne uniquement les conteneurs LXC.
|
||||
func (c *Client) GetLXCList() ([]Resource, error) {
|
||||
return c.GetResources("lxc")
|
||||
}
|
||||
|
||||
// GetVMList retourne uniquement les machines virtuelles QEMU.
|
||||
func (c *Client) GetVMList() ([]Resource, error) {
|
||||
return c.GetResources("vm")
|
||||
}
|
||||
|
||||
// StartLXC démarre un conteneur LXC.
|
||||
func (c *Client) StartLXC(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/start", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// StopLXC arrête un conteneur LXC.
|
||||
func (c *Client) StopLXC(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/stop", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// StartVM démarre une machine virtuelle.
|
||||
func (c *Client) StartVM(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/start", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// StopVM arrête une machine virtuelle.
|
||||
func (c *Client) StopVM(node string, vmid int) error {
|
||||
_, err := c.post(fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/stop", node, vmid), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestConnection vérifie que le token API est valide en récupérant la liste des nœuds.
|
||||
func (c *Client) TestConnection() error {
|
||||
_, err := c.GetNodes()
|
||||
return err
|
||||
}
|
||||
|
||||
// get effectue une requête GET et décode la réponse dans dest.
|
||||
func (c *Client) get(path string, dest any) error {
|
||||
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("requête Proxmox : %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return fmt.Errorf("token Proxmox invalide ou expiré")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("erreur Proxmox API : HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return c.decodeResponse(resp.Body, dest)
|
||||
}
|
||||
|
||||
// post effectue une requête POST.
|
||||
func (c *Client) post(path string, body any) (json.RawMessage, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = strings.NewReader(string(data))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requête Proxmox : %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return nil, fmt.Errorf("token Proxmox invalide")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("erreur Proxmox API : HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
c.decodeResponse(resp.Body, &result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decodeResponse décode l'enveloppe JSON Proxmox et extrait le champ "data".
|
||||
func (c *Client) decodeResponse(body io.Reader, dest any) error {
|
||||
var wrapper proxmoxResponse
|
||||
if err := json.NewDecoder(body).Decode(&wrapper); err != nil {
|
||||
return fmt.Errorf("décodage réponse Proxmox : %w", err)
|
||||
}
|
||||
if wrapper.Error != "" {
|
||||
return fmt.Errorf("erreur Proxmox : %s", wrapper.Error)
|
||||
}
|
||||
if dest == nil || wrapper.Data == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(wrapper.Data, dest)
|
||||
}
|
||||
216
backend/internal/ssh/pool.go
Normal file
216
backend/internal/ssh/pool.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Package ssh gère un pool de connexions SSH réutilisables vers le host Proxmox et les LXC.
|
||||
// Les connexions inactives depuis plus de 5 minutes sont automatiquement fermées.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
idleTimeout = 5 * time.Minute
|
||||
connTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// poolEntry représente une connexion SSH dans le pool avec sa date de dernier usage.
|
||||
type poolEntry struct {
|
||||
client *gossh.Client
|
||||
lastUsed time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Pool est un pool thread-safe de connexions SSH.
|
||||
type Pool struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*poolEntry
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewPool crée un pool SSH et démarre le nettoyage automatique des connexions inactives.
|
||||
func NewPool() *Pool {
|
||||
p := &Pool{
|
||||
entries: make(map[string]*poolEntry),
|
||||
ticker: time.NewTicker(1 * time.Minute),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go p.cleanup()
|
||||
return p
|
||||
}
|
||||
|
||||
// Close arrête le pool et ferme toutes les connexions.
|
||||
func (p *Pool) Close() {
|
||||
close(p.done)
|
||||
p.ticker.Stop()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, entry := range p.entries {
|
||||
entry.client.Close()
|
||||
}
|
||||
p.entries = make(map[string]*poolEntry)
|
||||
}
|
||||
|
||||
// getOrCreate retourne une connexion existante ou en crée une nouvelle.
|
||||
func (p *Pool) getOrCreate(key, host, user, password string) (*poolEntry, error) {
|
||||
p.mu.Lock()
|
||||
entry, exists := p.entries[key]
|
||||
p.mu.Unlock()
|
||||
|
||||
if exists {
|
||||
// Vérifier que la connexion est toujours active
|
||||
entry.mu.Lock()
|
||||
_, _, err := entry.client.SendRequest("keepalive@openssh.com", true, nil)
|
||||
if err == nil {
|
||||
entry.lastUsed = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return entry, nil
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
// Connexion morte — on la supprime et en crée une nouvelle
|
||||
p.mu.Lock()
|
||||
delete(p.entries, key)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Créer une nouvelle connexion
|
||||
config := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []gossh.AuthMethod{
|
||||
gossh.Password(password),
|
||||
},
|
||||
Timeout: connTimeout,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := gossh.Dial("tcp", host, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connexion SSH vers %s : %w", host, err)
|
||||
}
|
||||
|
||||
newEntry := &poolEntry{
|
||||
client: client,
|
||||
lastUsed: time.Now(),
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.entries[key] = newEntry
|
||||
p.mu.Unlock()
|
||||
|
||||
return newEntry, nil
|
||||
}
|
||||
|
||||
// RunCommand exécute une commande sur l'hôte distant et retourne la sortie combinée.
|
||||
func (p *Pool) RunCommand(host, user, password, command string) (string, error) {
|
||||
key := fmt.Sprintf("%s@%s", user, host)
|
||||
entry, err := p.getOrCreate(key, host, user, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
|
||||
session, err := entry.client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ouverture session : %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(command)
|
||||
entry.lastUsed = time.Now()
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
// StreamCommand exécute une commande et envoie sa sortie ligne par ligne dans le channel.
|
||||
// Le channel est fermé à la fin de la commande.
|
||||
func (p *Pool) StreamCommand(host, user, password, command string, output chan<- string) error {
|
||||
key := fmt.Sprintf("%s@%s", user, host)
|
||||
entry, err := p.getOrCreate(key, host, user, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.mu.Lock()
|
||||
session, err := entry.client.NewSession()
|
||||
entry.mu.Unlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ouverture session : %w", err)
|
||||
}
|
||||
|
||||
// Utiliser un pipe pour lire la sortie en streaming
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("pipe stdout : %w", err)
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("pipe stderr : %w", err)
|
||||
}
|
||||
|
||||
if err := session.Start(command); err != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("démarrage commande : %w", err)
|
||||
}
|
||||
|
||||
// Lire stdout et stderr en goroutines et envoyer dans le channel
|
||||
var wg sync.WaitGroup
|
||||
readStream := func(r io.Reader) {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
output <- string(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go readStream(stdout)
|
||||
go readStream(stderr)
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
session.Wait()
|
||||
session.Close()
|
||||
close(output)
|
||||
p.mu.Lock()
|
||||
if e, ok := p.entries[key]; ok {
|
||||
e.lastUsed = time.Now()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanup supprime périodiquement les connexions inactives depuis plus de idleTimeout.
|
||||
func (p *Pool) cleanup() {
|
||||
for {
|
||||
select {
|
||||
case <-p.done:
|
||||
return
|
||||
case <-p.ticker.C:
|
||||
p.mu.Lock()
|
||||
for key, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if time.Since(entry.lastUsed) > idleTimeout {
|
||||
entry.client.Close()
|
||||
delete(p.entries, key)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
228
backend/internal/websocket/hub.go
Normal file
228
backend/internal/websocket/hub.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// Package websocket fournit le hub central WebSocket de ProxmoxPanel.
|
||||
// Les clients s'abonnent à des channels nommés et reçoivent les messages qui leur sont destinés.
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 8192
|
||||
)
|
||||
|
||||
// Message représente un message WebSocket avec un type et un payload JSON.
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// Client représente un client WebSocket connecté.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
channels map[string]bool
|
||||
mu sync.RWMutex
|
||||
userID int64
|
||||
}
|
||||
|
||||
// Hub gère toutes les connexions WebSocket actives et le routage des messages par channel.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan broadcastMsg
|
||||
}
|
||||
|
||||
type broadcastMsg struct {
|
||||
channel string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// NewHub crée un nouveau hub WebSocket et le démarre.
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
register: make(chan *Client, 64),
|
||||
unregister: make(chan *Client, 64),
|
||||
broadcast: make(chan broadcastMsg, 256),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
// run est la boucle principale du hub (goroutine unique pour éviter les races).
|
||||
func (h *Hub) run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if h.clients[client] {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
case msg := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for client := range h.clients {
|
||||
client.mu.RLock()
|
||||
subscribed := client.channels[msg.channel] || client.channels["*"]
|
||||
client.mu.RUnlock()
|
||||
if subscribed {
|
||||
select {
|
||||
case client.send <- msg.data:
|
||||
default:
|
||||
// Client lent ou déconnecté — on le supprime
|
||||
h.mu.RUnlock()
|
||||
h.mu.Lock()
|
||||
if h.clients[client] {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.mu.RLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish envoie un message sur un channel donné à tous les clients abonnés.
|
||||
func (h *Hub) Publish(channel, msgType string, payload any) {
|
||||
data, err := marshalMessage(msgType, channel, payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.broadcast <- broadcastMsg{channel: channel, data: data}
|
||||
}
|
||||
|
||||
// PublishRaw envoie des données brutes sur un channel.
|
||||
func (h *Hub) PublishRaw(channel string, data []byte) {
|
||||
h.broadcast <- broadcastMsg{channel: channel, data: data}
|
||||
}
|
||||
|
||||
// NewClient crée et enregistre un nouveau client WebSocket.
|
||||
func (h *Hub) NewClient(conn *websocket.Conn, userID int64) *Client {
|
||||
c := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
channels: make(map[string]bool),
|
||||
userID: userID,
|
||||
}
|
||||
h.register <- c
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
return c
|
||||
}
|
||||
|
||||
// Subscribe abonne le client à un channel.
|
||||
func (c *Client) Subscribe(channel string) {
|
||||
c.mu.Lock()
|
||||
c.channels[channel] = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Unsubscribe désabonne le client d'un channel.
|
||||
func (c *Client) Unsubscribe(channel string) {
|
||||
c.mu.Lock()
|
||||
delete(c.channels, channel)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// writePump envoie les messages en attente au client WebSocket.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
// Hub a fermé le channel
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readPump lit les messages entrants du client (abonnements, ping, etc.)
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, rawMsg, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Traiter les messages d'abonnement entrants
|
||||
var msg Message
|
||||
if json.Unmarshal(rawMsg, &msg) == nil {
|
||||
switch msg.Type {
|
||||
case "subscribe":
|
||||
c.Subscribe(msg.Channel)
|
||||
case "unsubscribe":
|
||||
c.Unsubscribe(msg.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// marshalMessage sérialise un message WebSocket en JSON.
|
||||
func marshalMessage(msgType, channel string, payload any) ([]byte, error) {
|
||||
var rawPayload json.RawMessage
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawPayload = data
|
||||
}
|
||||
return json.Marshal(Message{
|
||||
Type: msgType,
|
||||
Channel: channel,
|
||||
Payload: rawPayload,
|
||||
})
|
||||
}
|
||||
214
backend/main.go
Normal file
214
backend/main.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// ProxmoxPanel — CORE Backend
|
||||
// Point d'entrée du serveur Go. Initialise la base de données, les services,
|
||||
// enregistre les modules actifs et démarre le serveur HTTP sur :3001.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/api"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||
sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
|
||||
"git.geronzi.fr/proxmoxPanel/core/backend/modules"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Répertoire de données persistantes (volume Docker)
|
||||
dataDir := getEnv("DATA_DIR", "/app/data")
|
||||
|
||||
log.Printf("ProxmoxPanel CORE — démarrage (data: %s)", dataDir)
|
||||
|
||||
// ── Initialisation de la base de données ───────────────────────────────
|
||||
database, err := db.Open(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Impossible d'ouvrir la base de données : %v", err)
|
||||
}
|
||||
log.Println("Base de données SQLite initialisée")
|
||||
|
||||
// ── Services de base ───────────────────────────────────────────────────
|
||||
encryptor, err := crypto.NewEncryptor(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Impossible d'initialiser le chiffrement : %v", err)
|
||||
}
|
||||
log.Println("Chiffrement AES-256-GCM initialisé")
|
||||
|
||||
jwtManager, err := auth.NewJWTManager(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Impossible d'initialiser JWT : %v", err)
|
||||
}
|
||||
log.Println("Clés JWT RS256 prêtes")
|
||||
|
||||
// SSH host depuis la configuration (peut être vide si pas encore installé)
|
||||
sshHost, _, _ := database.GetSetting("ssh_host")
|
||||
var sshAuthenticator *auth.SSHAuthenticator
|
||||
if sshHost != "" {
|
||||
sshAuthenticator = auth.NewSSHAuthenticator(sshHost)
|
||||
} else {
|
||||
sshAuthenticator = auth.NewSSHAuthenticator("") // Sera mis à jour après installation
|
||||
}
|
||||
|
||||
sshPool := sshpool.NewPool()
|
||||
defer sshPool.Close()
|
||||
|
||||
hub := websocket.NewHub()
|
||||
auditLogger := audit.New(database.DB)
|
||||
|
||||
// ── Chargement des modules actifs ──────────────────────────────────────
|
||||
loader := modules.NewLoader(database.DB)
|
||||
// Les modules sont enregistrés ici (compilés dans le binaire)
|
||||
// loader.RegisterModule(dashboard.New(...)) ← à décommenter quand implémentés
|
||||
if err := loader.LoadActive(); err != nil {
|
||||
log.Fatalf("Erreur chargement modules : %v", err)
|
||||
}
|
||||
|
||||
// ── Handlers HTTP ──────────────────────────────────────────────────────
|
||||
installHandler := api.NewInstallHandler(database, encryptor)
|
||||
authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger)
|
||||
proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor)
|
||||
updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor)
|
||||
settingsHandler := api.NewSettingsHandler(database, auditLogger)
|
||||
terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor)
|
||||
|
||||
// Démarrer le polling Proxmox en arrière-plan
|
||||
proxmoxHandler.StartPolling()
|
||||
|
||||
// ── Router Chi ─────────────────────────────────────────────────────────
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middlewares globaux
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(api.SecurityHeaders)
|
||||
r.Use(middleware.Compress(5)) // Compression gzip
|
||||
|
||||
// Limiter global (100 req/min par IP)
|
||||
globalLimiter := api.NewRateLimiter(100, 60*1000000000) // 60 secondes
|
||||
r.Use(api.RateLimit(globalLimiter))
|
||||
|
||||
// ── Routes publiques (sans authentification) ───────────────────────────
|
||||
r.Get("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
api.JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Routes d'installation (accessibles seulement si non-installé)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireNotInstalled(database))
|
||||
r.Get("/api/install/status", installHandler.GetStatus)
|
||||
r.Post("/api/install/test-ssh", installHandler.TestSSH)
|
||||
r.Post("/api/install/test-proxmox", installHandler.TestProxmoxToken)
|
||||
r.Post("/api/install/configure", installHandler.Configure)
|
||||
})
|
||||
|
||||
// Status d'installation accessible toujours (pour la redirection frontend)
|
||||
r.Get("/api/install/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
installed, _ := database.IsInstalled()
|
||||
api.JSONResponse(w, http.StatusOK, map[string]bool{"installed": installed})
|
||||
})
|
||||
|
||||
// Routes d'authentification
|
||||
r.Post("/api/auth/login", authHandler.Login)
|
||||
r.Post("/api/auth/refresh", authHandler.Refresh)
|
||||
|
||||
// ── Routes protégées (JWT requis) ──────────────────────────────────────
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(api.RequireAuth(jwtManager))
|
||||
|
||||
r.Post("/api/auth/logout", authHandler.Logout)
|
||||
r.Get("/api/auth/me", authHandler.Me)
|
||||
r.Patch("/api/auth/preferences", authHandler.UpdatePreferences)
|
||||
|
||||
// Proxmox
|
||||
r.Get("/api/proxmox/resources", proxmoxHandler.GetResources)
|
||||
r.Get("/api/proxmox/lxc", proxmoxHandler.GetLXC)
|
||||
|
||||
// Actions Proxmox — admin uniquement
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(api.RequireAdmin)
|
||||
r.Post("/api/proxmox/lxc/{vmid}/start", proxmoxHandler.StartLXC)
|
||||
r.Post("/api/proxmox/lxc/{vmid}/stop", proxmoxHandler.StopLXC)
|
||||
})
|
||||
|
||||
// Mises à jour — admin uniquement
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(api.RequireAdmin)
|
||||
r.Post("/api/updates/run", updatesHandler.RunUpdate)
|
||||
})
|
||||
r.Get("/api/updates/history", updatesHandler.GetHistory)
|
||||
|
||||
// Paramètres — admin uniquement
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(api.RequireAdmin)
|
||||
r.Get("/api/settings", settingsHandler.GetAll)
|
||||
r.Put("/api/settings/{key}", settingsHandler.UpdateSetting)
|
||||
r.Get("/api/settings/audit", settingsHandler.GetAuditLog)
|
||||
})
|
||||
|
||||
// Modules
|
||||
r.Get("/api/modules", settingsHandler.GetModules)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(api.RequireAdmin)
|
||||
r.Post("/api/modules/{id}/enable", settingsHandler.EnableModule)
|
||||
r.Post("/api/modules/{id}/disable", settingsHandler.DisableModule)
|
||||
})
|
||||
|
||||
// WebSocket — les routes WS extraient le token via query param
|
||||
r.Get("/ws/proxmox", proxmoxHandler.WebSocket)
|
||||
r.Get("/ws/updates/{jobId}", updatesHandler.WebSocketUpdate)
|
||||
r.Get("/ws/terminal", terminalHandler.WebSocket)
|
||||
})
|
||||
|
||||
// Routes enregistrées par les modules actifs
|
||||
for _, route := range loader.Registry().GetRoutes() {
|
||||
routeCopy := route // Capturer la variable pour la closure
|
||||
if routeCopy.RequireAdmin {
|
||||
r.With(api.RequireAuth(jwtManager), api.RequireAdmin).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
|
||||
} else {
|
||||
r.With(api.RequireAuth(jwtManager)).MethodFunc(routeCopy.Method, routeCopy.Path, routeCopy.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Servir les assets frontend (en production, c'est Nginx qui s'en charge)
|
||||
if _, err := os.Stat("./static"); err == nil {
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
r.Handle("/*", fs)
|
||||
}
|
||||
|
||||
// ── Démarrage du serveur ───────────────────────────────────────────────
|
||||
addr := getEnv("LISTEN_ADDR", ":3001")
|
||||
log.Printf("Serveur démarré sur %s", addr)
|
||||
if err := http.ListenAndServe(addr, r); err != nil {
|
||||
log.Fatalf("Serveur arrêté : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireNotInstalled est un middleware qui bloque les routes d'installation si déjà installé.
|
||||
func requireNotInstalled(database *db.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// La route /api/install/status reste accessible pour le check
|
||||
installed, _ := database.IsInstalled()
|
||||
if installed {
|
||||
api.JSONError(w, "Application déjà installée", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv lit une variable d'environnement avec une valeur par défaut.
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
44
backend/modules/dashboard/README.md
Normal file
44
backend/modules/dashboard/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Module — Dashboard
|
||||
|
||||
**Type**: Core (always enabled)
|
||||
|
||||
Provides the main dashboard with a configurable, per-user widget grid.
|
||||
|
||||
## Features
|
||||
|
||||
- Drag-and-drop widget reordering (saved per user in SQLite)
|
||||
- Add and remove widgets via modal
|
||||
- Widget layout persisted across sessions
|
||||
|
||||
## Widget Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `shortcut` | Clickable link card (icon + label + URL) |
|
||||
| `lxc_status` | Live status of a specific LXC container |
|
||||
| `metrics` | Host CPU/RAM/disk summary |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/dashboard/widgets` | JWT | Get current user's widget layout |
|
||||
| PUT | `/api/dashboard/widgets` | JWT | Save widget layout |
|
||||
|
||||
## Widget Layout Format
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": "w1", "type": "shortcut", "config": { "label": "Proxmox", "url": "https://proxmox.example.com", "icon": "server" } },
|
||||
{ "id": "w2", "type": "lxc_status", "config": { "vmid": 100 } },
|
||||
{ "id": "w3", "type": "metrics", "config": {} }
|
||||
]
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Layouts are stored in the `user_widgets` table, keyed by user ID.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE)
|
||||
41
backend/modules/files/README.md
Normal file
41
backend/modules/files/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Module — Files
|
||||
|
||||
**Type**: Optional (disabled by default)
|
||||
|
||||
SFTP-based file browser for the Proxmox host and LXC containers. Navigate, view, edit, upload, and download files directly from the browser.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- Directory listing with permissions, size, modification date
|
||||
- File preview (text, JSON, YAML, shell scripts, logs)
|
||||
- File editing via CodeMirror 6 (syntax highlighting for common formats)
|
||||
- Upload and download
|
||||
- Create/delete files and directories
|
||||
- Navigate into LXC containers via `pct exec` or direct SFTP
|
||||
|
||||
## Planned API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/files/list` | JWT | List directory contents |
|
||||
| GET | `/api/files/read` | JWT | Read file content |
|
||||
| PUT | `/api/files/write` | JWT+Admin | Write file content |
|
||||
| POST | `/api/files/mkdir` | JWT+Admin | Create directory |
|
||||
| DELETE | `/api/files/delete` | JWT+Admin | Delete file or directory |
|
||||
| GET | `/api/files/download` | JWT | Download file |
|
||||
| POST | `/api/files/upload` | JWT+Admin | Upload file |
|
||||
|
||||
Query parameters: `path=<absolute-path>`, `host=<optional-ssh-override>`
|
||||
|
||||
## Status
|
||||
|
||||
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full SFTP implementation is planned for a future release.
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH/SFTP access to the target host
|
||||
- The `ssh_host`, `ssh_username`, `ssh_password` settings must be configured
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE)
|
||||
150
backend/modules/loader.go
Normal file
150
backend/modules/loader.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Package modules — Loader de modules.
|
||||
// Découvre les modules disponibles, vérifie leur état en DB, et les initialise si activés.
|
||||
// Un module désactivé ne fait appel à aucune de ses méthodes Register().
|
||||
package modules
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Loader charge et gère les modules actifs.
|
||||
type Loader struct {
|
||||
db *sql.DB
|
||||
registry *coreRegistry
|
||||
modules []Module
|
||||
}
|
||||
|
||||
// NewLoader crée un Loader avec le router et la DB fournis.
|
||||
func NewLoader(db *sql.DB) *Loader {
|
||||
return &Loader{
|
||||
db: db,
|
||||
registry: newCoreRegistry(db),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterModule enregistre un module disponible (appelé à l'init, depuis main.go).
|
||||
// Le module sera initialisé seulement s'il est activé en base.
|
||||
func (l *Loader) RegisterModule(m Module) {
|
||||
l.modules = append(l.modules, m)
|
||||
}
|
||||
|
||||
// LoadActive charge et initialise tous les modules activés en base de données.
|
||||
func (l *Loader) LoadActive() error {
|
||||
for _, m := range l.modules {
|
||||
enabled, err := l.isEnabled(m.ID())
|
||||
if err != nil {
|
||||
return fmt.Errorf("vérification module %s : %w", m.ID(), err)
|
||||
}
|
||||
if !enabled {
|
||||
log.Printf("Module %s : désactivé, ignoré", m.ID())
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Module %s : chargement...", m.ID())
|
||||
if err := m.Register(l.registry); err != nil {
|
||||
return fmt.Errorf("initialisation module %s : %w", m.ID(), err)
|
||||
}
|
||||
log.Printf("Module %s : chargé avec succès", m.ID())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isEnabled vérifie en base de données si un module est activé.
|
||||
func (l *Loader) isEnabled(id string) (bool, error) {
|
||||
var enabled int
|
||||
err := l.db.QueryRow(`SELECT is_enabled FROM modules WHERE id = ?`, id).Scan(&enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil // Module inconnu = désactivé
|
||||
}
|
||||
return enabled == 1, err
|
||||
}
|
||||
|
||||
// Registry retourne le registry partagé (pour accès par le serveur HTTP).
|
||||
func (l *Loader) Registry() *coreRegistry {
|
||||
return l.registry
|
||||
}
|
||||
|
||||
// ---- Implémentation interne du Registry ----
|
||||
|
||||
// RouteEntry décrit une route HTTP enregistrée par un module.
|
||||
type RouteEntry struct {
|
||||
Method string
|
||||
Path string
|
||||
Handler http.HandlerFunc
|
||||
RequireAdmin bool
|
||||
}
|
||||
|
||||
type migrationEntry struct {
|
||||
version int
|
||||
sql string
|
||||
fn MigrationFn
|
||||
}
|
||||
|
||||
type translationEntry struct {
|
||||
lang string
|
||||
keys map[string]string
|
||||
}
|
||||
|
||||
// coreRegistry implémente l'interface Registry.
|
||||
type coreRegistry struct {
|
||||
db *sql.DB
|
||||
routes []RouteEntry
|
||||
wsChannels map[string]WSHandler
|
||||
widgets []WidgetDef
|
||||
settingsTabs []SettingsTabDef
|
||||
migrations []migrationEntry
|
||||
translations []translationEntry
|
||||
}
|
||||
|
||||
func newCoreRegistry(db *sql.DB) *coreRegistry {
|
||||
return &coreRegistry{
|
||||
db: db,
|
||||
wsChannels: make(map[string]WSHandler),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *coreRegistry) RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool) {
|
||||
r.routes = append(r.routes, RouteEntry{method, path, handler, requireAdmin})
|
||||
}
|
||||
|
||||
func (r *coreRegistry) RegisterWSChannel(channel string, handler WSHandler) {
|
||||
r.wsChannels[channel] = handler
|
||||
}
|
||||
|
||||
func (r *coreRegistry) RegisterWidget(widget WidgetDef) {
|
||||
r.widgets = append(r.widgets, widget)
|
||||
}
|
||||
|
||||
func (r *coreRegistry) RegisterSettingsTab(tab SettingsTabDef) {
|
||||
r.settingsTabs = append(r.settingsTabs, tab)
|
||||
}
|
||||
|
||||
func (r *coreRegistry) RegisterTranslations(lang string, keys map[string]string) {
|
||||
r.translations = append(r.translations, translationEntry{lang, keys})
|
||||
}
|
||||
|
||||
func (r *coreRegistry) RegisterMigration(version int, sqlStr string, fn MigrationFn) {
|
||||
r.migrations = append(r.migrations, migrationEntry{version, sqlStr, fn})
|
||||
}
|
||||
|
||||
func (r *coreRegistry) DB() *sql.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// GetRoutes retourne les routes enregistrées par les modules.
|
||||
func (r *coreRegistry) GetRoutes() []RouteEntry {
|
||||
return r.routes
|
||||
}
|
||||
|
||||
// GetWidgets retourne les types de widgets disponibles.
|
||||
func (r *coreRegistry) GetWidgets() []WidgetDef {
|
||||
return r.widgets
|
||||
}
|
||||
|
||||
// GetSettingsTabs retourne les onglets de paramètres des modules.
|
||||
func (r *coreRegistry) GetSettingsTabs() []SettingsTabDef {
|
||||
return r.settingsTabs
|
||||
}
|
||||
45
backend/modules/logs/README.md
Normal file
45
backend/modules/logs/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Module — Logs
|
||||
|
||||
**Type**: Optional (disabled by default)
|
||||
|
||||
Stream and browse system logs from the Proxmox host or LXC containers in real time via WebSocket (`tail -f` equivalent).
|
||||
|
||||
## Planned Features
|
||||
|
||||
- Real-time log streaming via WebSocket
|
||||
- Common log sources: `syslog`, `auth.log`, `kern.log`, journald
|
||||
- Filter by log level (error, warning, info)
|
||||
- Stop/start streaming on demand
|
||||
- LXC log access via `pct exec`
|
||||
|
||||
## Planned API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/logs/sources` | JWT | List available log sources |
|
||||
|
||||
## Planned WebSocket Endpoint
|
||||
|
||||
`GET /ws/logs/{source}?token=<jwt>&host=<optional>`
|
||||
|
||||
Where `source` is a log name such as `syslog`, `auth`, or `journal`.
|
||||
|
||||
Message types:
|
||||
|
||||
| Type | Payload | Description |
|
||||
|------|---------|-------------|
|
||||
| `log_line` | `{ "line": "...", "level": "info" }` | New log line |
|
||||
| `log_end` | — | Stream closed (e.g. SSH disconnected) |
|
||||
|
||||
## Status
|
||||
|
||||
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release.
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH access to the target host
|
||||
- Read permissions on the log files (root or appropriate group)
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE)
|
||||
66
backend/modules/module.go
Normal file
66
backend/modules/module.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Package modules définit le contrat d'interface pour les modules ProxmoxPanel.
|
||||
// Chaque module implémente l'interface Module et s'enregistre auprès du ModuleRegistry.
|
||||
package modules
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Module est l'interface que chaque module doit implémenter.
|
||||
type Module interface {
|
||||
// ID retourne l'identifiant unique du module (doit correspondre à la table modules en DB).
|
||||
ID() string
|
||||
|
||||
// Register est appelé au chargement du module actif.
|
||||
// Il reçoit le registry pour enregistrer ses routes, widgets, etc.
|
||||
Register(registry Registry) error
|
||||
}
|
||||
|
||||
// Registry est l'interface exposée aux modules pour s'enregistrer dans le CORE.
|
||||
type Registry interface {
|
||||
// RegisterRoute enregistre une route HTTP dans le router principal.
|
||||
RegisterRoute(method, path string, handler http.HandlerFunc, requireAdmin bool)
|
||||
|
||||
// RegisterWSChannel enregistre un handler WebSocket pour un channel nommé.
|
||||
RegisterWSChannel(channel string, handler WSHandler)
|
||||
|
||||
// RegisterWidget déclare un type de widget disponible pour le dashboard.
|
||||
RegisterWidget(widget WidgetDef)
|
||||
|
||||
// RegisterSettingsTab ajoute un onglet dans la page paramètres.
|
||||
RegisterSettingsTab(tab SettingsTabDef)
|
||||
|
||||
// RegisterTranslations fusionne des clés de traduction pour une langue donnée.
|
||||
RegisterTranslations(lang string, keys map[string]string)
|
||||
|
||||
// RegisterMigration déclare une migration de base de données propre au module.
|
||||
RegisterMigration(version int, sql string, fn MigrationFn)
|
||||
|
||||
// DB retourne un accès à SQLite avec isolation par module (préfixe de tables).
|
||||
DB() *sql.DB
|
||||
}
|
||||
|
||||
// WSHandler est un handler WebSocket pour un channel nommé.
|
||||
type WSHandler func(userID int64, send chan<- []byte, recv <-chan []byte)
|
||||
|
||||
// WidgetDef décrit un type de widget disponible pour le dashboard.
|
||||
type WidgetDef struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DefaultW int `json:"default_width"`
|
||||
DefaultH int `json:"default_height"`
|
||||
}
|
||||
|
||||
// SettingsTabDef décrit un onglet de paramètres fourni par un module.
|
||||
type SettingsTabDef struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon"`
|
||||
// Path est le chemin frontend du composant Vue à charger (lazy import).
|
||||
ComponentPath string `json:"component_path"`
|
||||
}
|
||||
|
||||
// MigrationFn est une fonction de migration optionnelle (pour les migrations non-SQL).
|
||||
type MigrationFn func(db *sql.DB) error
|
||||
55
backend/modules/services/README.md
Normal file
55
backend/modules/services/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Module — Services
|
||||
|
||||
**Type**: Optional (disabled by default)
|
||||
|
||||
Manage systemd services on the Proxmox host and LXC containers. Check status, start, stop, and restart services directly from the web interface.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- List systemd services with current status (active/inactive/failed)
|
||||
- Start, stop, restart, reload actions
|
||||
- View service logs (last N lines via `journalctl -u <service>`)
|
||||
- Filter by status or name
|
||||
- LXC service management via `pct exec`
|
||||
|
||||
## Planned API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/services` | JWT | List services and their status |
|
||||
| POST | `/api/services/{name}/start` | JWT+Admin | Start a service |
|
||||
| POST | `/api/services/{name}/stop` | JWT+Admin | Stop a service |
|
||||
| POST | `/api/services/{name}/restart` | JWT+Admin | Restart a service |
|
||||
| POST | `/api/services/{name}/reload` | JWT+Admin | Reload a service |
|
||||
| GET | `/api/services/{name}/logs` | JWT | Last 100 log lines |
|
||||
|
||||
Query parameter: `host=<optional>` to target a specific LXC.
|
||||
|
||||
## How It Works
|
||||
|
||||
Commands are executed over SSH using `systemctl`:
|
||||
|
||||
```bash
|
||||
systemctl status nginx
|
||||
systemctl restart nginx
|
||||
journalctl -u nginx -n 100 --no-pager
|
||||
```
|
||||
|
||||
For LXC containers:
|
||||
|
||||
```bash
|
||||
pct exec <vmid> -- systemctl restart nginx
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
> This module is currently a stub. The UI view is implemented (shows a "module not enabled" placeholder). Full implementation is planned for a future release.
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH access with sufficient privileges to run `systemctl` commands
|
||||
- `systemd` on the target host/containers
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE)
|
||||
54
backend/modules/terminal/README.md
Normal file
54
backend/modules/terminal/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Module — Terminal
|
||||
|
||||
**Type**: Optional (disabled by default)
|
||||
|
||||
Interactive SSH terminal in the browser. Connects to the Proxmox host (or any SSH-accessible target) and opens a full PTY session via WebSocket.
|
||||
|
||||
## Features
|
||||
|
||||
- Full PTY support (`xterm-256color`, interactive shell)
|
||||
- Responsive resizing — the terminal adjusts when the browser window is resized
|
||||
- Terminal theme matches the panel's Neumorphism dark/light design
|
||||
- Audit log entry on open and close
|
||||
|
||||
## WebSocket Endpoint
|
||||
|
||||
`GET /ws/terminal?token=<jwt>&host=<optional-override>`
|
||||
|
||||
If `host` is not specified, the SSH host configured during installation is used.
|
||||
|
||||
### Message Format
|
||||
|
||||
**Client → Server** (keyboard input): raw binary bytes
|
||||
|
||||
**Client → Server** (resize event): JSON text frame
|
||||
|
||||
```json
|
||||
{ "type": "resize", "cols": 120, "rows": 40 }
|
||||
```
|
||||
|
||||
**Server → Client** (terminal output): raw binary bytes
|
||||
|
||||
## Frontend
|
||||
|
||||
Uses [xterm.js](https://xtermjs.org/) with the following addons:
|
||||
|
||||
- `@xterm/addon-fit` — auto-resize to container dimensions
|
||||
- `@xterm/addon-attach` — attach xterm directly to a WebSocket
|
||||
|
||||
## How It Works
|
||||
|
||||
1. WebSocket connection is established and JWT is validated
|
||||
2. Backend opens an SSH connection using stored credentials
|
||||
3. A PTY session is requested (`xterm-256color`, initial size 80×24)
|
||||
4. An interactive shell is launched
|
||||
5. All data flows bidirectionally: WebSocket ↔ SSH ↔ PTY
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH access to the target host (password authentication)
|
||||
- The `ssh_host`, `ssh_username`, `ssh_password` settings must be configured
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE)
|
||||
68
backend/modules/updates/README.md
Normal file
68
backend/modules/updates/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Module — Updates
|
||||
|
||||
**Type**: Core (always enabled)
|
||||
|
||||
Run `apt update && apt full-upgrade` on the Proxmox host or any LXC container, with real-time streaming output via WebSocket.
|
||||
|
||||
## Features
|
||||
|
||||
- Target: host, a specific LXC (`lxc:100`), or all LXC containers at once
|
||||
- Output streamed line-by-line via WebSocket — no polling required
|
||||
- Full output saved to `update_history` table in SQLite
|
||||
- Admin-only action
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/updates/run` | JWT+Admin | Start an update job |
|
||||
| GET | `/api/updates/history` | JWT | List past update jobs (last 50) |
|
||||
|
||||
### POST /api/updates/run
|
||||
|
||||
```json
|
||||
{ "target": "host" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "target": "lxc:100" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "target": "all" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "job_id": "1710000000-ab3f9z12", "message": "Mise à jour démarrée" }
|
||||
```
|
||||
|
||||
## WebSocket Streaming
|
||||
|
||||
Connect to `GET /ws/updates/{jobId}?token=<jwt>` to receive output in real time.
|
||||
|
||||
Message types published on the channel:
|
||||
|
||||
| Type | Payload | Description |
|
||||
|------|---------|-------------|
|
||||
| `update_output` | `{ "chunk": "..." }` | Line(s) of apt output |
|
||||
| `update_done` | `{ "job_id": "..." }` | Job completed successfully |
|
||||
| `update_error` | `{ "error": "..." }` | Job failed |
|
||||
|
||||
## How It Works
|
||||
|
||||
Updates run over SSH using the credentials configured during installation:
|
||||
|
||||
- **Host**: runs `DEBIAN_FRONTEND=noninteractive apt-get update && apt-get full-upgrade -y` directly
|
||||
- **LXC**: runs the same command via `pct exec <vmid> -- bash -c '...'`
|
||||
- **All**: iterates over `pct list` output and updates each container
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH access to the Proxmox host with sudo/root privileges
|
||||
- `pct` available on the host (standard on Proxmox VE)
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE)
|
||||
73
docker-compose.yml
Normal file
73
docker-compose.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# ProxmoxPanel — Docker Compose
|
||||
# Démarrage : docker-compose up --build
|
||||
# Accès : http://localhost (port 80) ou https://panel.geronzi.fr via Traefik
|
||||
|
||||
services:
|
||||
|
||||
# ── Backend Go (API + WebSocket) ────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: proxmoxpanel-backend
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
# Volume persistant pour SQLite, clés JWT, clé maître AES
|
||||
- panel-data:/app/data
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- LISTEN_ADDR=:3001
|
||||
- APP_ENV=production
|
||||
# Pas de réseau host — le container reste isolé
|
||||
# Les connexions SSH sortantes vers le host Proxmox sont autorisées via le réseau Docker
|
||||
networks:
|
||||
- proxmoxpanel-net
|
||||
# Limite de ressources recommandées
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: "1.0"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# ── Frontend Vue 3 (Nginx + assets statiques) ───────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: proxmoxpanel-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# En développement local : http://localhost:80
|
||||
# En production : Traefik se charge du port 443 → ce port est juste pour accès direct
|
||||
- "80:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- proxmoxpanel-net
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
cpus: "0.5"
|
||||
|
||||
# ── Volumes ────────────────────────────────────────────────────────────────
|
||||
volumes:
|
||||
# Données persistantes : SQLite + clés cryptographiques
|
||||
# NE PAS supprimer ce volume sans sauvegarder panel.db au préalable
|
||||
panel-data:
|
||||
name: proxmoxpanel-data
|
||||
|
||||
# ── Réseaux ────────────────────────────────────────────────────────────────
|
||||
networks:
|
||||
proxmoxpanel-net:
|
||||
name: proxmoxpanel-net
|
||||
driver: bridge
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# ── Étape 1 : Build du frontend Vue 3 + Vite ───────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copier les fichiers de dépendances en premier
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --frozen-lockfile
|
||||
|
||||
# Copier le code source et compiler
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Étape 2 : Image Nginx pour servir le frontend ──────────────────────────
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Supprimer la config Nginx par défaut
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copier notre config Nginx personnalisée
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copier les fichiers compilés par Vite
|
||||
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||
|
||||
# Vérifier la config Nginx au build
|
||||
RUN nginx -t
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="ProxmoxPanel — Interface de gestion d'infrastructure" />
|
||||
<title>ProxmoxPanel</title>
|
||||
<!-- Pas de CDN — tous les assets sont locaux -->
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
73
frontend/nginx.conf
Normal file
73
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Configuration Nginx pour le frontend ProxmoxPanel
|
||||
# Sert les fichiers statiques et proxy les requêtes API/WebSocket vers le backend Go
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logs au format JSON pour faciliter l'analyse
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Compression gzip pour les assets statiques
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Cache agressif pour les assets avec hash dans le nom (Vite)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Proxy des requêtes API vers le backend Go
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s; # Timeout long pour les mises à jour apt
|
||||
}
|
||||
|
||||
# Proxy des connexions WebSocket
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600s; # 1 heure pour les sessions terminal
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# SPA : toutes les autres routes servent index.html (Vue Router)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
1994
frontend/package-lock.json
generated
Normal file
1994
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "proxmoxpanel-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.0",
|
||||
"vue-i18n": "^11.0.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@codemirror/state": "^6.5.1",
|
||||
"@codemirror/view": "^6.36.3",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/language": "^6.10.8",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.10",
|
||||
"@codemirror/lang-python": "^6.1.7",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"vue-draggable-plus": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.3.3",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
49
frontend/src/App.vue
Normal file
49
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<!-- Applique le thème (dark/light) et la position de la sidebar via data-attributes -->
|
||||
<div
|
||||
:data-theme="uiStore.theme"
|
||||
:data-sidebar="uiStore.sidebarPosition"
|
||||
class="app-root"
|
||||
>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// Restaurer le thème depuis localStorage
|
||||
uiStore.initTheme()
|
||||
|
||||
// Tenter de restaurer la session (refresh token via cookie httpOnly)
|
||||
await authStore.tryRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Reset minimal — pas de framework CSS externe */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #app, .app-root {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
139
frontend/src/components/Layout.vue
Normal file
139
frontend/src/components/Layout.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<!-- Layout principal : sidebar + contenu principal -->
|
||||
<div class="layout" :class="[`layout--sidebar-${uiStore.sidebarPosition}`, { 'layout--collapsed': uiStore.sidebarCollapsed }]">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<Sidebar class="layout__sidebar" />
|
||||
|
||||
<!-- Zone principale -->
|
||||
<div class="layout__main">
|
||||
<!-- Navbar supérieure -->
|
||||
<Navbar class="layout__navbar" />
|
||||
|
||||
<!-- Contenu de la page -->
|
||||
<main class="layout__content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page-fade" mode="out-in">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Overlay mobile -->
|
||||
<div
|
||||
v-if="uiStore.mobileMenuOpen"
|
||||
class="layout__overlay"
|
||||
@click="uiStore.mobileMenuOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import Navbar from './Navbar.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--neu-bg);
|
||||
}
|
||||
|
||||
/* Sidebar à gauche (défaut) */
|
||||
.layout--sidebar-left {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Sidebar à droite */
|
||||
.layout--sidebar-right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.layout__sidebar {
|
||||
flex-shrink: 0;
|
||||
width: var(--sidebar-width);
|
||||
transition: width 0.3s ease;
|
||||
z-index: var(--z-sidebar);
|
||||
}
|
||||
|
||||
.layout--collapsed .layout__sidebar {
|
||||
width: var(--sidebar-width-collapsed);
|
||||
}
|
||||
|
||||
.layout__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* Empêche l'overflow en flex */
|
||||
}
|
||||
|
||||
.layout__navbar {
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-navbar);
|
||||
}
|
||||
|
||||
.layout__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.layout__overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: calc(var(--z-sidebar) - 1);
|
||||
}
|
||||
|
||||
/* Animations de transition entre pages */
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.page-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.layout__overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout__sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.layout--sidebar-right .layout__sidebar {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.layout__content {
|
||||
padding: var(--neu-space-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
frontend/src/components/Navbar.vue
Normal file
121
frontend/src/components/Navbar.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<header class="navbar">
|
||||
<!-- Bouton hamburger (mobile) -->
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost navbar__mobile-menu" @click="uiStore.mobileMenuOpen = !uiStore.mobileMenuOpen">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Titre de la page courante -->
|
||||
<h1 class="navbar__title">{{ currentPageTitle }}</h1>
|
||||
|
||||
<!-- Actions navbar -->
|
||||
<div class="navbar__actions">
|
||||
<!-- Toggle thème sombre/clair -->
|
||||
<button
|
||||
class="neu-btn neu-btn--icon neu-btn--ghost"
|
||||
:title="uiStore.theme === 'dark' ? t('navbar.lightMode') : t('navbar.darkMode')"
|
||||
@click="uiStore.toggleTheme()"
|
||||
>
|
||||
<!-- Icône soleil (mode clair) / lune (mode sombre) -->
|
||||
<svg v-if="uiStore.theme === 'dark'" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Langue -->
|
||||
<select class="neu-input navbar__lang-select" :value="locale" @change="changeLang(($event.target as HTMLSelectElement).value)">
|
||||
<option value="fr">FR</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
|
||||
<!-- Déconnexion -->
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost" :title="t('navbar.logout')" @click="handleLogout">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const uiStore = useUiStore()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Titre de la page courante selon la route
|
||||
const currentPageTitle = computed(() => {
|
||||
const name = route.name as string
|
||||
return t(`nav.${name}`, name || 'Dashboard')
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function changeLang(lang: string) {
|
||||
locale.value = lang
|
||||
localStorage.setItem('pxp_locale', lang)
|
||||
authStore.updatePreferences({ lang })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-md);
|
||||
padding: 0 var(--neu-space-lg);
|
||||
height: 64px;
|
||||
background: var(--neu-surface);
|
||||
border-bottom: 1px solid var(--neu-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar__title {
|
||||
flex: 1;
|
||||
font-size: var(--neu-font-xl);
|
||||
font-weight: 600;
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.navbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.navbar__lang-select {
|
||||
width: auto;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--neu-font-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar__mobile-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar__mobile-menu {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
frontend/src/components/Sidebar.vue
Normal file
310
frontend/src/components/Sidebar.vue
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<template>
|
||||
<aside class="sidebar">
|
||||
<!-- En-tête sidebar avec logo + nom instance -->
|
||||
<div class="sidebar__header">
|
||||
<div class="sidebar__logo">
|
||||
<div class="sidebar__logo-icon neu-card">PX</div>
|
||||
<Transition name="fade">
|
||||
<span v-if="!uiStore.sidebarCollapsed" class="sidebar__logo-text">
|
||||
{{ instanceName || 'ProxmoxPanel' }}
|
||||
</span>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Bouton réduction sidebar -->
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost sidebar__collapse-btn" @click="uiStore.toggleSidebarCollapse()">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="!uiStore.sidebarCollapsed" d="M15 18l-6-6 6-6"/>
|
||||
<path v-else d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation principale -->
|
||||
<nav class="sidebar__nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.path"
|
||||
class="sidebar__nav-item"
|
||||
:class="{ 'sidebar__nav-item--active': isActive(item.path) }"
|
||||
:title="uiStore.sidebarCollapsed ? t(item.label) : undefined"
|
||||
>
|
||||
<span class="sidebar__nav-icon" v-html="item.icon" />
|
||||
<Transition name="fade">
|
||||
<span v-if="!uiStore.sidebarCollapsed" class="sidebar__nav-label">
|
||||
{{ t(item.label) }}
|
||||
</span>
|
||||
</Transition>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Bas de la sidebar : infos utilisateur -->
|
||||
<div class="sidebar__footer">
|
||||
<div class="sidebar__user">
|
||||
<div class="sidebar__user-avatar neu-card">
|
||||
{{ authStore.user?.username?.[0]?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<Transition name="fade">
|
||||
<div v-if="!uiStore.sidebarCollapsed" class="sidebar__user-info">
|
||||
<div class="sidebar__user-name">{{ authStore.user?.username }}</div>
|
||||
<div class="sidebar__user-role">
|
||||
<span v-if="authStore.user?.is_admin" class="neu-badge neu-badge--primary">Admin</span>
|
||||
<span v-else class="neu-badge neu-badge--info">User</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const uiStore = useUiStore()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
// Récupérer le nom de l'instance depuis le localStorage (chargé au démarrage)
|
||||
const instanceName = computed(() => localStorage.getItem('pxp_instance_name') || '')
|
||||
|
||||
// Items de navigation — les modules désactivés sont filtrés par le router
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
name: 'dashboard',
|
||||
path: '/',
|
||||
label: 'nav.dashboard',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'proxmox',
|
||||
path: '/proxmox',
|
||||
label: 'nav.proxmox',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'updates',
|
||||
path: '/updates',
|
||||
label: 'nav.updates',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'terminal',
|
||||
path: '/terminal',
|
||||
label: 'nav.terminal',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
path: '/files',
|
||||
label: 'nav.files',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'logs',
|
||||
path: '/logs',
|
||||
label: 'nav.logs',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'services',
|
||||
path: '/services',
|
||||
label: 'nav.services',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93A10 10 0 1 0 4.93 19.07"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
path: '/settings',
|
||||
label: 'nav.settings',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
||||
},
|
||||
]
|
||||
|
||||
// Afficher uniquement les routes admin si l'utilisateur est admin
|
||||
if (authStore.user?.is_admin) {
|
||||
items.push({
|
||||
name: 'modules',
|
||||
path: '/modules',
|
||||
label: 'nav.modules',
|
||||
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 21h7v-7h-7z"/></svg>`,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--neu-surface);
|
||||
border-right: 1px solid var(--neu-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-sidebar="right"] .sidebar {
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--neu-border);
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--neu-space-md);
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--neu-border);
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar__logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: var(--neu-primary);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: var(--neu-radius-sm);
|
||||
}
|
||||
|
||||
.sidebar__logo-text {
|
||||
font-size: var(--neu-font-md);
|
||||
font-weight: 600;
|
||||
color: var(--neu-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar__collapse-btn {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sidebar__collapse-btn:hover { opacity: 1; }
|
||||
|
||||
.sidebar__nav {
|
||||
flex: 1;
|
||||
padding: var(--neu-space-sm);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar__nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
padding: 10px var(--neu-space-sm);
|
||||
border-radius: var(--neu-radius-md);
|
||||
color: var(--neu-text-muted);
|
||||
text-decoration: none;
|
||||
transition: var(--neu-transition);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar__nav-item:hover {
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
box-shadow:
|
||||
3px 3px 6px var(--neu-shadow-dark),
|
||||
-2px -2px 4px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
.sidebar__nav-item--active {
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-primary);
|
||||
box-shadow:
|
||||
inset 2px 2px 5px var(--neu-shadow-dark),
|
||||
inset -1px -1px 3px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
.sidebar__nav-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.sidebar__nav-label {
|
||||
font-size: var(--neu-font-md);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
padding: var(--neu-space-sm) var(--neu-space-md);
|
||||
border-top: 1px solid var(--neu-border);
|
||||
}
|
||||
|
||||
.sidebar__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar__user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--neu-primary);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sidebar__user-info {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar__user-name {
|
||||
font-size: var(--neu-font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--neu-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar__user-role {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
140
frontend/src/locales/en.json
Normal file
140
frontend/src/locales/en.json
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"proxmox": "Proxmox",
|
||||
"updates": "Updates",
|
||||
"terminal": "Terminal",
|
||||
"files": "Files",
|
||||
"logs": "Logs",
|
||||
"services": "Services",
|
||||
"settings": "Settings",
|
||||
"modules": "Modules"
|
||||
},
|
||||
"navbar": {
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"install": {
|
||||
"subtitle": "Initial configuration of the management panel",
|
||||
"step1": { "label": "General", "title": "General configuration", "desc": "Configure the instance name and public URL." },
|
||||
"step2": { "label": "SSH", "title": "SSH Connection", "desc": "Configure SSH access to the Proxmox server. These credentials will be used for authentication and management." },
|
||||
"step3": { "label": "Proxmox", "title": "Proxmox API", "desc": "Optional — Configure the API token to access Proxmox metrics." },
|
||||
"step4": { "label": "Confirm", "title": "Confirm configuration" },
|
||||
"instanceName": "Instance name",
|
||||
"instanceNamePlaceholder": "My Proxmox server",
|
||||
"publicUrl": "Public URL",
|
||||
"publicUrlHint": "Auto-detected: {url}",
|
||||
"defaultLang": "Default language",
|
||||
"sshHost": "SSH host (host:port)",
|
||||
"sshUsername": "SSH username",
|
||||
"sshPassword": "SSH password",
|
||||
"testSSH": "Test SSH connection",
|
||||
"sshSuccess": "SSH connection successful!",
|
||||
"sshFailed": "SSH connection failed",
|
||||
"proxmoxUrl": "Proxmox URL",
|
||||
"proxmoxToken": "Proxmox API token",
|
||||
"proxmoxTokenHint": "Format: PVEAPIToken=user@realm!tokenid=secret",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Complete installation",
|
||||
"error": "Installation error",
|
||||
"networkError": "Network error"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Login with your Linux credentials",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Your Linux login",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Your password",
|
||||
"submit": "Login",
|
||||
"loading": "Logging in...",
|
||||
"error": "Authentication error",
|
||||
"hint": "Use your server's Linux credentials"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Hello, {name}",
|
||||
"addWidget": "Add widget",
|
||||
"lxcStatus": "LXC Status",
|
||||
"metrics": "Metrics",
|
||||
"noData": "No data available",
|
||||
"lxcCount": "Total LXC",
|
||||
"running": "Running",
|
||||
"widgetShortcut": "Shortcut",
|
||||
"widgetLXC": "LXC Status",
|
||||
"widgetMetrics": "Metrics"
|
||||
},
|
||||
"proxmox": {
|
||||
"all": "All",
|
||||
"lxc": "LXC",
|
||||
"vm": "VM",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"error": "Proxmox API error",
|
||||
"liveUpdates": "Live updates",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"updates": {
|
||||
"desc": "Run apt updates on the host or LXC containers.",
|
||||
"selectTarget": "Select target",
|
||||
"targetHost": "Proxmox Host",
|
||||
"targetAll": "All LXC",
|
||||
"start": "Start update",
|
||||
"running": "Updating...",
|
||||
"output": "Output",
|
||||
"history": "History",
|
||||
"noHistory": "No updates performed",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"pending": "Pending"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connected to {host}",
|
||||
"disconnected": "Disconnected",
|
||||
"connect": "Connect",
|
||||
"reconnect": "Reconnect"
|
||||
},
|
||||
"files": {
|
||||
"desc": "SFTP file browser",
|
||||
"moduleNotEnabled": "Module not enabled. Go to Settings → Modules to enable it."
|
||||
},
|
||||
"settings": {
|
||||
"general": "General",
|
||||
"infrastructure": "Infrastructure",
|
||||
"appearance": "Appearance",
|
||||
"audit": "Audit log",
|
||||
"instanceName": "Instance name",
|
||||
"publicUrl": "Public URL",
|
||||
"defaultLang": "Default language",
|
||||
"sshHost": "SSH host",
|
||||
"sshUsername": "SSH username",
|
||||
"proxmoxUrl": "Proxmox URL",
|
||||
"darkMode": "Dark mode",
|
||||
"sidebarPosition": "Sidebar position",
|
||||
"left": "Left",
|
||||
"right": "Right",
|
||||
"noAuditLog": "No audit log entries"
|
||||
},
|
||||
"modules": {
|
||||
"desc": "Manage installed modules on ProxmoxPanel.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"coreProtected": "CORE module (cannot be disabled)",
|
||||
"restartNotice": "A server restart is required to apply changes."
|
||||
},
|
||||
"common": {
|
||||
"refresh": "Refresh",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved!",
|
||||
"cancel": "Cancel",
|
||||
"networkError": "Network error"
|
||||
}
|
||||
}
|
||||
140
frontend/src/locales/fr.json
Normal file
140
frontend/src/locales/fr.json
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
{
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"proxmox": "Proxmox",
|
||||
"updates": "Mises à jour",
|
||||
"terminal": "Terminal",
|
||||
"files": "Fichiers",
|
||||
"logs": "Journaux",
|
||||
"services": "Services",
|
||||
"settings": "Paramètres",
|
||||
"modules": "Modules"
|
||||
},
|
||||
"navbar": {
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair",
|
||||
"logout": "Se déconnecter"
|
||||
},
|
||||
"install": {
|
||||
"subtitle": "Configuration initiale du panneau de gestion",
|
||||
"step1": { "label": "Général", "title": "Configuration générale", "desc": "Configurez le nom de l'instance et l'URL publique." },
|
||||
"step2": { "label": "SSH", "title": "Connexion SSH", "desc": "Configurez l'accès SSH au serveur Proxmox. Ces identifiants seront utilisés pour l'authentification et la gestion." },
|
||||
"step3": { "label": "Proxmox", "title": "API Proxmox", "desc": "Optionnel — Configurez le token API pour accéder aux métriques Proxmox." },
|
||||
"step4": { "label": "Confirmation", "title": "Confirmer la configuration" },
|
||||
"instanceName": "Nom de l'instance",
|
||||
"instanceNamePlaceholder": "Mon serveur Proxmox",
|
||||
"publicUrl": "URL publique",
|
||||
"publicUrlHint": "Détecté automatiquement : {url}",
|
||||
"defaultLang": "Langue par défaut",
|
||||
"sshHost": "Hôte SSH (host:port)",
|
||||
"sshUsername": "Nom d'utilisateur SSH",
|
||||
"sshPassword": "Mot de passe SSH",
|
||||
"testSSH": "Tester la connexion SSH",
|
||||
"sshSuccess": "Connexion SSH réussie !",
|
||||
"sshFailed": "Connexion SSH échouée",
|
||||
"proxmoxUrl": "URL Proxmox",
|
||||
"proxmoxToken": "Token API Proxmox",
|
||||
"proxmoxTokenHint": "Format : PVEAPIToken=user@realm!tokenid=secret",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"finish": "Terminer l'installation",
|
||||
"error": "Erreur lors de l'installation",
|
||||
"networkError": "Erreur réseau"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Connectez-vous avec vos identifiants Linux",
|
||||
"username": "Nom d'utilisateur",
|
||||
"usernamePlaceholder": "Votre login Linux",
|
||||
"password": "Mot de passe",
|
||||
"passwordPlaceholder": "Votre mot de passe",
|
||||
"submit": "Se connecter",
|
||||
"loading": "Connexion...",
|
||||
"error": "Erreur d'authentification",
|
||||
"hint": "Utilisez vos identifiants Linux du serveur"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Bonjour, {name}",
|
||||
"addWidget": "Ajouter un widget",
|
||||
"lxcStatus": "Statut LXC",
|
||||
"metrics": "Métriques",
|
||||
"noData": "Données non disponibles",
|
||||
"lxcCount": "LXC Total",
|
||||
"running": "En cours",
|
||||
"widgetShortcut": "Raccourci",
|
||||
"widgetLXC": "Statut LXC",
|
||||
"widgetMetrics": "Métriques"
|
||||
},
|
||||
"proxmox": {
|
||||
"all": "Tous",
|
||||
"lxc": "LXC",
|
||||
"vm": "VM",
|
||||
"running": "En marche",
|
||||
"stopped": "Arrêté",
|
||||
"start": "Démarrer",
|
||||
"stop": "Arrêter",
|
||||
"error": "Erreur API Proxmox",
|
||||
"liveUpdates": "Mises à jour en temps réel",
|
||||
"disconnected": "Déconnecté"
|
||||
},
|
||||
"updates": {
|
||||
"desc": "Lancez des mises à jour apt sur le host ou les LXC.",
|
||||
"selectTarget": "Sélectionner la cible",
|
||||
"targetHost": "Host Proxmox",
|
||||
"targetAll": "Tous les LXC",
|
||||
"start": "Lancer la mise à jour",
|
||||
"running": "Mise à jour en cours...",
|
||||
"output": "Sortie",
|
||||
"history": "Historique",
|
||||
"noHistory": "Aucune mise à jour effectuée",
|
||||
"status": {
|
||||
"running": "En cours",
|
||||
"success": "Succès",
|
||||
"error": "Erreur",
|
||||
"pending": "En attente"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"connected": "Connecté à {host}",
|
||||
"disconnected": "Déconnecté",
|
||||
"connect": "Connecter",
|
||||
"reconnect": "Reconnecter"
|
||||
},
|
||||
"files": {
|
||||
"desc": "Navigateur de fichiers SFTP",
|
||||
"moduleNotEnabled": "Module non activé. Rendez-vous dans Paramètres → Modules pour l'activer."
|
||||
},
|
||||
"settings": {
|
||||
"general": "Général",
|
||||
"infrastructure": "Infrastructure",
|
||||
"appearance": "Apparence",
|
||||
"audit": "Journal d'audit",
|
||||
"instanceName": "Nom de l'instance",
|
||||
"publicUrl": "URL publique",
|
||||
"defaultLang": "Langue par défaut",
|
||||
"sshHost": "Hôte SSH",
|
||||
"sshUsername": "Utilisateur SSH",
|
||||
"proxmoxUrl": "URL Proxmox",
|
||||
"darkMode": "Mode sombre",
|
||||
"sidebarPosition": "Position de la sidebar",
|
||||
"left": "Gauche",
|
||||
"right": "Droite",
|
||||
"noAuditLog": "Aucune entrée dans le journal"
|
||||
},
|
||||
"modules": {
|
||||
"desc": "Gérez les modules installés sur ProxmoxPanel.",
|
||||
"enabled": "Actif",
|
||||
"disabled": "Inactif",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"coreProtected": "Module CORE (non désactivable)",
|
||||
"restartNotice": "Un redémarrage du serveur est nécessaire pour appliquer les changements."
|
||||
},
|
||||
"common": {
|
||||
"refresh": "Actualiser",
|
||||
"save": "Sauvegarder",
|
||||
"saving": "Sauvegarde...",
|
||||
"saved": "Sauvegardé !",
|
||||
"cancel": "Annuler",
|
||||
"networkError": "Erreur réseau"
|
||||
}
|
||||
}
|
||||
37
frontend/src/main.ts
Normal file
37
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Point d'entrée de l'application ProxmoxPanel Frontend.
|
||||
// Initialise Vue 3, Pinia, Vue Router et vue-i18n.
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router/index'
|
||||
|
||||
// Imports des fichiers de traduction (locaux, pas de CDN)
|
||||
import fr from './locales/fr.json'
|
||||
import en from './locales/en.json'
|
||||
|
||||
// Styles Neumorphism — chargés globalement
|
||||
import './styles/neu.css'
|
||||
import './styles/dark.css'
|
||||
import './styles/light.css'
|
||||
|
||||
// Déterminer la locale initiale (localStorage > défaut 'fr')
|
||||
const savedLocale = localStorage.getItem('pxp_locale') || 'fr'
|
||||
|
||||
// Initialisation vue-i18n
|
||||
const i18n = createI18n({
|
||||
legacy: false, // Utiliser la Composition API
|
||||
locale: savedLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages: { fr, en },
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
128
frontend/src/router/index.ts
Normal file
128
frontend/src/router/index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Configuration du routeur Vue — gère la navigation et la protection des routes.
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// Page d'installation (premier lancement)
|
||||
{
|
||||
path: '/install',
|
||||
name: 'install',
|
||||
component: () => import('@/views/Install.vue'),
|
||||
meta: { public: true, hideLayout: true },
|
||||
},
|
||||
|
||||
// Authentification
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { public: true, hideLayout: true },
|
||||
},
|
||||
|
||||
// Application principale (protégée)
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/Layout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: 'proxmox',
|
||||
name: 'proxmox',
|
||||
component: () => import('@/views/Proxmox.vue'),
|
||||
},
|
||||
{
|
||||
path: 'updates',
|
||||
name: 'updates',
|
||||
component: () => import('@/views/Updates.vue'),
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'files',
|
||||
component: () => import('@/views/Files.vue'),
|
||||
meta: { module: 'files' },
|
||||
},
|
||||
{
|
||||
path: 'terminal',
|
||||
name: 'terminal',
|
||||
component: () => import('@/views/Terminal.vue'),
|
||||
meta: { module: 'terminal' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/Logs.vue'),
|
||||
meta: { module: 'logs' },
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
name: 'services',
|
||||
component: () => import('@/views/Services.vue'),
|
||||
meta: { module: 'services' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'modules',
|
||||
component: () => import('@/views/Modules.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Redirection 404
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Guard de navigation : vérification authentification et installation
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Vérifier si l'application est installée (appel API au premier chargement)
|
||||
if (!authStore.installChecked) {
|
||||
await authStore.checkInstallation()
|
||||
}
|
||||
|
||||
// Rediriger vers l'installation si pas encore configuré
|
||||
if (!authStore.isInstalled && to.name !== 'install') {
|
||||
return { name: 'install' }
|
||||
}
|
||||
|
||||
// Si installé et route d'install → rediriger vers le dashboard
|
||||
if (authStore.isInstalled && to.name === 'install') {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
|
||||
// Routes publiques — passer directement
|
||||
if (to.meta.public) return true
|
||||
|
||||
// Routes protégées — vérifier l'authentification
|
||||
if (to.meta.requiresAuth || to.matched.some(r => r.meta.requiresAuth)) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
|
||||
// Routes admin uniquement
|
||||
if (to.meta.requiresAdmin && !authStore.user?.is_admin) {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
180
frontend/src/stores/auth.store.ts
Normal file
180
frontend/src/stores/auth.store.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// Store d'authentification — gère la session JWT, le profil utilisateur et l'état d'installation.
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
is_admin: boolean
|
||||
lang: string
|
||||
theme: string
|
||||
sidebar_position: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// État
|
||||
const user = ref<User | null>(null)
|
||||
const accessToken = ref<string | null>(localStorage.getItem('pxp_token'))
|
||||
const isInstalled = ref(false)
|
||||
const installChecked = ref(false)
|
||||
|
||||
// Computed
|
||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Vérifie si l'application est installée via l'API.
|
||||
* Appelé une seule fois au démarrage par le router guard.
|
||||
*/
|
||||
async function checkInstallation(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/install/check')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
isInstalled.value = data.installed
|
||||
}
|
||||
} catch {
|
||||
// En cas d'erreur réseau, on suppose installé pour éviter une boucle
|
||||
isInstalled.value = true
|
||||
} finally {
|
||||
installChecked.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifie l'utilisateur avec ses credentials Linux.
|
||||
*/
|
||||
async function login(username: string, password: string): Promise<void> {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Erreur d\'authentification')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
accessToken.value = data.access_token
|
||||
localStorage.setItem('pxp_token', data.access_token)
|
||||
user.value = data.user
|
||||
|
||||
// Planifier le renouvellement automatique avant expiration (14 min)
|
||||
scheduleRefresh(14 * 60 * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente de renouveler le token via le cookie httpOnly (pxp_refresh).
|
||||
* Appelé au démarrage de l'application.
|
||||
*/
|
||||
async function tryRefresh(): Promise<void> {
|
||||
const token = localStorage.getItem('pxp_token')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Inclure le cookie httpOnly
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
accessToken.value = data.access_token
|
||||
localStorage.setItem('pxp_token', data.access_token)
|
||||
|
||||
// Charger le profil utilisateur
|
||||
await fetchMe()
|
||||
scheduleRefresh(14 * 60 * 1000)
|
||||
} else {
|
||||
// Refresh échoué — nettoyer la session
|
||||
clearSession()
|
||||
}
|
||||
} catch {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le profil de l'utilisateur connecté.
|
||||
*/
|
||||
async function fetchMe(): Promise<void> {
|
||||
if (!accessToken.value) return
|
||||
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${accessToken.value}` },
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
user.value = await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déconnecte l'utilisateur.
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken.value}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les préférences de l'utilisateur (thème, langue, sidebar).
|
||||
*/
|
||||
async function updatePreferences(prefs: Partial<Pick<User, 'lang' | 'theme' | 'sidebar_position'>>): Promise<void> {
|
||||
if (!accessToken.value) return
|
||||
|
||||
await fetch('/api/auth/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken.value}`,
|
||||
},
|
||||
body: JSON.stringify(prefs),
|
||||
})
|
||||
|
||||
// Mettre à jour localement
|
||||
if (user.value) {
|
||||
Object.assign(user.value, prefs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers privés ────────────────────────────────────────────────────────
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleRefresh(delayMs: number): void {
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
refreshTimer = setTimeout(() => tryRefresh(), delayMs)
|
||||
}
|
||||
|
||||
function clearSession(): void {
|
||||
user.value = null
|
||||
accessToken.value = null
|
||||
localStorage.removeItem('pxp_token')
|
||||
if (refreshTimer) clearTimeout(refreshTimer)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
isInstalled,
|
||||
installChecked,
|
||||
isAuthenticated,
|
||||
checkInstallation,
|
||||
login,
|
||||
logout,
|
||||
tryRefresh,
|
||||
fetchMe,
|
||||
updatePreferences,
|
||||
}
|
||||
})
|
||||
84
frontend/src/stores/ui.store.ts
Normal file
84
frontend/src/stores/ui.store.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Store UI — gère le thème (dark/light) et la position de la sidebar.
|
||||
// Les préférences sont persistées localement et synchronisées avec le serveur.
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
export type SidebarPosition = 'left' | 'right'
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const theme = ref<Theme>('dark')
|
||||
const sidebarPosition = ref<SidebarPosition>('left')
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Initialise le thème depuis les préférences locales.
|
||||
* Appelé au montage de App.vue.
|
||||
*/
|
||||
function initTheme(): void {
|
||||
const savedTheme = localStorage.getItem('pxp_theme') as Theme | null
|
||||
const savedSidebar = localStorage.getItem('pxp_sidebar') as SidebarPosition | null
|
||||
|
||||
if (savedTheme === 'dark' || savedTheme === 'light') {
|
||||
theme.value = savedTheme
|
||||
}
|
||||
if (savedSidebar === 'left' || savedSidebar === 'right') {
|
||||
sidebarPosition.value = savedSidebar
|
||||
}
|
||||
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule entre thème sombre et clair.
|
||||
*/
|
||||
function toggleTheme(): void {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('pxp_theme', theme.value)
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Définit le thème explicitement.
|
||||
*/
|
||||
function setTheme(newTheme: Theme): void {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('pxp_theme', newTheme)
|
||||
applyTheme(newTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Définit la position de la sidebar.
|
||||
*/
|
||||
function setSidebarPosition(pos: SidebarPosition): void {
|
||||
sidebarPosition.value = pos
|
||||
localStorage.setItem('pxp_sidebar', pos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'état réduit de la sidebar.
|
||||
*/
|
||||
function toggleSidebarCollapse(): void {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le thème sur l'élément <html> via data-theme.
|
||||
*/
|
||||
function applyTheme(t: Theme): void {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
sidebarPosition,
|
||||
sidebarCollapsed,
|
||||
mobileMenuOpen,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
setSidebarPosition,
|
||||
toggleSidebarCollapse,
|
||||
}
|
||||
})
|
||||
46
frontend/src/styles/dark.css
Normal file
46
frontend/src/styles/dark.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* ============================================================
|
||||
ProxmoxPanel — Thème sombre (mode par défaut)
|
||||
============================================================ */
|
||||
|
||||
[data-theme="dark"],
|
||||
:root {
|
||||
--neu-bg: #1a1d2e;
|
||||
--neu-surface: #212438;
|
||||
--neu-text: #e2e6f6;
|
||||
--neu-text-muted: #6b7694;
|
||||
--neu-border: #2a2f4a;
|
||||
|
||||
--neu-shadow-dark: #13162280;
|
||||
--neu-shadow-light: #2d3356;
|
||||
|
||||
--neu-primary: #6c8ef4;
|
||||
--neu-primary-dim: #4a6bd4;
|
||||
--neu-success: #4cbb8a;
|
||||
--neu-warning: #f5a623;
|
||||
--neu-danger: #f05c6b;
|
||||
--neu-info: #3dbfcf;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Corps de page */
|
||||
[data-theme="dark"] body {
|
||||
background-color: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
/* Accents spécifiques au mode sombre */
|
||||
[data-theme="dark"] .neu-card {
|
||||
background: var(--neu-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .neu-input {
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
/* Sélections */
|
||||
[data-theme="dark"] ::selection {
|
||||
background: rgba(108, 142, 244, 0.3);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
70
frontend/src/styles/light.css
Normal file
70
frontend/src/styles/light.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/* ============================================================
|
||||
ProxmoxPanel — Thème clair
|
||||
============================================================ */
|
||||
|
||||
[data-theme="light"] {
|
||||
--neu-bg: #e8ecf2;
|
||||
--neu-surface: #eef1f8;
|
||||
--neu-text: #2d3561;
|
||||
--neu-text-muted: #8892b0;
|
||||
--neu-border: #d4d9e8;
|
||||
|
||||
--neu-shadow-dark: #c8cdd8;
|
||||
--neu-shadow-light: #ffffff;
|
||||
|
||||
--neu-primary: #4a6bd4;
|
||||
--neu-primary-dim: #3558c0;
|
||||
--neu-success: #2ea87a;
|
||||
--neu-warning: #d4860e;
|
||||
--neu-danger: #d43f52;
|
||||
--neu-info: #1fa8bc;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
background-color: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
[data-theme="light"] .neu-card {
|
||||
background: var(--neu-surface);
|
||||
box-shadow:
|
||||
5px 5px 10px var(--neu-shadow-dark),
|
||||
-5px -5px 10px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
[data-theme="light"] .neu-inset {
|
||||
background: var(--neu-bg);
|
||||
box-shadow:
|
||||
inset 3px 3px 7px var(--neu-shadow-dark),
|
||||
inset -3px -3px 7px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
[data-theme="light"] .neu-btn {
|
||||
box-shadow:
|
||||
4px 4px 8px var(--neu-shadow-dark),
|
||||
-4px -4px 8px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
[data-theme="light"] .neu-input {
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
box-shadow:
|
||||
inset 3px 3px 7px var(--neu-shadow-dark),
|
||||
inset -3px -3px 7px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
[data-theme="light"] ::selection {
|
||||
background: rgba(74, 107, 212, 0.25);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
/* Scrollbar thème clair */
|
||||
[data-theme="light"] ::-webkit-scrollbar-track {
|
||||
background: var(--neu-bg);
|
||||
}
|
||||
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb {
|
||||
background: var(--neu-border);
|
||||
}
|
||||
372
frontend/src/styles/neu.css
Normal file
372
frontend/src/styles/neu.css
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
/* =============================================================================
|
||||
ProxmoxPanel — Système de design Neumorphism
|
||||
Définit les classes utilitaires et les variables CSS du thème.
|
||||
Utilisé par tous les composants et les modules.
|
||||
============================================================================= */
|
||||
|
||||
/* ── Variables CSS (surchargées par dark.css et light.css) ─────────────────── */
|
||||
:root {
|
||||
/* Couleurs de base */
|
||||
--neu-bg: #1e2130;
|
||||
--neu-surface: #252838;
|
||||
--neu-text: #e0e4f0;
|
||||
--neu-text-muted: #7f8899;
|
||||
--neu-border: #2e3348;
|
||||
|
||||
/* Ombres Neumorphism */
|
||||
--neu-shadow-dark: #161823;
|
||||
--neu-shadow-light: #2a2f48;
|
||||
|
||||
/* Couleurs d'accent */
|
||||
--neu-primary: #6c8ef4;
|
||||
--neu-primary-dim: #4a6bd4;
|
||||
--neu-success: #4cbb8a;
|
||||
--neu-warning: #f5a623;
|
||||
--neu-danger: #f05c6b;
|
||||
--neu-info: #3dbfcf;
|
||||
|
||||
/* Typographie */
|
||||
--neu-font-xs: 11px;
|
||||
--neu-font-sm: 12px;
|
||||
--neu-font-md: 14px;
|
||||
--neu-font-lg: 16px;
|
||||
--neu-font-xl: 20px;
|
||||
--neu-font-2xl: 24px;
|
||||
|
||||
/* Espacements */
|
||||
--neu-space-xs: 4px;
|
||||
--neu-space-sm: 8px;
|
||||
--neu-space-md: 16px;
|
||||
--neu-space-lg: 24px;
|
||||
--neu-space-xl: 32px;
|
||||
|
||||
/* Rayons */
|
||||
--neu-radius-sm: 8px;
|
||||
--neu-radius-md: 12px;
|
||||
--neu-radius-lg: 16px;
|
||||
--neu-radius-xl: 24px;
|
||||
--neu-radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--neu-transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
|
||||
/* Z-index */
|
||||
--z-sidebar: 100;
|
||||
--z-navbar: 200;
|
||||
--z-modal: 300;
|
||||
--z-toast: 400;
|
||||
}
|
||||
|
||||
/* ── Carte Neumorphism (élévation convexe) ─────────────────────────────────── */
|
||||
.neu-card {
|
||||
background: var(--neu-surface);
|
||||
border-radius: var(--neu-radius-lg);
|
||||
box-shadow:
|
||||
6px 6px 12px var(--neu-shadow-dark),
|
||||
-4px -4px 10px var(--neu-shadow-light);
|
||||
border: 1px solid var(--neu-border);
|
||||
padding: var(--neu-space-md);
|
||||
color: var(--neu-text);
|
||||
transition: var(--neu-transition);
|
||||
}
|
||||
|
||||
.neu-card--flat {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--neu-border);
|
||||
}
|
||||
|
||||
.neu-card--hover:hover {
|
||||
box-shadow:
|
||||
8px 8px 16px var(--neu-shadow-dark),
|
||||
-6px -6px 14px var(--neu-shadow-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Surface enfoncée (inputs, zones de saisie) ────────────────────────────── */
|
||||
.neu-inset {
|
||||
background: var(--neu-bg);
|
||||
border-radius: var(--neu-radius-md);
|
||||
box-shadow:
|
||||
inset 3px 3px 7px var(--neu-shadow-dark),
|
||||
inset -2px -2px 5px var(--neu-shadow-light);
|
||||
border: 1px solid var(--neu-border);
|
||||
}
|
||||
|
||||
/* ── Boutons ────────────────────────────────────────────────────────────────── */
|
||||
.neu-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--neu-space-xs);
|
||||
padding: var(--neu-space-sm) var(--neu-space-md);
|
||||
border-radius: var(--neu-radius-md);
|
||||
font-size: var(--neu-font-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: var(--neu-transition);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
color: var(--neu-text);
|
||||
background: var(--neu-surface);
|
||||
box-shadow:
|
||||
4px 4px 8px var(--neu-shadow-dark),
|
||||
-3px -3px 6px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
.neu-btn:hover:not(:disabled) {
|
||||
box-shadow:
|
||||
6px 6px 12px var(--neu-shadow-dark),
|
||||
-4px -4px 8px var(--neu-shadow-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.neu-btn:active:not(:disabled) {
|
||||
box-shadow:
|
||||
inset 2px 2px 5px var(--neu-shadow-dark),
|
||||
inset -1px -1px 3px var(--neu-shadow-light);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.neu-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Variantes de boutons */
|
||||
.neu-btn--primary {
|
||||
background: var(--neu-primary);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
4px 4px 8px rgba(0,0,0,0.3),
|
||||
-2px -2px 6px rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.neu-btn--primary:hover:not(:disabled) {
|
||||
background: var(--neu-primary-dim);
|
||||
box-shadow:
|
||||
6px 6px 12px rgba(0,0,0,0.4),
|
||||
-3px -3px 8px rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.neu-btn--success {
|
||||
background: var(--neu-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.neu-btn--danger {
|
||||
background: var(--neu-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.neu-btn--warning {
|
||||
background: var(--neu-warning);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.neu-btn--sm {
|
||||
padding: 4px 10px;
|
||||
font-size: var(--neu-font-sm);
|
||||
border-radius: var(--neu-radius-sm);
|
||||
}
|
||||
|
||||
.neu-btn--lg {
|
||||
padding: 12px 24px;
|
||||
font-size: var(--neu-font-lg);
|
||||
border-radius: var(--neu-radius-lg);
|
||||
}
|
||||
|
||||
.neu-btn--icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: var(--neu-radius-md);
|
||||
}
|
||||
|
||||
.neu-btn--ghost {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--neu-border);
|
||||
}
|
||||
|
||||
.neu-btn--ghost:hover:not(:disabled) {
|
||||
background: var(--neu-surface);
|
||||
box-shadow:
|
||||
2px 2px 5px var(--neu-shadow-dark),
|
||||
-1px -1px 3px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
/* ── Inputs ─────────────────────────────────────────────────────────────────── */
|
||||
.neu-input {
|
||||
width: 100%;
|
||||
padding: var(--neu-space-sm) var(--neu-space-md);
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
border: 1px solid var(--neu-border);
|
||||
border-radius: var(--neu-radius-md);
|
||||
font-size: var(--neu-font-md);
|
||||
outline: none;
|
||||
transition: var(--neu-transition);
|
||||
box-shadow:
|
||||
inset 3px 3px 7px var(--neu-shadow-dark),
|
||||
inset -2px -2px 5px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
.neu-input:focus {
|
||||
border-color: var(--neu-primary);
|
||||
box-shadow:
|
||||
inset 3px 3px 7px var(--neu-shadow-dark),
|
||||
inset -2px -2px 5px var(--neu-shadow-light),
|
||||
0 0 0 2px rgba(108, 142, 244, 0.25);
|
||||
}
|
||||
|
||||
.neu-input::placeholder {
|
||||
color: var(--neu-text-muted);
|
||||
}
|
||||
|
||||
.neu-input--error {
|
||||
border-color: var(--neu-danger) !important;
|
||||
}
|
||||
|
||||
/* ── Toggle switch ───────────────────────────────────────────────────────────── */
|
||||
.neu-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.neu-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.neu-toggle__slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--neu-bg);
|
||||
border-radius: var(--neu-radius-full);
|
||||
cursor: pointer;
|
||||
transition: var(--neu-transition);
|
||||
box-shadow:
|
||||
inset 2px 2px 5px var(--neu-shadow-dark),
|
||||
inset -1px -1px 3px var(--neu-shadow-light);
|
||||
}
|
||||
|
||||
.neu-toggle__slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--neu-text-muted);
|
||||
border-radius: 50%;
|
||||
transition: var(--neu-transition);
|
||||
box-shadow: 2px 2px 4px var(--neu-shadow-dark);
|
||||
}
|
||||
|
||||
.neu-toggle input:checked + .neu-toggle__slider {
|
||||
background: var(--neu-primary);
|
||||
box-shadow:
|
||||
inset 2px 2px 5px rgba(0,0,0,0.3),
|
||||
inset -1px -1px 3px rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.neu-toggle input:checked + .neu-toggle__slider::before {
|
||||
transform: translateX(20px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Badges / Tags ───────────────────────────────────────────────────────────── */
|
||||
.neu-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--neu-radius-full);
|
||||
font-size: var(--neu-font-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.neu-badge--success { background: rgba(76, 187, 138, 0.15); color: var(--neu-success); }
|
||||
.neu-badge--danger { background: rgba(240, 92, 107, 0.15); color: var(--neu-danger); }
|
||||
.neu-badge--warning { background: rgba(245, 166, 35, 0.15); color: var(--neu-warning); }
|
||||
.neu-badge--info { background: rgba(61, 191, 207, 0.15); color: var(--neu-info); }
|
||||
.neu-badge--primary { background: rgba(108, 142, 244, 0.15); color: var(--neu-primary); }
|
||||
|
||||
/* ── Séparateurs ────────────────────────────────────────────────────────────── */
|
||||
.neu-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--neu-border), transparent);
|
||||
border: none;
|
||||
margin: var(--neu-space-md) 0;
|
||||
}
|
||||
|
||||
/* ── Utilitaires de layout ──────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-sm { gap: var(--neu-space-sm); }
|
||||
.gap-md { gap: var(--neu-space-md); }
|
||||
.gap-lg { gap: var(--neu-space-lg); }
|
||||
.w-full { width: 100%; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
/* ── Grid responsive ────────────────────────────────────────────────────────── */
|
||||
.neu-grid {
|
||||
display: grid;
|
||||
gap: var(--neu-space-md);
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* ── Scrollbar personnalisée ────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track { background: var(--neu-bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--neu-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--neu-text-muted); }
|
||||
|
||||
/* ── Animations ─────────────────────────────────────────────────────────────── */
|
||||
@keyframes neu-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes neu-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.neu-loading {
|
||||
animation: neu-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.neu-pulse {
|
||||
animation: neu-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.neu-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
:root {
|
||||
--sidebar-width: 100%;
|
||||
}
|
||||
}
|
||||
393
frontend/src/views/Dashboard.vue
Normal file
393
frontend/src/views/Dashboard.vue
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- En-tête avec bouton d'ajout de widget -->
|
||||
<div class="dashboard__header flex items-center justify-between">
|
||||
<div>
|
||||
<h2>{{ t('nav.dashboard') }}</h2>
|
||||
<p class="text-muted">{{ t('dashboard.welcome', { name: authStore.user?.username }) }}</p>
|
||||
</div>
|
||||
<button class="neu-btn neu-btn--primary" @click="showAddWidget = true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('dashboard.addWidget') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grille de widgets drag-and-drop -->
|
||||
<VueDraggable
|
||||
v-model="widgets"
|
||||
class="dashboard__grid"
|
||||
item-key="id"
|
||||
handle=".widget-drag-handle"
|
||||
@end="saveLayout"
|
||||
>
|
||||
<div
|
||||
v-for="widget in widgets"
|
||||
:key="widget.id"
|
||||
class="widget-wrapper"
|
||||
:style="{ gridColumn: `span ${widget.width}`, gridRow: `span ${widget.height}` }"
|
||||
>
|
||||
<!-- Widget raccourci service -->
|
||||
<div v-if="widget.type === 'shortcut'" class="neu-card widget widget--shortcut">
|
||||
<div class="widget__header">
|
||||
<span class="widget-drag-handle">⠿</span>
|
||||
<span class="widget__title">{{ widget.title }}</span>
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a :href="widget.config.url" target="_blank" rel="noopener" class="shortcut-link">
|
||||
<div class="shortcut-icon">{{ widget.config.icon || '🔗' }}</div>
|
||||
<div class="shortcut-url">{{ widget.config.url }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Widget statut LXC -->
|
||||
<div v-else-if="widget.type === 'lxc_status'" class="neu-card widget">
|
||||
<div class="widget__header">
|
||||
<span class="widget-drag-handle">⠿</span>
|
||||
<span class="widget__title">{{ t('dashboard.lxcStatus') }}</span>
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="lxc-list">
|
||||
<div v-for="lxc in proxmoxResources.filter(r => r.type === 'lxc').slice(0, 6)" :key="lxc.vmid" class="lxc-item">
|
||||
<span :class="['neu-badge', lxc.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
|
||||
{{ lxc.status === 'running' ? '●' : '○' }}
|
||||
</span>
|
||||
<span class="lxc-name">{{ lxc.name || `LXC ${lxc.vmid}` }}</span>
|
||||
<span class="lxc-id text-muted">{{ lxc.vmid }}</span>
|
||||
</div>
|
||||
<p v-if="proxmoxResources.length === 0" class="text-muted text-sm">
|
||||
{{ t('dashboard.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widget métriques système -->
|
||||
<div v-else-if="widget.type === 'metrics'" class="neu-card widget">
|
||||
<div class="widget__header">
|
||||
<span class="widget-drag-handle">⠿</span>
|
||||
<span class="widget__title">{{ t('dashboard.metrics') }}</span>
|
||||
<button class="neu-btn neu-btn--icon neu-btn--ghost widget__remove" @click="removeWidget(widget.id)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">{{ t('dashboard.lxcCount') }}</div>
|
||||
<div class="metric-value">{{ proxmoxResources.filter(r => r.type === 'lxc').length }}</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">{{ t('dashboard.running') }}</div>
|
||||
<div class="metric-value text-success">{{ proxmoxResources.filter(r => r.status === 'running').length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
|
||||
<!-- Modal ajout de widget -->
|
||||
<div v-if="showAddWidget" class="modal-overlay" @click.self="showAddWidget = false">
|
||||
<div class="neu-card modal">
|
||||
<h3>{{ t('dashboard.addWidget') }}</h3>
|
||||
<div class="widget-types">
|
||||
<button
|
||||
v-for="type in availableWidgetTypes"
|
||||
:key="type.id"
|
||||
class="neu-btn widget-type-btn"
|
||||
@click="addWidget(type)"
|
||||
>
|
||||
<span class="widget-type-icon">{{ type.icon }}</span>
|
||||
<span>{{ t(type.label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="neu-btn w-full" @click="showAddWidget = false">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface Widget {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
config: Record<string, string>
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const widgets = ref<Widget[]>([
|
||||
{ id: 1, type: 'lxc_status', title: 'LXC Status', config: {}, width: 2, height: 2 },
|
||||
{ id: 2, type: 'metrics', title: 'Métriques', config: {}, width: 1, height: 1 },
|
||||
{ id: 3, type: 'shortcut', title: 'Proxmox', config: { url: 'https://proxmox.geronzi.fr', icon: '🖥️' }, width: 1, height: 1 },
|
||||
{ id: 4, type: 'shortcut', title: 'Grafana', config: { url: 'https://grafana.geronzi.fr', icon: '📊' }, width: 1, height: 1 },
|
||||
])
|
||||
|
||||
const proxmoxResources = ref<any[]>([])
|
||||
const showAddWidget = ref(false)
|
||||
|
||||
const availableWidgetTypes = [
|
||||
{ id: 'shortcut', icon: '🔗', label: 'dashboard.widgetShortcut' },
|
||||
{ id: 'lxc_status', icon: '🖥️', label: 'dashboard.widgetLXC' },
|
||||
{ id: 'metrics', icon: '📊', label: 'dashboard.widgetMetrics' },
|
||||
]
|
||||
|
||||
let wsConnection: WebSocket | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
// Charger les données Proxmox
|
||||
await loadProxmoxData()
|
||||
|
||||
// Connecter le WebSocket pour les mises à jour temps réel
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
wsConnection?.close()
|
||||
})
|
||||
|
||||
async function loadProxmoxData() {
|
||||
try {
|
||||
const res = await fetch('/api/proxmox/resources', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
proxmoxResources.value = await res.json() || []
|
||||
}
|
||||
} catch { /* Silencieux — affiché via le widget */ }
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const token = authStore.accessToken
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${token}`)
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
|
||||
}
|
||||
|
||||
wsConnection.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'resources_update' && msg.payload) {
|
||||
proxmoxResources.value = msg.payload
|
||||
}
|
||||
} catch { /* Ignorer les messages invalides */ }
|
||||
}
|
||||
|
||||
wsConnection.onerror = () => {
|
||||
setTimeout(() => connectWebSocket(), 5000) // Reconnexion après 5s
|
||||
}
|
||||
}
|
||||
|
||||
function addWidget(type: { id: string; icon: string; label: string }) {
|
||||
const newId = Date.now()
|
||||
widgets.value.push({
|
||||
id: newId,
|
||||
type: type.id,
|
||||
title: t(type.label),
|
||||
config: type.id === 'shortcut' ? { url: 'https://example.com', icon: type.icon } : {},
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
showAddWidget.value = false
|
||||
saveLayout()
|
||||
}
|
||||
|
||||
function removeWidget(id: number) {
|
||||
widgets.value = widgets.value.filter(w => w.id !== id)
|
||||
saveLayout()
|
||||
}
|
||||
|
||||
function saveLayout() {
|
||||
// Sauvegarder via API (implémentation future avec endpoint dédié)
|
||||
localStorage.setItem('pxp_dashboard_layout', JSON.stringify(widgets.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.dashboard__header {
|
||||
margin-bottom: var(--neu-space-xl);
|
||||
}
|
||||
|
||||
.dashboard__header h2 {
|
||||
font-size: var(--neu-font-xl);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
.text-success { color: var(--neu-success); }
|
||||
.text-sm { font-size: var(--neu-font-sm); }
|
||||
|
||||
.dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--neu-space-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.widget__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-xs);
|
||||
margin-bottom: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.widget-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--neu-text-muted);
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.widget-drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.widget__title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: var(--neu-font-sm);
|
||||
color: var(--neu-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.widget__remove {
|
||||
opacity: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.widget:hover .widget__remove { opacity: 1; }
|
||||
|
||||
.shortcut-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
text-decoration: none;
|
||||
color: var(--neu-text);
|
||||
padding: var(--neu-space-sm);
|
||||
border-radius: var(--neu-radius-md);
|
||||
transition: var(--neu-transition);
|
||||
}
|
||||
|
||||
.shortcut-link:hover {
|
||||
background: var(--neu-bg);
|
||||
}
|
||||
|
||||
.shortcut-icon { font-size: 24px; }
|
||||
|
||||
.shortcut-url {
|
||||
font-size: var(--neu-font-sm);
|
||||
color: var(--neu-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lxc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lxc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
|
||||
.lxc-name { flex: 1; color: var(--neu-text); }
|
||||
.lxc-id { color: var(--neu-text-muted); font-family: monospace; }
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
text-align: center;
|
||||
padding: var(--neu-space-sm);
|
||||
background: var(--neu-bg);
|
||||
border-radius: var(--neu-radius-md);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: var(--neu-font-xs);
|
||||
color: var(--neu-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--neu-font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: var(--neu-space-md);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.widget-types {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--neu-space-sm);
|
||||
margin-bottom: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.widget-type-btn {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
height: 70px;
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
|
||||
.widget-type-icon { font-size: 24px; }
|
||||
</style>
|
||||
25
frontend/src/views/Files.vue
Normal file
25
frontend/src/views/Files.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="files-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.files') }}</h2>
|
||||
<p class="text-muted">{{ t('files.desc') }}</p>
|
||||
</div>
|
||||
<div class="neu-card">
|
||||
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
|
||||
{{ t('files.moduleNotEnabled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.files-page { max-width: 1400px; }
|
||||
.page-header { margin-bottom: var(--neu-space-lg); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
</style>
|
||||
480
frontend/src/views/Install.vue
Normal file
480
frontend/src/views/Install.vue
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
<template>
|
||||
<!-- Page d'installation — wizard multi-étapes -->
|
||||
<div class="install-page">
|
||||
<div class="install-container">
|
||||
<!-- En-tête -->
|
||||
<div class="install-header">
|
||||
<div class="install-logo">PX</div>
|
||||
<h1>ProxmoxPanel</h1>
|
||||
<p class="install-subtitle">{{ t('install.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de progression -->
|
||||
<div class="install-steps">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
class="install-step"
|
||||
:class="{
|
||||
'install-step--active': currentStep === i,
|
||||
'install-step--done': currentStep > i,
|
||||
}"
|
||||
>
|
||||
<div class="install-step__dot">
|
||||
<svg v-if="currentStep > i" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<span v-else>{{ i + 1 }}</span>
|
||||
</div>
|
||||
<span class="install-step__label">{{ t(step.label) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu des étapes -->
|
||||
<div class="neu-card install-card">
|
||||
<!-- Étape 1 : Configuration générale -->
|
||||
<div v-if="currentStep === 0">
|
||||
<h2>{{ t('install.step1.title') }}</h2>
|
||||
<p class="step-desc">{{ t('install.step1.desc') }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.instanceName') }}</label>
|
||||
<input v-model="form.instanceName" class="neu-input" :placeholder="t('install.instanceNamePlaceholder')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.publicUrl') }}</label>
|
||||
<input v-model="form.publicUrl" class="neu-input" :placeholder="detectedURL" />
|
||||
<small>{{ t('install.publicUrlHint', { url: detectedURL }) }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.defaultLang') }}</label>
|
||||
<select v-model="form.defaultLang" class="neu-input">
|
||||
<option value="fr">Français</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Configuration SSH -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2>{{ t('install.step2.title') }}</h2>
|
||||
<p class="step-desc">{{ t('install.step2.desc') }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.sshHost') }}</label>
|
||||
<input v-model="form.sshHost" class="neu-input" placeholder="10.0.0.1:2244" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.sshUsername') }}</label>
|
||||
<input v-model="form.sshUsername" class="neu-input" placeholder="enzo" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.sshPassword') }}</label>
|
||||
<input v-model="form.sshPassword" type="password" class="neu-input" />
|
||||
</div>
|
||||
|
||||
<!-- Bouton test SSH -->
|
||||
<button
|
||||
class="neu-btn neu-btn--primary"
|
||||
:disabled="testingSSH || !form.sshHost || !form.sshUsername || !form.sshPassword"
|
||||
@click="testSSH"
|
||||
>
|
||||
<span v-if="testingSSH" class="neu-loading">⟳</span>
|
||||
{{ t('install.testSSH') }}
|
||||
</button>
|
||||
|
||||
<div v-if="sshTestResult" :class="['install-result', sshTestResult.success ? 'install-result--success' : 'install-result--error']">
|
||||
{{ sshTestResult.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 3 : Token Proxmox -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h2>{{ t('install.step3.title') }}</h2>
|
||||
<p class="step-desc">{{ t('install.step3.desc') }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.proxmoxUrl') }}</label>
|
||||
<input v-model="form.proxmoxUrl" class="neu-input" placeholder="https://10.0.0.1:8006" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('install.proxmoxToken') }}</label>
|
||||
<input v-model="form.proxmoxToken" class="neu-input" placeholder="PVEAPIToken=enzo@pam!panel=xxxx" />
|
||||
<small>{{ t('install.proxmoxTokenHint') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 4 : Confirmation -->
|
||||
<div v-if="currentStep === 3">
|
||||
<h2>{{ t('install.step4.title') }}</h2>
|
||||
<div class="install-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-key">{{ t('install.instanceName') }}</span>
|
||||
<span class="summary-value">{{ form.instanceName }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-key">{{ t('install.sshHost') }}</span>
|
||||
<span class="summary-value">{{ form.sshHost }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-key">{{ t('install.defaultLang') }}</span>
|
||||
<span class="summary-value">{{ form.defaultLang === 'fr' ? 'Français' : 'English' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erreur globale -->
|
||||
<div v-if="error" class="install-result install-result--error">{{ error }}</div>
|
||||
|
||||
<!-- Actions navigation -->
|
||||
<div class="install-actions">
|
||||
<button v-if="currentStep > 0" class="neu-btn" @click="currentStep--">
|
||||
{{ t('install.back') }}
|
||||
</button>
|
||||
<div class="spacer" />
|
||||
<button
|
||||
v-if="currentStep < steps.length - 1"
|
||||
class="neu-btn neu-btn--primary"
|
||||
:disabled="!canProceed"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ t('install.next') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="neu-btn neu-btn--success"
|
||||
:disabled="installing"
|
||||
@click="finalize"
|
||||
>
|
||||
<span v-if="installing" class="neu-loading">⟳</span>
|
||||
{{ t('install.finish') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentStep = ref(0)
|
||||
const detectedURL = ref('')
|
||||
const testingSSH = ref(false)
|
||||
const installing = ref(false)
|
||||
const error = ref('')
|
||||
const sshTestResult = ref<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
const steps = [
|
||||
{ label: 'install.step1.label' },
|
||||
{ label: 'install.step2.label' },
|
||||
{ label: 'install.step3.label' },
|
||||
{ label: 'install.step4.label' },
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
instanceName: 'ProxmoxPanel',
|
||||
publicUrl: '',
|
||||
defaultLang: 'fr',
|
||||
sshHost: '10.0.0.1:2244',
|
||||
sshUsername: 'enzo',
|
||||
sshPassword: '',
|
||||
proxmoxUrl: 'https://10.0.0.1:8006',
|
||||
proxmoxToken: '',
|
||||
})
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 0: return !!form.value.instanceName
|
||||
case 1: return sshTestResult.value?.success === true
|
||||
case 2: return true // Token Proxmox optionnel
|
||||
default: return true
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Récupérer les valeurs pré-remplies depuis l'API
|
||||
try {
|
||||
const res = await fetch('/api/install/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
detectedURL.value = data.detected_url || window.location.origin
|
||||
form.value.publicUrl = detectedURL.value
|
||||
}
|
||||
} catch {
|
||||
detectedURL.value = window.location.origin
|
||||
form.value.publicUrl = window.location.origin
|
||||
}
|
||||
})
|
||||
|
||||
async function testSSH() {
|
||||
testingSSH.value = true
|
||||
sshTestResult.value = null
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/install/test-ssh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
host: form.value.sshHost,
|
||||
username: form.value.sshUsername,
|
||||
password: form.value.sshPassword,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
sshTestResult.value = {
|
||||
success: data.success,
|
||||
message: data.success ? t('install.sshSuccess') : (data.error || t('install.sshFailed')),
|
||||
}
|
||||
} catch (e) {
|
||||
sshTestResult.value = { success: false, message: t('install.networkError') }
|
||||
} finally {
|
||||
testingSSH.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep.value < steps.length - 1) {
|
||||
currentStep.value++
|
||||
sshTestResult.value = null
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
installing.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/install/configure', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
instance_name: form.value.instanceName,
|
||||
public_url: form.value.publicUrl || detectedURL.value,
|
||||
default_lang: form.value.defaultLang,
|
||||
ssh_host: form.value.sshHost,
|
||||
ssh_username: form.value.sshUsername,
|
||||
ssh_password: form.value.sshPassword,
|
||||
proxmox_url: form.value.proxmoxUrl,
|
||||
proxmox_token: form.value.proxmoxToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.error || t('install.error')
|
||||
return
|
||||
}
|
||||
|
||||
// Marquer comme installé et rediriger vers le login
|
||||
authStore.isInstalled = true
|
||||
localStorage.setItem('pxp_instance_name', form.value.instanceName)
|
||||
router.push('/login')
|
||||
} catch (e) {
|
||||
error.value = t('install.networkError')
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.install-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--neu-bg);
|
||||
padding: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.install-container {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.install-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--neu-space-xl);
|
||||
}
|
||||
|
||||
.install-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--neu-primary);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--neu-radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--neu-space-md);
|
||||
box-shadow: 4px 4px 12px rgba(108, 142, 244, 0.4);
|
||||
}
|
||||
|
||||
.install-header h1 {
|
||||
font-size: var(--neu-font-2xl);
|
||||
color: var(--neu-text);
|
||||
margin-bottom: var(--neu-space-xs);
|
||||
}
|
||||
|
||||
.install-subtitle {
|
||||
color: var(--neu-text-muted);
|
||||
font-size: var(--neu-font-md);
|
||||
}
|
||||
|
||||
.install-steps {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--neu-space-lg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.install-steps::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
height: 2px;
|
||||
background: var(--neu-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.install-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.install-step__dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--neu-surface);
|
||||
border: 2px solid var(--neu-border);
|
||||
color: var(--neu-text-muted);
|
||||
transition: var(--neu-transition);
|
||||
}
|
||||
|
||||
.install-step--active .install-step__dot {
|
||||
border-color: var(--neu-primary);
|
||||
color: var(--neu-primary);
|
||||
}
|
||||
|
||||
.install-step--done .install-step__dot {
|
||||
background: var(--neu-success);
|
||||
border-color: var(--neu-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.install-step__label {
|
||||
font-size: var(--neu-font-xs);
|
||||
color: var(--neu-text-muted);
|
||||
}
|
||||
|
||||
.install-step--active .install-step__label {
|
||||
color: var(--neu-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.install-card {
|
||||
padding: var(--neu-space-xl);
|
||||
}
|
||||
|
||||
.install-card h2 {
|
||||
font-size: var(--neu-font-xl);
|
||||
color: var(--neu-text);
|
||||
margin-bottom: var(--neu-space-xs);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: var(--neu-text-muted);
|
||||
margin-bottom: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: var(--neu-font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--neu-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--neu-space-xs);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
font-size: var(--neu-font-xs);
|
||||
color: var(--neu-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.install-result {
|
||||
margin: var(--neu-space-md) 0;
|
||||
padding: var(--neu-space-sm) var(--neu-space-md);
|
||||
border-radius: var(--neu-radius-md);
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
|
||||
.install-result--success {
|
||||
background: rgba(76, 187, 138, 0.1);
|
||||
color: var(--neu-success);
|
||||
border: 1px solid rgba(76, 187, 138, 0.3);
|
||||
}
|
||||
|
||||
.install-result--error {
|
||||
background: rgba(240, 92, 107, 0.1);
|
||||
color: var(--neu-danger);
|
||||
border: 1px solid rgba(240, 92, 107, 0.3);
|
||||
}
|
||||
|
||||
.install-summary {
|
||||
background: var(--neu-bg);
|
||||
border-radius: var(--neu-radius-md);
|
||||
padding: var(--neu-space-md);
|
||||
margin-bottom: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--neu-space-xs) 0;
|
||||
border-bottom: 1px solid var(--neu-border);
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
|
||||
.summary-item:last-child { border-bottom: none; }
|
||||
|
||||
.summary-key { color: var(--neu-text-muted); }
|
||||
.summary-value { color: var(--neu-text); font-weight: 500; }
|
||||
|
||||
.install-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: var(--neu-space-xl);
|
||||
gap: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.spacer { flex: 1; }
|
||||
</style>
|
||||
185
frontend/src/views/Login.vue
Normal file
185
frontend/src/views/Login.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<!-- Logo + titre -->
|
||||
<div class="login-header">
|
||||
<div class="login-logo">PX</div>
|
||||
<h1>{{ instanceName || 'ProxmoxPanel' }}</h1>
|
||||
<p>{{ t('login.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<form class="neu-card login-card" @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>{{ t('login.username') }}</label>
|
||||
<input
|
||||
v-model="username"
|
||||
class="neu-input"
|
||||
:placeholder="t('login.usernamePlaceholder')"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('login.password') }}</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="neu-input"
|
||||
:placeholder="t('login.passwordPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="login-error">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="neu-btn neu-btn--primary neu-btn--lg w-full" :disabled="loading || !username || !password">
|
||||
<span v-if="loading" class="neu-loading">⟳</span>
|
||||
{{ loading ? t('login.loading') : t('login.submit') }}
|
||||
</button>
|
||||
|
||||
<p class="login-hint">{{ t('login.hint') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const instanceName = computed(() => localStorage.getItem('pxp_instance_name') || '')
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await authStore.login(username.value, password.value)
|
||||
|
||||
// Appliquer les préférences de l'utilisateur
|
||||
const user = authStore.user
|
||||
if (user) {
|
||||
uiStore.setTheme(user.theme as 'dark' | 'light')
|
||||
uiStore.setSidebarPosition(user.sidebar_position as 'left' | 'right')
|
||||
}
|
||||
|
||||
// Rediriger vers la page demandée ou le dashboard
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('login.error')
|
||||
password.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--neu-bg);
|
||||
padding: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--neu-space-xl);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: var(--neu-primary);
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--neu-radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--neu-space-md);
|
||||
box-shadow:
|
||||
6px 6px 14px rgba(108, 142, 244, 0.4),
|
||||
-3px -3px 8px rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: var(--neu-font-2xl);
|
||||
color: var(--neu-text);
|
||||
margin-bottom: var(--neu-space-xs);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--neu-text-muted);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: var(--neu-space-xl);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: var(--neu-font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--neu-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-xs);
|
||||
padding: var(--neu-space-sm) var(--neu-space-md);
|
||||
background: rgba(240, 92, 107, 0.1);
|
||||
color: var(--neu-danger);
|
||||
border: 1px solid rgba(240, 92, 107, 0.3);
|
||||
border-radius: var(--neu-radius-md);
|
||||
font-size: var(--neu-font-sm);
|
||||
margin-bottom: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
font-size: var(--neu-font-xs);
|
||||
color: var(--neu-text-muted);
|
||||
margin-top: var(--neu-space-md);
|
||||
}
|
||||
</style>
|
||||
24
frontend/src/views/Logs.vue
Normal file
24
frontend/src/views/Logs.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<div class="logs-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.logs') }}</h2>
|
||||
</div>
|
||||
<div class="neu-card">
|
||||
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
|
||||
{{ t('files.moduleNotEnabled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-page { max-width: 1200px; }
|
||||
.page-header { margin-bottom: var(--neu-space-lg); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
</style>
|
||||
107
frontend/src/views/Modules.vue
Normal file
107
frontend/src/views/Modules.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div class="modules-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.modules') }}</h2>
|
||||
<p class="text-muted">{{ t('modules.desc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="modules-grid">
|
||||
<div v-for="mod in modules" :key="mod.id" class="neu-card module-card">
|
||||
<div class="module-header">
|
||||
<div class="module-title-row flex items-center gap-sm">
|
||||
<span class="module-name">{{ mod.name }}</span>
|
||||
<span v-if="mod.is_core" class="neu-badge neu-badge--info">CORE</span>
|
||||
<span :class="['neu-badge', mod.is_enabled ? 'neu-badge--success' : 'neu-badge--danger']">
|
||||
{{ mod.is_enabled ? t('modules.enabled') : t('modules.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="module-version text-muted">v{{ mod.version }}</div>
|
||||
</div>
|
||||
|
||||
<p class="module-description text-muted">{{ mod.description }}</p>
|
||||
|
||||
<div class="module-actions flex gap-sm">
|
||||
<button
|
||||
v-if="!mod.is_core"
|
||||
:class="['neu-btn neu-btn--sm', mod.is_enabled ? 'neu-btn--danger' : 'neu-btn--success']"
|
||||
:disabled="actionLoading === mod.id"
|
||||
@click="toggleModule(mod)"
|
||||
>
|
||||
{{ mod.is_enabled ? t('modules.disable') : t('modules.enable') }}
|
||||
</button>
|
||||
<span v-else class="text-muted" style="font-size:11px">{{ t('modules.coreProtected') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="restartNeeded" class="neu-card restart-notice">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{{ t('modules.restartNotice') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const modules = ref<any[]>([])
|
||||
const actionLoading = ref<string | null>(null)
|
||||
const restartNeeded = ref(false)
|
||||
|
||||
onMounted(loadModules)
|
||||
|
||||
async function loadModules() {
|
||||
const res = await fetch('/api/modules', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) modules.value = await res.json() || []
|
||||
}
|
||||
|
||||
async function toggleModule(mod: any) {
|
||||
actionLoading.value = mod.id
|
||||
const action = mod.is_enabled ? 'disable' : 'enable'
|
||||
|
||||
const res = await fetch(`/api/modules/${mod.id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
mod.is_enabled = !mod.is_enabled
|
||||
restartNeeded.value = true
|
||||
}
|
||||
|
||||
actionLoading.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modules-page { max-width: 1000px; }
|
||||
.page-header { margin-bottom: var(--neu-space-lg); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
|
||||
.modules-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--neu-space-md); margin-bottom: var(--neu-space-lg); }
|
||||
|
||||
.module-header { margin-bottom: var(--neu-space-sm); }
|
||||
.module-name { font-weight: 600; color: var(--neu-text); }
|
||||
.module-version { font-size: var(--neu-font-xs); margin-top: 2px; }
|
||||
.module-description { font-size: var(--neu-font-sm); margin-bottom: var(--neu-space-md); }
|
||||
.module-actions { border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-sm); }
|
||||
|
||||
.restart-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
color: var(--neu-warning);
|
||||
border-color: var(--neu-warning);
|
||||
font-size: var(--neu-font-sm);
|
||||
}
|
||||
</style>
|
||||
290
frontend/src/views/Proxmox.vue
Normal file
290
frontend/src/views/Proxmox.vue
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<template>
|
||||
<div class="proxmox-page">
|
||||
<div class="page-header flex items-center justify-between">
|
||||
<h2>{{ t('nav.proxmox') }}</h2>
|
||||
<button class="neu-btn" @click="loadResources">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" :class="{ 'neu-loading': loading }">
|
||||
<path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filtre par type -->
|
||||
<div class="filter-bar flex gap-sm">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.value"
|
||||
:class="['neu-btn neu-btn--sm', activeFilter === f.value ? 'neu-btn--primary' : '']"
|
||||
@click="activeFilter = f.value"
|
||||
>
|
||||
{{ t(f.label) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loader -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<span class="neu-loading" style="font-size:32px">⟳</span>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="error" class="neu-card error-card">
|
||||
<p class="error-msg">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Grille des ressources -->
|
||||
<div v-else class="resources-grid">
|
||||
<div
|
||||
v-for="resource in filteredResources"
|
||||
:key="`${resource.type}-${resource.vmid}`"
|
||||
class="neu-card resource-card neu-card--hover"
|
||||
>
|
||||
<!-- En-tête -->
|
||||
<div class="resource-header flex items-center gap-sm">
|
||||
<span :class="['status-dot', resource.status === 'running' ? 'status-dot--running' : 'status-dot--stopped']" />
|
||||
<div class="resource-title">
|
||||
<div class="resource-name">{{ resource.name || `${resource.type.toUpperCase()} ${resource.vmid}` }}</div>
|
||||
<div class="resource-meta">
|
||||
<span class="neu-badge neu-badge--info">{{ resource.type.toUpperCase() }}</span>
|
||||
<span class="resource-id">#{{ resource.vmid }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span :class="['neu-badge', resource.status === 'running' ? 'neu-badge--success' : 'neu-badge--danger']">
|
||||
{{ resource.status === 'running' ? t('proxmox.running') : t('proxmox.stopped') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Métriques -->
|
||||
<div v-if="resource.status === 'running'" class="resource-metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-bar">
|
||||
<div class="metric-bar-fill" :style="{ width: `${Math.round(resource.cpu * 100)}%` }" />
|
||||
</div>
|
||||
<div class="metric-label">CPU {{ Math.round(resource.cpu * 100) }}%</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-bar">
|
||||
<div class="metric-bar-fill metric-bar-fill--mem" :style="{ width: `${Math.round((resource.mem / resource.maxmem) * 100)}%` }" />
|
||||
</div>
|
||||
<div class="metric-label">RAM {{ formatBytes(resource.mem) }} / {{ formatBytes(resource.maxmem) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions (admin uniquement) -->
|
||||
<div v-if="authStore.user?.is_admin" class="resource-actions flex gap-sm">
|
||||
<button
|
||||
v-if="resource.status === 'stopped'"
|
||||
class="neu-btn neu-btn--sm neu-btn--success"
|
||||
:disabled="actionLoading === resource.vmid"
|
||||
@click="startResource(resource)"
|
||||
>
|
||||
▶ {{ t('proxmox.start') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="neu-btn neu-btn--sm neu-btn--danger"
|
||||
:disabled="actionLoading === resource.vmid"
|
||||
@click="stopResource(resource)"
|
||||
>
|
||||
■ {{ t('proxmox.stop') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connexion WebSocket indicator -->
|
||||
<div class="ws-indicator">
|
||||
<span :class="['ws-dot', wsConnected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
|
||||
<span class="text-muted" style="font-size:11px">
|
||||
{{ wsConnected ? t('proxmox.liveUpdates') : t('proxmox.disconnected') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface Resource {
|
||||
vmid: number
|
||||
name: string
|
||||
node: string
|
||||
type: string
|
||||
status: string
|
||||
cpu: number
|
||||
maxcpu: number
|
||||
mem: number
|
||||
maxmem: number
|
||||
}
|
||||
|
||||
const resources = ref<Resource[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const actionLoading = ref<number | null>(null)
|
||||
const wsConnected = ref(false)
|
||||
let wsConnection: WebSocket | null = null
|
||||
|
||||
const filters = [
|
||||
{ value: 'all', label: 'proxmox.all' },
|
||||
{ value: 'lxc', label: 'proxmox.lxc' },
|
||||
{ value: 'qemu', label: 'proxmox.vm' },
|
||||
]
|
||||
|
||||
const filteredResources = computed(() => {
|
||||
if (activeFilter.value === 'all') return resources.value.filter(r => r.type === 'lxc' || r.type === 'qemu')
|
||||
return resources.value.filter(r => r.type === activeFilter.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadResources()
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
wsConnection?.close()
|
||||
})
|
||||
|
||||
async function loadResources() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/proxmox/resources', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
resources.value = await res.json() || []
|
||||
} else {
|
||||
const data = await res.json()
|
||||
error.value = data.error || t('proxmox.error')
|
||||
}
|
||||
} catch {
|
||||
error.value = t('common.networkError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startResource(resource: Resource) {
|
||||
actionLoading.value = resource.vmid
|
||||
const path = resource.type === 'lxc' ? 'lxc' : 'vm'
|
||||
await fetch(`/api/proxmox/${path}/${resource.vmid}/start?node=${resource.node}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
actionLoading.value = null
|
||||
}
|
||||
|
||||
async function stopResource(resource: Resource) {
|
||||
actionLoading.value = resource.vmid
|
||||
const path = resource.type === 'lxc' ? 'lxc' : 'vm'
|
||||
await fetch(`/api/proxmox/${path}/${resource.vmid}/stop?node=${resource.node}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
actionLoading.value = null
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/proxmox?token=${authStore.accessToken}`)
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
wsConnected.value = true
|
||||
wsConnection?.send(JSON.stringify({ type: 'subscribe', channel: 'proxmox' }))
|
||||
}
|
||||
wsConnection.onclose = () => {
|
||||
wsConnected.value = false
|
||||
setTimeout(() => connectWebSocket(), 5000)
|
||||
}
|
||||
wsConnection.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'resources_update' && msg.payload) {
|
||||
resources.value = msg.payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)} MB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxmox-page { max-width: 1400px; }
|
||||
|
||||
.page-header { margin-bottom: var(--neu-space-lg); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
|
||||
.filter-bar { margin-bottom: var(--neu-space-lg); flex-wrap: wrap; }
|
||||
|
||||
.loading-state { display: flex; justify-content: center; padding: var(--neu-space-xl); color: var(--neu-primary); }
|
||||
|
||||
.error-card { border-color: var(--neu-danger); }
|
||||
.error-msg { color: var(--neu-danger); }
|
||||
|
||||
.resources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.resource-card { cursor: default; }
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot--running { background: var(--neu-success); box-shadow: 0 0 6px var(--neu-success); }
|
||||
.status-dot--stopped { background: var(--neu-text-muted); }
|
||||
|
||||
.resource-header { margin-bottom: var(--neu-space-sm); }
|
||||
.resource-name { font-weight: 600; color: var(--neu-text); }
|
||||
.resource-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
||||
.resource-id { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
|
||||
|
||||
.resource-metrics { margin: var(--neu-space-sm) 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.metric-bar {
|
||||
height: 6px;
|
||||
background: var(--neu-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 1px 1px 3px var(--neu-shadow-dark);
|
||||
}
|
||||
|
||||
.metric-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--neu-primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.metric-bar-fill--mem { background: var(--neu-info); }
|
||||
|
||||
.metric-label { font-size: 10px; color: var(--neu-text-muted); margin-top: 2px; }
|
||||
|
||||
.resource-actions { margin-top: var(--neu-space-sm); border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-sm); }
|
||||
|
||||
.ws-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: var(--neu-space-lg);
|
||||
}
|
||||
|
||||
.ws-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
|
||||
.ws-dot--disconnected { background: var(--neu-text-muted); }
|
||||
</style>
|
||||
24
frontend/src/views/Services.vue
Normal file
24
frontend/src/views/Services.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<div class="services-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.services') }}</h2>
|
||||
</div>
|
||||
<div class="neu-card">
|
||||
<p class="text-muted" style="text-align:center; padding: var(--neu-space-xl)">
|
||||
{{ t('files.moduleNotEnabled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.services-page { max-width: 1000px; }
|
||||
.page-header { margin-bottom: var(--neu-space-lg); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
</style>
|
||||
259
frontend/src/views/Settings.vue
Normal file
259
frontend/src/views/Settings.vue
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.settings') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
<!-- Onglets -->
|
||||
<div class="settings-tabs neu-card">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['settings-tab', activeTab === tab.id ? 'settings-tab--active' : '']"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<span v-html="tab.icon" class="tab-icon" />
|
||||
{{ t(tab.label) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="settings-content neu-card">
|
||||
|
||||
<!-- Général -->
|
||||
<div v-if="activeTab === 'general'">
|
||||
<h3>{{ t('settings.general') }}</h3>
|
||||
<div class="settings-form">
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.instanceName') }}</label>
|
||||
<input v-model="settings.instance_name" class="neu-input" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.publicUrl') }}</label>
|
||||
<input v-model="settings.public_url" class="neu-input" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.defaultLang') }}</label>
|
||||
<select v-model="settings.default_lang" class="neu-input">
|
||||
<option value="fr">Français</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH / Proxmox -->
|
||||
<div v-if="activeTab === 'infrastructure'">
|
||||
<h3>{{ t('settings.infrastructure') }}</h3>
|
||||
<div class="settings-form">
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.sshHost') }}</label>
|
||||
<input v-model="settings.ssh_host" class="neu-input" placeholder="10.0.0.1:2244" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.sshUsername') }}</label>
|
||||
<input v-model="settings.ssh_username" class="neu-input" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.proxmoxUrl') }}</label>
|
||||
<input v-model="settings.proxmox_url" class="neu-input" placeholder="https://10.0.0.1:8006" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apparence -->
|
||||
<div v-if="activeTab === 'appearance'">
|
||||
<h3>{{ t('settings.appearance') }}</h3>
|
||||
<div class="settings-form">
|
||||
<div class="form-row form-row--toggle">
|
||||
<label>{{ t('settings.darkMode') }}</label>
|
||||
<label class="neu-toggle">
|
||||
<input type="checkbox" :checked="uiStore.theme === 'dark'" @change="uiStore.toggleTheme()" />
|
||||
<span class="neu-toggle__slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ t('settings.sidebarPosition') }}</label>
|
||||
<div class="flex gap-sm">
|
||||
<button :class="['neu-btn neu-btn--sm', uiStore.sidebarPosition === 'left' ? 'neu-btn--primary' : '']" @click="setSidebar('left')">
|
||||
{{ t('settings.left') }}
|
||||
</button>
|
||||
<button :class="['neu-btn neu-btn--sm', uiStore.sidebarPosition === 'right' ? 'neu-btn--primary' : '']" @click="setSidebar('right')">
|
||||
{{ t('settings.right') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit log -->
|
||||
<div v-if="activeTab === 'audit'">
|
||||
<h3>{{ t('settings.audit') }}</h3>
|
||||
<div class="audit-list">
|
||||
<div v-for="entry in auditLog" :key="entry.id" class="audit-entry">
|
||||
<span class="audit-action neu-badge neu-badge--info">{{ entry.action }}</span>
|
||||
<span class="audit-user">{{ entry.username }}</span>
|
||||
<span v-if="entry.resource" class="audit-resource text-muted">{{ entry.resource }}</span>
|
||||
<span class="audit-date text-muted">{{ formatDate(entry.created_at) }}</span>
|
||||
</div>
|
||||
<p v-if="auditLog.length === 0" class="text-muted">{{ t('settings.noAuditLog') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton sauvegarder (sauf audit) -->
|
||||
<div v-if="activeTab !== 'audit'" class="settings-actions">
|
||||
<button class="neu-btn neu-btn--primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<span v-if="saveSuccess" class="neu-badge neu-badge--success">{{ t('common.saved') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const activeTab = ref('general')
|
||||
const saving = ref(false)
|
||||
const saveSuccess = ref(false)
|
||||
const auditLog = ref<any[]>([])
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'settings.general', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>` },
|
||||
{ id: 'infrastructure', label: 'settings.infrastructure', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/></svg>` },
|
||||
{ id: 'appearance', label: 'settings.appearance', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>` },
|
||||
{ id: 'audit', label: 'settings.audit', icon: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12"/></svg>` },
|
||||
]
|
||||
|
||||
const settings = ref({
|
||||
instance_name: '',
|
||||
public_url: '',
|
||||
default_lang: 'fr',
|
||||
ssh_host: '',
|
||||
ssh_username: '',
|
||||
proxmox_url: '',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings()
|
||||
await loadAuditLog()
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
const res = await fetch('/api/settings', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
Object.assign(settings.value, data)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditLog() {
|
||||
const res = await fetch('/api/settings/audit', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) auditLog.value = await res.json() || []
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
saveSuccess.value = false
|
||||
|
||||
const keys = Object.entries(settings.value)
|
||||
for (const [key, value] of keys) {
|
||||
await fetch(`/api/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
}
|
||||
|
||||
saving.value = false
|
||||
saveSuccess.value = true
|
||||
setTimeout(() => (saveSuccess.value = false), 3000)
|
||||
}
|
||||
|
||||
function setSidebar(pos: 'left' | 'right') {
|
||||
uiStore.setSidebarPosition(pos)
|
||||
authStore.updatePreferences({ sidebar_position: pos })
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1000px; }
|
||||
.page-header { margin-bottom: var(--neu-space-lg); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
|
||||
.settings-layout { display: grid; grid-template-columns: 200px 1fr; gap: var(--neu-space-md); }
|
||||
|
||||
.settings-tabs { display: flex; flex-direction: column; gap: 2px; padding: var(--neu-space-sm); align-self: start; }
|
||||
|
||||
.settings-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-sm);
|
||||
padding: 10px var(--neu-space-sm);
|
||||
border-radius: var(--neu-radius-md);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--neu-text-muted);
|
||||
font-size: var(--neu-font-sm);
|
||||
text-align: left;
|
||||
transition: var(--neu-transition);
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-text);
|
||||
}
|
||||
|
||||
.settings-tab--active {
|
||||
background: var(--neu-bg);
|
||||
color: var(--neu-primary);
|
||||
box-shadow: inset 2px 2px 5px var(--neu-shadow-dark);
|
||||
}
|
||||
|
||||
.settings-content h3 { margin-bottom: var(--neu-space-lg); color: var(--neu-text); }
|
||||
|
||||
.settings-form { display: flex; flex-direction: column; gap: var(--neu-space-md); }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 200px 1fr; align-items: center; gap: var(--neu-space-md); }
|
||||
.form-row label { font-size: var(--neu-font-sm); color: var(--neu-text-muted); }
|
||||
.form-row--toggle { grid-template-columns: 200px auto; }
|
||||
|
||||
.settings-actions { margin-top: var(--neu-space-xl); display: flex; align-items: center; gap: var(--neu-space-sm); border-top: 1px solid var(--neu-border); padding-top: var(--neu-space-lg); }
|
||||
|
||||
.audit-list { display: flex; flex-direction: column; gap: var(--neu-space-xs); }
|
||||
.audit-entry { display: flex; align-items: center; gap: var(--neu-space-sm); padding: 6px 0; border-bottom: 1px solid var(--neu-border); font-size: var(--neu-font-sm); flex-wrap: wrap; }
|
||||
.audit-entry:last-child { border-bottom: none; }
|
||||
.audit-user { font-weight: 600; color: var(--neu-text); }
|
||||
.audit-resource { font-family: monospace; }
|
||||
.audit-date { margin-left: auto; }
|
||||
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-tabs { flex-direction: row; flex-wrap: wrap; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
162
frontend/src/views/Terminal.vue
Normal file
162
frontend/src/views/Terminal.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<div class="terminal-page">
|
||||
<div class="page-header flex items-center justify-between">
|
||||
<h2>{{ t('nav.terminal') }}</h2>
|
||||
<div class="flex gap-sm">
|
||||
<input v-model="customHost" class="neu-input" placeholder="host:port (défaut: config)" style="width:200px" />
|
||||
<button class="neu-btn neu-btn--primary" @click="reconnect">
|
||||
{{ connected ? t('terminal.reconnect') : t('terminal.connect') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="neu-card terminal-container">
|
||||
<div class="terminal-status flex items-center gap-sm">
|
||||
<span :class="['ws-dot', connected ? 'ws-dot--connected' : 'ws-dot--disconnected']" />
|
||||
<span class="text-muted" style="font-size:11px">
|
||||
{{ connected ? t('terminal.connected', { host: currentHost }) : t('terminal.disconnected') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Conteneur xterm.js -->
|
||||
<div ref="terminalContainer" class="terminal-xterm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const terminalContainer = ref<HTMLElement | null>(null)
|
||||
const customHost = ref('')
|
||||
const connected = ref(false)
|
||||
const currentHost = ref('')
|
||||
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Initialiser xterm.js
|
||||
terminal = new Terminal({
|
||||
theme: {
|
||||
background: 'var(--neu-bg, #1a1d2e)',
|
||||
foreground: '#e2e6f6',
|
||||
cursor: '#6c8ef4',
|
||||
},
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
scrollback: 1000,
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(terminalContainer.value!)
|
||||
fitAddon.fit()
|
||||
|
||||
// Observer le redimensionnement
|
||||
const ro = new ResizeObserver(() => fitAddon?.fit())
|
||||
if (terminalContainer.value) ro.observe(terminalContainer.value)
|
||||
|
||||
// Connexion automatique
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ws?.close()
|
||||
terminal?.dispose()
|
||||
})
|
||||
|
||||
function connect() {
|
||||
ws?.close()
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const hostParam = customHost.value ? `&host=${encodeURIComponent(customHost.value)}` : ''
|
||||
const url = `${proto}//${window.location.host}/ws/terminal?token=${authStore.accessToken}${hostParam}`
|
||||
|
||||
ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
currentHost.value = customHost.value || 'ssh_host configuré'
|
||||
terminal?.write('\r\n\x1b[32mConnecté au terminal SSH\x1b[0m\r\n\r\n')
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
terminal?.write('\r\n\x1b[31mDéconnecté\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminal?.write(new Uint8Array(event.data))
|
||||
} else {
|
||||
terminal?.write(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal?.write('\r\n\x1b[31mErreur de connexion WebSocket\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
// Envoyer les frappes clavier au serveur SSH
|
||||
terminal?.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
// Envoyer le resize au serveur
|
||||
terminal?.onResize(({ cols, rows }) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
terminal?.clear()
|
||||
connect()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-page { height: 100%; display: flex; flex-direction: column; max-width: 1200px; }
|
||||
|
||||
.page-header { margin-bottom: var(--neu-space-lg); flex-shrink: 0; }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: var(--neu-space-sm);
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
padding: var(--neu-space-xs) var(--neu-space-sm);
|
||||
border-bottom: 1px solid var(--neu-border);
|
||||
margin-bottom: var(--neu-space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.ws-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.ws-dot--connected { background: var(--neu-success); animation: neu-pulse 2s infinite; }
|
||||
.ws-dot--disconnected { background: var(--neu-text-muted); }
|
||||
</style>
|
||||
187
frontend/src/views/Updates.vue
Normal file
187
frontend/src/views/Updates.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<div class="updates-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.updates') }}</h2>
|
||||
<p class="text-muted">{{ t('updates.desc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélection de la cible -->
|
||||
<div class="neu-card target-card">
|
||||
<h3>{{ t('updates.selectTarget') }}</h3>
|
||||
<div class="targets">
|
||||
<button
|
||||
v-for="target in targets"
|
||||
:key="target.value"
|
||||
:class="['neu-btn', selectedTarget === target.value ? 'neu-btn--primary' : '']"
|
||||
@click="selectedTarget = target.value"
|
||||
>
|
||||
{{ t(target.label) }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="neu-btn neu-btn--success"
|
||||
:disabled="!selectedTarget || running"
|
||||
@click="startUpdate"
|
||||
style="margin-top: var(--neu-space-md);"
|
||||
>
|
||||
{{ running ? t('updates.running') : t('updates.start') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Terminal de sortie -->
|
||||
<div v-if="currentJob" class="neu-card output-card">
|
||||
<div class="output-header flex items-center justify-between">
|
||||
<h3>{{ t('updates.output') }}</h3>
|
||||
<span :class="['neu-badge', jobStatus === 'success' ? 'neu-badge--success' : jobStatus === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${jobStatus}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="output-terminal neu-inset" ref="terminalEl">
|
||||
<pre class="output-text">{{ outputText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historique -->
|
||||
<div class="neu-card">
|
||||
<h3>{{ t('updates.history') }}</h3>
|
||||
<div v-if="history.length === 0" class="empty-state">
|
||||
<p class="text-muted">{{ t('updates.noHistory') }}</p>
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-for="entry in history" :key="entry.job_id" class="history-item">
|
||||
<div class="history-meta flex items-center gap-sm">
|
||||
<span :class="['neu-badge', entry.status === 'success' ? 'neu-badge--success' : entry.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${entry.status}`) }}
|
||||
</span>
|
||||
<span class="history-target">{{ entry.target }}</span>
|
||||
<span class="text-muted history-date">{{ formatDate(entry.started_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const selectedTarget = ref('host')
|
||||
const running = ref(false)
|
||||
const currentJob = ref<string | null>(null)
|
||||
const outputText = ref('')
|
||||
const jobStatus = ref('running')
|
||||
const history = ref<any[]>([])
|
||||
const terminalEl = ref<HTMLElement | null>(null)
|
||||
|
||||
let wsConnection: WebSocket | null = null
|
||||
|
||||
const targets = [
|
||||
{ value: 'host', label: 'updates.targetHost' },
|
||||
{ value: 'all', label: 'updates.targetAll' },
|
||||
]
|
||||
|
||||
onMounted(loadHistory)
|
||||
onUnmounted(() => wsConnection?.close())
|
||||
|
||||
async function loadHistory() {
|
||||
const res = await fetch('/api/updates/history', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) history.value = await res.json() || []
|
||||
}
|
||||
|
||||
async function startUpdate() {
|
||||
running.value = true
|
||||
outputText.value = ''
|
||||
jobStatus.value = 'running'
|
||||
|
||||
const res = await fetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ target: selectedTarget.value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
running.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
currentJob.value = data.job_id
|
||||
|
||||
// Connecter le WebSocket de streaming
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${data.job_id}?token=${authStore.accessToken}`)
|
||||
|
||||
wsConnection.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === 'update_output' && msg.payload?.chunk) {
|
||||
outputText.value += msg.payload.chunk
|
||||
await nextTick()
|
||||
if (terminalEl.value) {
|
||||
terminalEl.value.scrollTop = terminalEl.value.scrollHeight
|
||||
}
|
||||
} else if (msg.type === 'update_done') {
|
||||
jobStatus.value = 'success'
|
||||
running.value = false
|
||||
wsConnection?.close()
|
||||
loadHistory()
|
||||
} else if (msg.type === 'update_error') {
|
||||
jobStatus.value = 'error'
|
||||
running.value = false
|
||||
wsConnection?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.updates-page { max-width: 900px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
|
||||
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
|
||||
.target-card h3, .output-card h3 { margin-bottom: var(--neu-space-md); color: var(--neu-text); }
|
||||
|
||||
.targets { display: flex; flex-wrap: wrap; gap: var(--neu-space-sm); }
|
||||
|
||||
.output-header { margin-bottom: var(--neu-space-sm); }
|
||||
|
||||
.output-terminal {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: var(--neu-space-md);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
color: var(--neu-success);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state { padding: var(--neu-space-md) 0; }
|
||||
|
||||
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
|
||||
|
||||
.history-item { padding: var(--neu-space-sm) 0; border-bottom: 1px solid var(--neu-border); }
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
|
||||
.history-target { font-size: var(--neu-font-sm); color: var(--neu-text); font-family: monospace; }
|
||||
.history-date { font-size: var(--neu-font-xs); }
|
||||
</style>
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
48
frontend/vite.config.ts
Normal file
48
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// Proxy vers le backend Go en développement
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3001',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
// Optimiser les chunks pour de meilleures performances
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-core': ['vue', 'vue-router', 'pinia'],
|
||||
'i18n': ['vue-i18n'],
|
||||
'terminal': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-attach'],
|
||||
'editor': [
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue