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:
enzo 2026-03-20 21:08:53 +01:00
commit 5dbcb1df07
66 changed files with 10370 additions and 0 deletions

13
.gitattributes vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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=

View 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[:])
}

View 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)
}

View 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
}

View 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})
}

View 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
}

View 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)
}

View 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))
}

View 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))
}

View 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()
}

View 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
}

View 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
}

View 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
View 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(&currentVersion); 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
}

View 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);

View 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)
}

View 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()
}
}
}

View 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
View 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
}

View 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)

View 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
View 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
}

View 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
View 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

View 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)

View 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)

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

37
frontend/package.json Normal file
View 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
View 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>

View 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>

View 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>

View 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>

View 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"
}
}

View 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
View 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')

View 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

View 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,
}
})

View 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,
}
})

View 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);
}

View 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
View 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%;
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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" }]
}

View 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
View 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',
],
},
},
},
},
})