Compare commits
4 Commits
5b0f33320e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
442a1e2f9f
|
|||
|
163f5641e5
|
|||
|
3a6cd42361
|
|||
|
033082954a
|
@@ -63,3 +63,12 @@ BANDWIDTH_BPS=209715
|
||||
# Default: 100
|
||||
# Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments)
|
||||
RATE_LIMIT_RPS=100
|
||||
|
||||
# ── Web Admin Panel ───────────────────────────────────────
|
||||
|
||||
# Whether the Go server should serve the built SPA frontend from ./web/dist.
|
||||
# Set to false when using Caddy, Nginx, or another reverse proxy to serve
|
||||
# the frontend and proxy API requests to this server.
|
||||
# Default: true
|
||||
# Example: SERVE_SPA=false (frontend served by external web server)
|
||||
SERVE_SPA=true
|
||||
|
||||
+172
-154
@@ -1,187 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/artefact"
|
||||
"onixbyte.com/message-converter-manifest/internal/auth"
|
||||
"onixbyte.com/message-converter-manifest/internal/config"
|
||||
"onixbyte.com/message-converter-manifest/internal/database"
|
||||
"onixbyte.com/message-converter-manifest/internal/delta"
|
||||
"onixbyte.com/message-converter-manifest/internal/deployment"
|
||||
"onixbyte.com/message-converter-manifest/internal/device"
|
||||
"onixbyte.com/message-converter-manifest/internal/download"
|
||||
"onixbyte.com/message-converter-manifest/internal/middleware"
|
||||
"onixbyte.com/pipely/internal/artefact"
|
||||
"onixbyte.com/pipely/internal/auth"
|
||||
"onixbyte.com/pipely/internal/config"
|
||||
"onixbyte.com/pipely/internal/database"
|
||||
"onixbyte.com/pipely/internal/delta"
|
||||
"onixbyte.com/pipely/internal/deployment"
|
||||
"onixbyte.com/pipely/internal/device"
|
||||
"onixbyte.com/pipely/internal/download"
|
||||
"onixbyte.com/pipely/internal/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
cfg := config.Load()
|
||||
|
||||
// Connect to PostgreSQL (auto-creates database if needed)
|
||||
db, err := database.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Database connection failed: %v", err)
|
||||
}
|
||||
// Connect to PostgreSQL (auto-creates database if needed)
|
||||
db, err := database.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Database connection failed: %v", err)
|
||||
}
|
||||
|
||||
// Auto-migrate tables
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
log.Fatalf("Auto-migrate failed: %v", err)
|
||||
}
|
||||
// Auto-migrate tables
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
log.Fatalf("Auto-migrate failed: %v", err)
|
||||
}
|
||||
|
||||
// Initialise auth service and create default admin
|
||||
authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs)
|
||||
if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil {
|
||||
log.Printf("Warning: failed to create initial admin: %v", err)
|
||||
}
|
||||
log.Println("[main] Default admin user ready (admin / admin)")
|
||||
// Initialise auth service and create default admin
|
||||
authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs)
|
||||
if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil {
|
||||
log.Printf("Warning: failed to create initial admin: %v", err)
|
||||
}
|
||||
log.Println("[main] Default admin user ready (admin / admin)")
|
||||
|
||||
// Ensure artefact storage directory exists
|
||||
if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create artefact directory: %v", err)
|
||||
}
|
||||
// Ensure artefact storage directory exists
|
||||
if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create artefact directory: %v", err)
|
||||
}
|
||||
|
||||
// Global rate limiter
|
||||
tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2)
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
for range ticker.C {
|
||||
tokenBucket.Cleanup(30 * time.Minute)
|
||||
}
|
||||
}()
|
||||
// Global rate limiter
|
||||
tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2)
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
for range ticker.C {
|
||||
tokenBucket.Cleanup(30 * time.Minute)
|
||||
}
|
||||
}()
|
||||
|
||||
// Gated rollout semaphore
|
||||
downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent)
|
||||
// Gated rollout semaphore
|
||||
downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent)
|
||||
|
||||
// Offline device detection
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
offline, err := database.GetOfflineDevices(db, 10*time.Minute)
|
||||
if err != nil {
|
||||
log.Printf("[health] Offline check failed: %v", err)
|
||||
continue
|
||||
}
|
||||
ids := make([]string, len(offline))
|
||||
for i, d := range offline {
|
||||
ids[i] = d.DeviceID
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := database.MarkDevicesOffline(db, ids); err != nil {
|
||||
log.Printf("[health] Mark offline failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[health] Marked %d devices as offline", len(ids))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Offline device detection
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
offline, err := database.GetOfflineDevices(db, 10*time.Minute)
|
||||
if err != nil {
|
||||
log.Printf("[health] Offline check failed: %v", err)
|
||||
continue
|
||||
}
|
||||
ids := make([]string, len(offline))
|
||||
for i, d := range offline {
|
||||
ids[i] = d.DeviceID
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := database.MarkDevicesOffline(db, ids); err != nil {
|
||||
log.Printf("[health] Mark offline failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[health] Marked %d devices as offline", len(ids))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialise handlers
|
||||
authHandler := auth.NewHandler(authSvc)
|
||||
deviceHandler := device.NewHandler(db)
|
||||
artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir)
|
||||
deploymentHandler := deployment.NewHandler(db)
|
||||
deltaHandler := delta.NewHandler(cfg.ArtefactDir)
|
||||
downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS)
|
||||
// Initialise handlers
|
||||
authHandler := auth.NewHandler(authSvc)
|
||||
deviceHandler := device.NewHandler(db)
|
||||
artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir)
|
||||
deploymentHandler := deployment.NewHandler(db)
|
||||
deltaHandler := delta.NewHandler(cfg.ArtefactDir)
|
||||
downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS)
|
||||
|
||||
// Setup Gin
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
// Setup Gin
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// CORS
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
// CORS — withCredentials requires explicit origin, not wildcard
|
||||
r.Use(func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// -- Public routes (daemon-facing) --
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
api.POST("/devices/register", deviceHandler.Register)
|
||||
api.POST("/devices/heartbeat", deviceHandler.Heartbeat)
|
||||
api.GET("/devices/check-update", deviceHandler.CheckUpdate)
|
||||
}
|
||||
// -- Public routes (daemon-facing) --
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/devices/register", deviceHandler.Register)
|
||||
api.POST("/devices/heartbeat", deviceHandler.Heartbeat)
|
||||
api.GET("/devices/check-update", deviceHandler.CheckUpdate)
|
||||
}
|
||||
|
||||
// -- Authenticated admin routes --
|
||||
admin := api.Group("")
|
||||
admin.Use(middleware.AuthMiddleware(authSvc))
|
||||
{
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
// -- Public auth routes --
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
|
||||
admin.GET("/devices", deviceHandler.List)
|
||||
admin.GET("/devices/:deviceId", deviceHandler.Get)
|
||||
// -- Authenticated admin routes --
|
||||
admin := api.Group("")
|
||||
admin.Use(middleware.AuthMiddleware(authSvc))
|
||||
{
|
||||
api.POST("/auth/logout", authHandler.Logout)
|
||||
|
||||
admin.POST("/artefacts", artefactHandler.Upload)
|
||||
admin.GET("/artefacts", artefactHandler.List)
|
||||
admin.GET("/artefacts/:id", artefactHandler.Get)
|
||||
admin.GET("/devices", deviceHandler.List)
|
||||
admin.GET("/devices/:deviceId", deviceHandler.Get)
|
||||
|
||||
admin.POST("/delta/generate", deltaHandler.GenerateDelta)
|
||||
admin.POST("/artefacts", artefactHandler.Upload)
|
||||
admin.GET("/artefacts", artefactHandler.List)
|
||||
admin.GET("/artefacts/:id", artefactHandler.Get)
|
||||
|
||||
admin.POST("/deployments", deploymentHandler.Create)
|
||||
admin.GET("/deployments", deploymentHandler.List)
|
||||
admin.GET("/deployments/:id", deploymentHandler.Get)
|
||||
admin.POST("/deployments/:id/activate", deploymentHandler.Activate)
|
||||
admin.POST("/deployments/:id/pause", deploymentHandler.Pause)
|
||||
admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices)
|
||||
admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback)
|
||||
admin.POST("/delta/generate", deltaHandler.GenerateDelta)
|
||||
|
||||
admin.GET("/admin/users", authHandler.ListUsers)
|
||||
admin.POST("/admin/users", authHandler.CreateUser)
|
||||
admin.DELETE("/admin/users/:id", authHandler.DeleteUser)
|
||||
admin.PUT("/admin/users/:id/password", authHandler.ChangePassword)
|
||||
}
|
||||
admin.POST("/deployments", deploymentHandler.Create)
|
||||
admin.GET("/deployments", deploymentHandler.List)
|
||||
admin.GET("/deployments/:id", deploymentHandler.Get)
|
||||
admin.POST("/deployments/:id/activate", deploymentHandler.Activate)
|
||||
admin.POST("/deployments/:id/pause", deploymentHandler.Pause)
|
||||
admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices)
|
||||
admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback)
|
||||
|
||||
// -- Download endpoint (rate-limited + gated) --
|
||||
packages := api.Group("/packages")
|
||||
packages.Use(middleware.RateLimitMiddleware(tokenBucket))
|
||||
packages.Use(middleware.GatedMiddleware(downloadGate))
|
||||
{
|
||||
packages.GET("/download/:id", downloadHandler.HandleDownload)
|
||||
}
|
||||
admin.GET("/admin/users", authHandler.ListUsers)
|
||||
admin.POST("/admin/users", authHandler.CreateUser)
|
||||
admin.DELETE("/admin/users/:id", authHandler.DeleteUser)
|
||||
admin.PUT("/admin/users/:id/password", authHandler.ChangePassword)
|
||||
}
|
||||
|
||||
// -- Device status update --
|
||||
api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus)
|
||||
// -- Download endpoint (rate-limited + gated) --
|
||||
packages := api.Group("/packages")
|
||||
packages.Use(middleware.RateLimitMiddleware(tokenBucket))
|
||||
packages.Use(middleware.GatedMiddleware(downloadGate))
|
||||
{
|
||||
packages.GET("/download/:id", downloadHandler.HandleDownload)
|
||||
}
|
||||
|
||||
// -- Static admin panel (served at root; API routes take precedence) --
|
||||
r.StaticFS("/", http.Dir("./web/dist"))
|
||||
// -- Device status update --
|
||||
api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus)
|
||||
|
||||
// Graceful shutdown
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.ServerPort,
|
||||
Handler: r,
|
||||
}
|
||||
// -- Static admin panel (SPA fallback) --
|
||||
// Enable with SERVE_SPA=true (default). Set to false when using Caddy/Nginx as reverse proxy.
|
||||
if cfg.ServeSPA {
|
||||
r.Static("/assets", "./web/dist/assets")
|
||||
r.GET("/favicon.ico", func(c *gin.Context) { c.File("./web/dist/favicon.ico") })
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[main] OTA Manifest Server listening on :%s", cfg.ServerPort)
|
||||
log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
// Graceful shutdown
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.ServerPort,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("[main] Shutting down...")
|
||||
go func() {
|
||||
log.Printf("[main] Pipely listening on port :%s", cfg.ServerPort)
|
||||
log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Forced shutdown: %v", err)
|
||||
}
|
||||
log.Println("[main] Server stopped")
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("[main] Shutting down...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Forced shutdown: %v", err)
|
||||
}
|
||||
log.Println("[main] Server stopped")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module onixbyte.com/message-converter-manifest
|
||||
module onixbyte.com/pipely
|
||||
|
||||
go 1.26
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/database"
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/database"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
// -- Admin user management handlers --
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -28,5 +28,16 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
// Set JWT as HttpOnly cookie so the frontend doesn't need to manage it.
|
||||
// The browser sends it automatically on every request.
|
||||
maxAge := h.svc.ExpiryHrs() * 3600
|
||||
c.SetCookie("pipely_token", resp.Token, maxAge, "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
// Clear the auth cookie by setting MaxAge=-1
|
||||
c.SetCookie("pipely_token", "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/database"
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/database"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,6 +33,10 @@ func NewService(db *gorm.DB, secret []byte, expiryHrs int) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ExpiryHrs() int {
|
||||
return int(s.expiry.Hours())
|
||||
}
|
||||
|
||||
func (s *Service) Login(username, password string) (*model.LoginResponse, error) {
|
||||
user, err := database.GetAdminUserByUsername(s.db, username)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
MaxConcurrent int
|
||||
BandwidthBPS int
|
||||
RateLimitRPS int
|
||||
ServeSPA bool
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@@ -34,6 +35,7 @@ func Load() *Config {
|
||||
MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50),
|
||||
BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50),
|
||||
RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100),
|
||||
ServeSPA: envOrDefaultBool("SERVE_SPA", true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +54,12 @@ func envOrDefaultInt(key string, def int) int {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envOrDefaultBool(key string, def bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
// Connect opens a PostgreSQL connection via GORM. If the target database does
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
// -- Admin Users --
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/database"
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/database"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/database"
|
||||
"onixbyte.com/message-converter-manifest/internal/model"
|
||||
"onixbyte.com/pipely/internal/database"
|
||||
"onixbyte.com/pipely/internal/model"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"onixbyte.com/message-converter-manifest/internal/database"
|
||||
"onixbyte.com/message-converter-manifest/internal/middleware"
|
||||
"onixbyte.com/pipely/internal/database"
|
||||
"onixbyte.com/pipely/internal/middleware"
|
||||
)
|
||||
|
||||
// Handler manages artefact downloads with bandwidth throttling and Range support.
|
||||
|
||||
+23
-11
@@ -5,24 +5,18 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"onixbyte.com/message-converter-manifest/internal/auth"
|
||||
"onixbyte.com/pipely/internal/auth"
|
||||
)
|
||||
|
||||
func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation header is missing"})
|
||||
token := extractToken(c)
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation token is missing"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorisation format"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authSvc.ValidateToken(parts[1])
|
||||
user, err := authSvc.ValidateToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()})
|
||||
return
|
||||
@@ -32,3 +26,21 @@ func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
// First try the Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the cookie set at login
|
||||
if cookie, err := c.Cookie("pipely_token"); err == nil {
|
||||
return cookie
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# Pipely Web — Environment Variables
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# Copy this file to .env and adjust values for your environment:
|
||||
# cp .env.example .env
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
# ── API Server ───────────────────────────────────────────
|
||||
|
||||
# Base URL for the OTA Manifest Server API.
|
||||
# Default: https://pipely.onixbyte.com/
|
||||
# Development: http://localhost:8080
|
||||
VITE_API_BASE_URL=http://localhost:5001/api
|
||||
|
||||
# Redux Persistent storage location
|
||||
# Options: `local` (default), `session`
|
||||
VITE_REDUX_STORAGE=local
|
||||
@@ -0,0 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# OTA Manager Web Frontend — Environment Variables
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# Copy this file to .env and adjust values for your environment:
|
||||
# cp .env.example .env
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
# ── API Server ───────────────────────────────────────────
|
||||
|
||||
# Base URL for the OTA Manifest Server API.
|
||||
# Default: https://pipely.onixbyte.com/
|
||||
# Development: http://localhost:8080
|
||||
VITE_API_BASE_URL=https://pipely.onixbyte.com/
|
||||
|
||||
# Redux Persistent storage location
|
||||
# Options: `local` (default), `session`
|
||||
VITE_REDUX_STORAGE=local
|
||||
@@ -12,6 +12,12 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -6,12 +6,18 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
// tseslint.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
// tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
// tseslint.configs.stylisticTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
|
||||
+14
-1
@@ -10,9 +10,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -25,6 +37,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.9.4",
|
||||
"shadcn": "^4.13.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.1",
|
||||
|
||||
Generated
+3047
-43
File diff suppressed because it is too large
Load Diff
-126
@@ -1,126 +0,0 @@
|
||||
/* Reset & base */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Shared card style */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Shared table style */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.td-empty {
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
padding: 30px 12px !important;
|
||||
}
|
||||
|
||||
/* Shared pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Shared utility */
|
||||
.page-title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 20px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.page-loading {
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #d63850;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
width: auto;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.msg-success { color: #27ae60; font-size: 13px; margin: 8px 0; }
|
||||
.msg-error { color: #c00; font-size: 13px; margin: 8px 0; }
|
||||
@@ -1,36 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
|
||||
import Layout from "@/components/Layout"
|
||||
import Login from "@/pages/Login"
|
||||
import Dashboard from "@/pages/Dashboard"
|
||||
import Artefacts from "@/pages/Artefacts"
|
||||
import Deployments from "@/pages/Deployments"
|
||||
import Users from "@/pages/Users"
|
||||
import "@/App.css"
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) return <Navigate to="/" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/artefacts" element={<Artefacts />} />
|
||||
<Route path="/deployments" element={<Deployments />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import WebClient from "@/shared/web-client"
|
||||
|
||||
export interface Artefact {
|
||||
id: number
|
||||
version: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sha256Hash: string
|
||||
isDelta: boolean
|
||||
deltaRef: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ArtefactsResponse {
|
||||
data: Artefact[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ArtefactsQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export async function getArtefacts(query?: ArtefactsQuery): Promise<ArtefactsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (query?.page) params.append("page", query.page.toString())
|
||||
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
|
||||
|
||||
const queryString = params.toString()
|
||||
const { data } = await WebClient.get<ArtefactsResponse>(
|
||||
`/artefacts${queryString ? `?${queryString}` : ""}`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function uploadArtefact(version: string, file: File): Promise<Artefact> {
|
||||
const form = new FormData()
|
||||
form.append("version", version)
|
||||
form.append("file", file)
|
||||
|
||||
const { data } = await WebClient.post<Artefact>("/artefacts", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteArtefact(id: number): Promise<void> {
|
||||
await WebClient.delete(`/artefacts/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import WebClient from "@/shared/web-client"
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function login(credentials: LoginRequest): Promise<void> {
|
||||
await WebClient.post("/auth/login", credentials)
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await WebClient.post("/auth/logout")
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
let token: string | null = localStorage.getItem('token');
|
||||
|
||||
export function setToken(t: string | null) {
|
||||
token = t;
|
||||
if (t) {
|
||||
localStorage.setItem('token', t);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Only set Content-Type for non-GET requests with body
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
} else {
|
||||
delete headers['Content-Type']; // Let browser set multipart boundary
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
setToken(null);
|
||||
window.location.href = '/';
|
||||
throw new Error('Unauthorised');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||
}),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import WebClient from "@/shared/web-client"
|
||||
|
||||
export interface Deployment {
|
||||
id: string
|
||||
name: string
|
||||
artefactId: string
|
||||
artefactVersion: string
|
||||
status: "active" | "paused" | "completed" | "failed"
|
||||
targetGroups: string[]
|
||||
totalDevices: number
|
||||
completedDevices: number
|
||||
failedDevices: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateDeploymentRequest {
|
||||
name: string
|
||||
artefactId: string
|
||||
targetGroups: string[]
|
||||
}
|
||||
|
||||
export interface UpdateDeploymentRequest {
|
||||
status: "active" | "paused"
|
||||
}
|
||||
|
||||
export interface DeploymentsResponse {
|
||||
data: Deployment[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DeploymentsQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
export async function getDeployments(query?: DeploymentsQuery): Promise<DeploymentsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (query?.page) params.append("page", query.page.toString())
|
||||
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
|
||||
if (query?.status) params.append("status", query.status)
|
||||
|
||||
const queryString = params.toString()
|
||||
const { data } = await WebClient.get<DeploymentsResponse>(
|
||||
`/deployments${queryString ? `?${queryString}` : ""}`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createDeployment(request: CreateDeploymentRequest): Promise<Deployment> {
|
||||
const { data } = await WebClient.post<Deployment>("/deployments", request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateDeployment(
|
||||
id: string,
|
||||
request: UpdateDeploymentRequest
|
||||
): Promise<Deployment> {
|
||||
const { data } = await WebClient.put<Deployment>(`/deployments/${id}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteDeployment(id: string): Promise<void> {
|
||||
await WebClient.delete<void>(`/deployments/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import WebClient from "@/shared/web-client"
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
deviceId: string
|
||||
status: "online" | "offline" | "upgrading"
|
||||
lastHeartbeat: string
|
||||
currentVersion?: string
|
||||
targetVersion?: string
|
||||
groupId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DevicesResponse {
|
||||
data: Device[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DevicesQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
groupId?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export async function getDevices(query?: DevicesQuery): Promise<DevicesResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (query?.page) params.append("page", query.page.toString())
|
||||
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
|
||||
if (query?.status) params.append("status", query.status)
|
||||
if (query?.groupId) params.append("groupId", query.groupId)
|
||||
if (query?.search) params.append("search", query.search)
|
||||
|
||||
const queryString = params.toString()
|
||||
const { data } = await WebClient.get<DevicesResponse>(`/devices${queryString ? `?${queryString}` : ""}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import WebClient from "@/shared/web-client"
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: "admin" | "user"
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
password: string
|
||||
role: "admin" | "user"
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
username?: string
|
||||
password?: string
|
||||
role?: "admin" | "user"
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
data: User[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface UsersQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export async function getUsers(query?: UsersQuery): Promise<UsersResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (query?.page) params.append("page", query.page.toString())
|
||||
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
|
||||
|
||||
const queryString = params.toString()
|
||||
const { data } = await WebClient.get<UsersResponse>(
|
||||
`/users${queryString ? `?${queryString}` : ""}`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUser(request: CreateUserRequest): Promise<User> {
|
||||
const { data } = await WebClient.post<User>("/users", request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, request: UpdateUserRequest): Promise<User> {
|
||||
const { data } = await WebClient.put<User>(`/users/${id}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
await WebClient.delete<void>(`/users/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
/**
|
||||
* Empty layout component that provides minimal structure.
|
||||
* Useful for pages that need full control over their layout.
|
||||
*/
|
||||
export default function EmptyLayout() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useRouteError, isRouteErrorResponse } from "react-router-dom"
|
||||
import { Container, Typography, Button, Box } from "@mui/material"
|
||||
|
||||
export default function ErrorPage() {
|
||||
const error = useRouteError()
|
||||
|
||||
let status = 500
|
||||
let message = "Something went wrong."
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
status = error.status
|
||||
message = error.statusText || message
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" component="p" sx={{ mb: 2 }}>
|
||||
{status}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="p" color="text.secondary" sx={{ mb: 4 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => window.location.assign("/")}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid #16213e;
|
||||
}
|
||||
|
||||
.sidebar-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #e94560;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: #a0a0b8;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: #16213e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #16213e;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #e94560;
|
||||
color: #e94560;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
background: #f5f5f7;
|
||||
overflow-y: auto;
|
||||
}
|
||||
+105
-34
@@ -1,42 +1,113 @@
|
||||
import { NavLink, useNavigate, Outlet } from 'react-router-dom';
|
||||
import { setToken } from '../api/client';
|
||||
import './Layout.css';
|
||||
import { NavLink, useNavigate, Outlet } from "react-router-dom"
|
||||
import { useDispatch } from "react-redux"
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
AppBar,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
} from "@mui/material"
|
||||
import {
|
||||
Dashboard as DashboardIcon,
|
||||
Inventory,
|
||||
RocketLaunch,
|
||||
People,
|
||||
Logout,
|
||||
} from "@mui/icons-material"
|
||||
import { logoutAction } from "@/store/auth-slice"
|
||||
import { logout } from "@/api/auth"
|
||||
|
||||
const drawerWidth = 240
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
function handleLogout() {
|
||||
setToken(null);
|
||||
navigate('/');
|
||||
async function handleLogout() {
|
||||
dispatch(logoutAction())
|
||||
await logout().catch(() => {})
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: <DashboardIcon /> },
|
||||
{ path: "/artefacts", label: "Versions", icon: <Inventory /> },
|
||||
{ path: "/deployments", label: "Deployments", icon: <RocketLaunch /> },
|
||||
{ path: "/users", label: "Admin Users", icon: <People /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand">
|
||||
<h2>OTA Manager</h2>
|
||||
</div>
|
||||
<nav>
|
||||
<NavLink to="/dashboard" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/artefacts" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
|
||||
Versions
|
||||
</NavLink>
|
||||
<NavLink to="/deployments" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
|
||||
Deployments
|
||||
</NavLink>
|
||||
<NavLink to="/users" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
|
||||
Admin Users
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<button className="btn-logout" onClick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
ml: `${drawerWidth}px`,
|
||||
}}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
Pipely
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: drawerWidth,
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
}}
|
||||
variant="permanent"
|
||||
anchor="left">
|
||||
<Toolbar />
|
||||
<Divider />
|
||||
<List>
|
||||
{navItems.map((item) => (
|
||||
<ListItem key={item.path} disablePadding>
|
||||
<ListItemButton
|
||||
component={NavLink}
|
||||
to={item.path}
|
||||
sx={{
|
||||
"&.active": {
|
||||
bgcolor: "action.selected",
|
||||
},
|
||||
}}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<Logout />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Logout" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
mt: 8,
|
||||
}}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useDispatch, useSelector } from "react-redux"
|
||||
import type { AppDispatch, RootState } from "@/store"
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||
@@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
export default dayjs
|
||||
+24
-2
@@ -1,10 +1,32 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { RouterProvider } from "react-router-dom"
|
||||
import { Provider } from "react-redux"
|
||||
import { PersistGate } from "redux-persist/integration/react"
|
||||
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"
|
||||
import router from "@/router"
|
||||
import store, { persistor } from "@/store"
|
||||
import "./index.css"
|
||||
import App from "./App.tsx"
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: "light",
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Main application entry point.
|
||||
* Sets up the React app with React Router.
|
||||
*/
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-section h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-form input[type="text"] {
|
||||
width: 180px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-form input[type="file"] {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.msg-success { color: #27ae60; font-size: 13px; margin: 8px 0 0; }
|
||||
.msg-error { color: #c00; font-size: 13px; margin: 8px 0 0; }
|
||||
|
||||
.btn-sm {
|
||||
width: auto;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-delta { background: #fef3cd; color: #856404; }
|
||||
.badge-full { background: #d4edda; color: #155724; }
|
||||
|
||||
.hash-cell {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
+231
-118
@@ -1,17 +1,29 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { api } from "../api/client"
|
||||
import "./Artefacts.css"
|
||||
|
||||
interface Artefact {
|
||||
id: number
|
||||
version: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sha256Hash: string
|
||||
isDelta: boolean
|
||||
deltaRef: string
|
||||
createdAt: string
|
||||
}
|
||||
import { useEffect, useState, type MouseEvent } from "react"
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
Alert,
|
||||
Pagination,
|
||||
} from "@mui/material"
|
||||
import { Upload, Delete, Archive } from "@mui/icons-material"
|
||||
import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -20,17 +32,26 @@ export default function Artefacts() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: number; version: string } | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [version, setVersion] = useState("")
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [uploadMsg, setUploadMsg] = useState("")
|
||||
const [snackbar, setSnackbar] = useState<{
|
||||
open: boolean
|
||||
message: string
|
||||
severity: "success" | "error"
|
||||
}>({
|
||||
open: false,
|
||||
message: "",
|
||||
severity: "success",
|
||||
})
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get<{ data: Artefact[]; total: number }>(
|
||||
`/artefacts?page=${page}&pageSize=${PAGE_SIZE}`
|
||||
)
|
||||
const res = await getArtefacts({ page, pageSize: PAGE_SIZE })
|
||||
setArtefacts(res.data)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
@@ -42,131 +63,223 @@ export default function Artefacts() {
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
}, [page])
|
||||
|
||||
async function handleUpload(e: React.FormEvent) {
|
||||
async function handleUpload(e: MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
if (!file || !version) return
|
||||
|
||||
const form = new FormData()
|
||||
form.append("version", version)
|
||||
form.append("file", file)
|
||||
|
||||
setUploading(true)
|
||||
setUploadMsg("")
|
||||
try {
|
||||
await api.post("/artefacts", form)
|
||||
await uploadArtefact(version, file)
|
||||
setVersion("")
|
||||
setFile(null)
|
||||
setUploadMsg("Version uploaded successfully.")
|
||||
load()
|
||||
setDialogOpen(false)
|
||||
setSnackbar({ open: true, message: "Version uploaded successfully", severity: "success" })
|
||||
await load()
|
||||
} catch (err) {
|
||||
setUploadMsg("Upload failed: " + (err instanceof Error ? err.message : "unknown error"))
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: "Upload failed: " + (err instanceof Error ? err.message : "unknown error"),
|
||||
severity: "error",
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteClick(id: number, version: string) {
|
||||
setDeleteTarget({ id, version })
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await deleteArtefact(deleteTarget.id)
|
||||
setSnackbar({ open: true, message: "Artefact deleted", severity: "success" })
|
||||
setDeleteDialogOpen(false)
|
||||
setDeleteTarget(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: "Failed to delete artefact", severity: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Versions</h1>
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Versions
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Manage software versions and updates
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<Upload />} onClick={() => setDialogOpen(true)}>
|
||||
Upload Version
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<div className="card upload-section">
|
||||
<h3>Create New Version</h3>
|
||||
<form className="upload-form" onSubmit={handleUpload}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Version (e.g. 1.2.0)"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jar,.patch,.zip"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary btn-sm" disabled={uploading}>
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</button>
|
||||
</form>
|
||||
{uploadMsg && (
|
||||
<p className={uploadMsg.includes("failed") ? "msg-error" : "msg-success"}>{uploadMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Upload New Version</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
Upload a new version for device updates.
|
||||
</DialogContentText>
|
||||
<Box component="form" sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="version"
|
||||
label="Version"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="e.g. 1.2.0"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="file"
|
||||
label="File"
|
||||
type="file"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { accept: ".jar,.patch,.zip" },
|
||||
}}
|
||||
onChange={(e) => setFile((e.target as HTMLInputElement).files?.[0] || null)}
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleUpload} disabled={uploading}>
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Paper elevation={1}>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Checksum</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : artefacts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
No versions yet. Upload the first one above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
artefacts.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell sx={{ fontWeight: "medium" }}>{a.version}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Archive fontSize="small" color="action" />
|
||||
{a.fileName}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{formatSize(a.fileSize)}</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
cursor: "help",
|
||||
}}
|
||||
title={a.sha256Hash}>
|
||||
{a.sha256Hash.slice(0, 12)}...
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(a.id, a.version)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>SHA256</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="td-empty">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : artefacts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="td-empty">
|
||||
No versions yet. Upload the first one above.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
artefacts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>
|
||||
<strong>{a.version}</strong>
|
||||
</td>
|
||||
<td>{a.fileName}</td>
|
||||
<td>{formatSize(a.fileSize)}</td>
|
||||
<td>
|
||||
{a.isDelta ? (
|
||||
<span className="badge badge-delta">delta ({a.deltaRef})</span>
|
||||
) : (
|
||||
<span className="badge badge-full">full</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hash-cell" title={a.sha256Hash}>
|
||||
{a.sha256Hash.slice(0, 12)}...
|
||||
</td>
|
||||
<td>{new Date(a.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={(_, value) => setPage(value)}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete Version</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete version "{deleteTarget?.version}"? This action cannot
|
||||
be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||
<Alert
|
||||
severity={snackbar.severity}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
sx={{ width: "100%" }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + " B"
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB"
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MiB"
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
.page-title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 20px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.page-loading {
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border-top: 3px solid #ccc;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
+87
-27
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { api } from "../api/client"
|
||||
import "./Dashboard.css"
|
||||
import { Paper, Typography, Box, Chip, CircularProgress } from "@mui/material"
|
||||
import { Devices, DevicesOther, RocketLaunch, Archive } from "@mui/icons-material"
|
||||
import { getDevices } from "@/api/devices"
|
||||
import { getArtefacts } from "@/api/artefacts"
|
||||
import { getDeployments } from "@/api/deployments"
|
||||
|
||||
interface Stats {
|
||||
totalDevices: number
|
||||
@@ -22,17 +25,13 @@ export default function Dashboard() {
|
||||
async function load() {
|
||||
try {
|
||||
const [devices, artefacts, deployments] = await Promise.all([
|
||||
api.get<{ total: number; data: { status: string }[] }>("/devices?pageSize=1"),
|
||||
api.get<{ total: number }>("/artefacts?pageSize=1"),
|
||||
api.get<{ total: number; data: { status: string }[] }>("/deployments?pageSize=100"),
|
||||
getDevices({ pageSize: 1000 }),
|
||||
getArtefacts({ pageSize: 1000 }),
|
||||
getDeployments({ pageSize: 1000 }),
|
||||
])
|
||||
|
||||
const online = devices.data
|
||||
? devices.data.filter((d: { status: string }) => d.status === "online").length
|
||||
: 0
|
||||
const active = deployments.data
|
||||
? deployments.data.filter((d: { status: string }) => d.status === "active").length
|
||||
: 0
|
||||
const online = devices.data.filter((d) => d.status === "online").length
|
||||
const active = deployments.data.filter((d) => d.status === "active").length
|
||||
|
||||
setStats({
|
||||
totalDevices: devices.total,
|
||||
@@ -47,28 +46,89 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="page-loading">Loading...</div>
|
||||
|
||||
const cards = [
|
||||
{ label: "Total Devices", value: stats.totalDevices, colour: "#4a90d9" },
|
||||
{ label: "Online Devices", value: stats.onlineDevices, colour: "#27ae60" },
|
||||
{ label: "Active Deployments", value: stats.activeDeployments, colour: "#e67e22" },
|
||||
{ label: "Artefact Versions", value: stats.totalArtefacts, colour: "#8e44ad" },
|
||||
{
|
||||
label: "Total Devices",
|
||||
value: stats.totalDevices,
|
||||
icon: <Devices />,
|
||||
colour: "#1976d2",
|
||||
bgColour: "#e3f2fd",
|
||||
},
|
||||
{
|
||||
label: "Online Devices",
|
||||
value: stats.onlineDevices,
|
||||
icon: <DevicesOther />,
|
||||
colour: "#2e7d32",
|
||||
bgColour: "#e8f5e9",
|
||||
},
|
||||
{
|
||||
label: "Active Deployments",
|
||||
value: stats.activeDeployments,
|
||||
icon: <RocketLaunch />,
|
||||
colour: "#e65100",
|
||||
bgColour: "#fff3e0",
|
||||
},
|
||||
{
|
||||
label: "Artefact Versions",
|
||||
value: stats.totalArtefacts,
|
||||
icon: <Archive />,
|
||||
colour: "#7b1fa2",
|
||||
bgColour: "#f3e5f5",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<div className="stats-grid">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="stat-card" style={{ borderTopColor: c.colour }}>
|
||||
<div className="stat-value">{c.value}</div>
|
||||
<div className="stat-label">{c.label}</div>
|
||||
</div>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
|
||||
{cards.map((c, index) => (
|
||||
<Box key={index} sx={{ minWidth: 240, flex: 1 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
borderTop: 4,
|
||||
borderTopColor: c.colour,
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 1,
|
||||
}}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{c.label}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<span style={{ display: "flex" }}>{c.icon}</span>}
|
||||
sx={{
|
||||
backgroundColor: c.bgColour,
|
||||
color: c.colour,
|
||||
"& .MuiChip-icon": { color: c.colour },
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Typography variant="h3" component="div">
|
||||
{c.value}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header .page-title { margin-bottom: 0; }
|
||||
|
||||
.form-section { margin-bottom: 20px; }
|
||||
.form-section h3 { margin: 0 0 12px; font-size: 15px; }
|
||||
|
||||
.dep-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dep-form label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dep-form input,
|
||||
.dep-form select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 3px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.badge-draft { background: #e2e3e5; color: #383d41; }
|
||||
.badge-active { background: #d4edda; color: #155724; }
|
||||
.badge-paused { background: #fff3cd; color: #856404; }
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-cancelled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.actions-cell { white-space: nowrap; }
|
||||
|
||||
.btn-action {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
background: #27ae60;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-action:hover { opacity: 0.85; }
|
||||
.btn-pause { background: #e67e22; }
|
||||
+256
-195
@@ -1,23 +1,42 @@
|
||||
import { type FormEvent, useEffect, useState } from "react"
|
||||
import { api } from "../api/client"
|
||||
import "./Deployments.css"
|
||||
import { useEffect, useState, type MouseEvent } from "react"
|
||||
import { getDeployments, createDeployment, updateDeployment, type Deployment } from "@/api/deployments"
|
||||
import { getArtefacts } from "@/api/artefacts"
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Pagination,
|
||||
} from "@mui/material"
|
||||
import {
|
||||
Add,
|
||||
PlayArrow,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Schedule,
|
||||
} from "@mui/icons-material"
|
||||
|
||||
interface Deployment {
|
||||
id: number
|
||||
artefactId: number
|
||||
artefactVersion: string
|
||||
name: string
|
||||
targetType: string
|
||||
rolloutStrategy: string
|
||||
maxConcurrent: number
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Artefact {
|
||||
id: number
|
||||
version: string
|
||||
}
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function Deployments() {
|
||||
const [deployments, setDeployments] = useState<Deployment[]>([])
|
||||
@@ -25,24 +44,17 @@ export default function Deployments() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Create form
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [artefacts, setArtefacts] = useState<Artefact[]>([])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [artefacts, setArtefacts] = useState<{ id: number; version: string }[]>([])
|
||||
const [name, setName] = useState("")
|
||||
const [artefactId, setArtefactId] = useState("")
|
||||
const [targetType, setTargetType] = useState("all")
|
||||
const [targetSpec, setTargetSpec] = useState("")
|
||||
const [rolloutStrategy, setRolloutStrategy] = useState("immediate")
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(50)
|
||||
const [targetGroups, setTargetGroups] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [msg, setMsg] = useState("")
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get<{ data: Deployment[]; total: number }>(
|
||||
`/deployments?page=${page}&pageSize=20`
|
||||
)
|
||||
const res = await getDeployments({ page, pageSize: PAGE_SIZE })
|
||||
setDeployments(res.data)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
@@ -54,213 +66,262 @@ export default function Deployments() {
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load, page])
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
}, [page])
|
||||
|
||||
async function loadArtefacts() {
|
||||
try {
|
||||
const res = await api.get<{ data: Artefact[] }>("/artefacts?pageSize=200")
|
||||
const res = await getArtefacts({ pageSize: 200 })
|
||||
setArtefacts(res.data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
async function handleCreate(e: MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setMsg("")
|
||||
try {
|
||||
await api.post("/deployments", {
|
||||
artefactId: parseInt(artefactId),
|
||||
await createDeployment({
|
||||
name,
|
||||
targetType,
|
||||
targetSpec: targetSpec || "{}",
|
||||
rolloutStrategy,
|
||||
maxConcurrent,
|
||||
artefactId,
|
||||
targetGroups: targetGroups.split(",").map(g => g.trim()).filter(Boolean),
|
||||
})
|
||||
setShowForm(false)
|
||||
setDialogOpen(false)
|
||||
setName("")
|
||||
setArtefactId("")
|
||||
setTargetSpec("")
|
||||
setMsg("Deployment created. Activate it to begin rollout.")
|
||||
setTargetGroups("")
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
} catch (err) {
|
||||
setMsg("Failed: " + (err instanceof Error ? err.message : "unknown"))
|
||||
console.error("Failed to create deployment:", err)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(id: number) {
|
||||
async function activate(id: string) {
|
||||
try {
|
||||
await api.post(`/deployments/${id}/activate`)
|
||||
await updateDeployment(id, { status: "active" })
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
} catch (err) {
|
||||
alert("Failed to activate")
|
||||
console.error("Failed to activate")
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(id: number) {
|
||||
async function pause(id: string) {
|
||||
try {
|
||||
await api.post(`/deployments/${id}/pause`)
|
||||
await updateDeployment(id, { status: "paused" })
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
} catch (err) {
|
||||
alert("Failed to pause")
|
||||
console.error("Failed to pause")
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / 20)
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'completed': return 'info';
|
||||
case 'failed': return 'error';
|
||||
case 'paused': return 'warning';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Deployments</h1>
|
||||
<button
|
||||
className="btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
if (!showForm) loadArtefacts()
|
||||
}}>
|
||||
{showForm ? "Cancel" : "New Deployment"}
|
||||
</button>
|
||||
</div>
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Deployments
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Manage software deployments to devices
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => {
|
||||
loadArtefacts()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
New Deployment
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{msg && <p className={msg.startsWith("Failed") ? "msg-error" : "msg-success"}>{msg}</p>}
|
||||
|
||||
{showForm && (
|
||||
<div className="card form-section">
|
||||
<h3>Create Deployment</h3>
|
||||
<form className="dep-form" onSubmit={handleCreate}>
|
||||
<label>
|
||||
Name <input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Version
|
||||
<select value={artefactId} onChange={(e) => setArtefactId(e.target.value)} required>
|
||||
<option value="">-- Select --</option>
|
||||
{artefacts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target
|
||||
<select value={targetType} onChange={(e) => setTargetType(e.target.value)}>
|
||||
<option value="all">All Devices</option>
|
||||
<option value="group">By Group</option>
|
||||
<option value="device">Specific Devices</option>
|
||||
</select>
|
||||
</label>
|
||||
{targetType !== "all" && (
|
||||
<label>
|
||||
Target Spec (JSON)
|
||||
<input
|
||||
value={targetSpec}
|
||||
onChange={(e) => setTargetSpec(e.target.value)}
|
||||
placeholder={
|
||||
targetType === "group"
|
||||
? '{"groupName":"default"}'
|
||||
: '{"deviceIds":["id1","id2"]}'
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Strategy
|
||||
<select value={rolloutStrategy} onChange={(e) => setRolloutStrategy(e.target.value)}>
|
||||
<option value="immediate">Immediate</option>
|
||||
<option value="staged">Staged</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Max Concurrent{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={maxConcurrent}
|
||||
onChange={(e) => setMaxConcurrent(Number(e.target.value))}
|
||||
min={1}
|
||||
max={500}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Deployment</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
Create a new deployment to push updates to devices.
|
||||
</DialogContentText>
|
||||
<Box component="form" sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="artefact-label">Version</InputLabel>
|
||||
<Select
|
||||
labelId="artefact-label"
|
||||
id="artefact"
|
||||
value={artefactId}
|
||||
label="Version"
|
||||
onChange={(e) => setArtefactId(e.target.value)}
|
||||
required
|
||||
>
|
||||
{artefacts.map((a) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{a.version}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="target"
|
||||
label="Target Groups"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Comma-separated group names (e.g., default, production)"
|
||||
value={targetGroups}
|
||||
onChange={(e) => setTargetGroups(e.target.value)}
|
||||
helperText="Leave empty to target all devices"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Paper elevation={1}>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell>Progress</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : deployments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
No deployments yet. Create the first one above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
deployments.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell sx={{ fontWeight: 'medium' }}>{d.name}</TableCell>
|
||||
<TableCell>{d.artefactVersion}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={d.totalDevices > 0 ? (d.completedDevices / d.totalDevices) * 100 : 0}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{d.completedDevices}/{d.totalDevices}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={d.status}
|
||||
size="small"
|
||||
color={getStatusColor(d.status) as any}
|
||||
icon={getStatusIcon(d.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(d.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
{d.status === "paused" && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={() => activate(d.id)}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
{d.status === "active" && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Pause />}
|
||||
onClick={() => pause(d.id)}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Version</th>
|
||||
<th>Target</th>
|
||||
<th>Strategy</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="td-empty">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : deployments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="td-empty">
|
||||
No deployments yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
deployments.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>
|
||||
<strong>{d.name}</strong>
|
||||
</td>
|
||||
<td>{d.artefactVersion}</td>
|
||||
<td>{d.targetType}</td>
|
||||
<td>{d.rolloutStrategy}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${d.status}`}>{d.status}</span>
|
||||
</td>
|
||||
<td>{new Date(d.createdAt).toLocaleDateString()}</td>
|
||||
<td className="actions-cell">
|
||||
{d.status === "draft" && (
|
||||
<button className="btn-action" onClick={() => activate(d.id)}>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
{d.status === "active" && (
|
||||
<button className="btn-action btn-pause" onClick={() => pause(d.id)}>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={(_, value) => setPage(value)}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'active': return <PlayArrow fontSize="small" />;
|
||||
case 'paused': return <Pause fontSize="small" />;
|
||||
case 'completed': return <CheckCircle fontSize="small" />;
|
||||
case 'failed': return <Cancel fontSize="small" />;
|
||||
default: return <Schedule fontSize="small" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
width: 360px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.login-form h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0 0 24px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: #fff0f0;
|
||||
color: #c00;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-form input:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 0 2px rgba(233, 69, 96, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #d63850;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
+87
-50
@@ -1,64 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, setToken } from '../api/client';
|
||||
import './Login.css';
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useDispatch } from "react-redux"
|
||||
import { login } from "@/api/auth"
|
||||
import { loginAction } from "@/store/auth-slice"
|
||||
import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material"
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await api.post<{ token: string }>('/auth/login', { username, password });
|
||||
setToken(res.token);
|
||||
navigate('/dashboard');
|
||||
await login({ username, password })
|
||||
dispatch(loginAction())
|
||||
navigate("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<h1>OTA Manager</h1>
|
||||
<p className="login-subtitle">Sign in to manage updates</p>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "grey.50",
|
||||
}}>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<Typography component="h1" variant="h4" sx={{ mb: 2 }}>
|
||||
Pipely
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Sign in to manage updates
|
||||
</Typography>
|
||||
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: "100%", mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn-primary">
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ width: "100%" }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
.user-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-form input,
|
||||
.user-form select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pw-inline {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pw-inline input {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-danger { background: #c0392b; }
|
||||
.btn-danger:hover { background: #a93226; }
|
||||
.btn-cancel { background: #888; }
|
||||
.btn-cancel:hover { background: #666; }
|
||||
+233
-120
@@ -1,149 +1,262 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import './Users.css';
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
import { useEffect, useState, type MouseEvent } from "react"
|
||||
import { getUsers, createUser, deleteUser, type User } from "@/api/users"
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
Chip,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from "@mui/material"
|
||||
import { Add, Delete } from "@mui/icons-material"
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Create form
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('admin');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
// Change password state
|
||||
const [changingId, setChangingId] = useState<number | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [role, setRole] = useState<"admin" | "user">("admin")
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [snackbar, setSnackbar] = useState<{
|
||||
open: boolean
|
||||
message: string
|
||||
severity: "success" | "error"
|
||||
}>({
|
||||
open: false,
|
||||
message: "",
|
||||
severity: "success",
|
||||
})
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get<{ data: AdminUser[] }>('/admin/users');
|
||||
setUsers(res.data);
|
||||
const res = await getUsers({ pageSize: 100 })
|
||||
setUsers(res.data)
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
console.error(err)
|
||||
setSnackbar({ open: true, message: "Failed to load users", severity: "error" })
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch((error: Error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setMsg('');
|
||||
async function handleCreate(e: MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.post('/admin/users', { username, password, role });
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRole('admin');
|
||||
setMsg('User created.');
|
||||
load();
|
||||
await createUser({ username, password, role })
|
||||
setUsername("")
|
||||
setPassword("")
|
||||
setRole("admin")
|
||||
setDialogOpen(false)
|
||||
setSnackbar({ open: true, message: "User created successfully", severity: "success" })
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
} catch (err) {
|
||||
setMsg('Failed: ' + (err instanceof Error ? err.message : 'unknown'));
|
||||
setSnackbar({ open: true, message: "Failed to create user", severity: "error" })
|
||||
} finally {
|
||||
setCreating(false);
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number, uname: string) {
|
||||
if (!confirm(`Delete user "${uname}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/admin/users/${id}`);
|
||||
load();
|
||||
} catch (err) {
|
||||
alert('Failed to delete');
|
||||
}
|
||||
function handleDeleteClick(id: string, name: string) {
|
||||
setDeleteTarget({ id, name })
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleChangePassword(id: number) {
|
||||
if (!newPassword) return;
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await api.put(`/admin/users/${id}/password`, { newPassword });
|
||||
setChangingId(null);
|
||||
setNewPassword('');
|
||||
setMsg('Password changed.');
|
||||
await deleteUser(deleteTarget.id)
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `User "${deleteTarget.name}" deleted`,
|
||||
severity: "success",
|
||||
})
|
||||
setDeleteDialogOpen(false)
|
||||
setDeleteTarget(null)
|
||||
load()
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
} catch (err) {
|
||||
alert('Failed to change password');
|
||||
setSnackbar({ open: true, message: "Failed to delete user", severity: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Admin Users</h1>
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Admin Users
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Manage system administrators
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={() => setDialogOpen(true)}>
|
||||
Add User
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{msg && <p className={msg.startsWith('Failed') ? 'msg-error' : 'msg-success'}>{msg}</p>}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
Add a new administrator to the system.
|
||||
</DialogContentText>
|
||||
<Box component="form" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="username"
|
||||
label="Username"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="role-label">Role</InputLabel>
|
||||
<Select
|
||||
labelId="role-label"
|
||||
id="role"
|
||||
value={role}
|
||||
label="Role"
|
||||
onChange={(e) => setRole(e.target.value as "admin" | "user")}>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="user">Operator</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? "Creating..." : "Create User"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<div className="card form-section">
|
||||
<h3>Add User</h3>
|
||||
<form className="user-form" onSubmit={handleCreate}>
|
||||
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
|
||||
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
<select value={role} onChange={e => setRole(e.target.value)}>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="operator">Operator</option>
|
||||
</select>
|
||||
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Add User'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<Paper elevation={1}>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
No users found. Create the first one above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell sx={{ fontWeight: "medium" }}>{u.username}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={u.role} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{new Date(u.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(u.id, u.username)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={4} className="td-empty">Loading...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={4} className="td-empty">No users.</td></tr>
|
||||
) : users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td><strong>{u.username}</strong></td>
|
||||
<td><span className="badge">{u.role}</span></td>
|
||||
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td className="actions-cell">
|
||||
{changingId === u.id ? (
|
||||
<span className="pw-inline">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
size={16}
|
||||
/>
|
||||
<button className="btn-action" onClick={() => handleChangePassword(u.id)}>Save</button>
|
||||
<button className="btn-action btn-cancel" onClick={() => setChangingId(null)}>Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-action" onClick={() => setChangingId(u.id)}>Change PW</button>
|
||||
<button className="btn-action btn-danger" onClick={() => handleDelete(u.id, u.username)}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete user "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||
<Alert
|
||||
severity={snackbar.severity}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
sx={{ width: "100%" }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ComponentType, ReactNode } from "react"
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom"
|
||||
import ErrorPage from "@/components/ErrorPage"
|
||||
import Layout from "@/components/Layout"
|
||||
import EmptyLayout from "@/components/EmptyLayout"
|
||||
import { isAuthenticated } from "@/store/auth-slice"
|
||||
import { useAppSelector } from "@/hooks/store"
|
||||
|
||||
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
|
||||
return async () => {
|
||||
const module = await importer()
|
||||
return { Component: module.default }
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFallbackElement = <div className="px-4 py-6 text-neutral-500">Loading...</div>
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const authenticated = useAppSelector(isAuthenticated)
|
||||
if (!authenticated) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: <Navigate to="/dashboard" replace />,
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
),
|
||||
hydrateFallbackElement,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
path: "dashboard",
|
||||
lazy: lazy(() => import("@/pages/Dashboard")),
|
||||
},
|
||||
{
|
||||
path: "artefacts",
|
||||
lazy: lazy(() => import("@/pages/Artefacts")),
|
||||
},
|
||||
{
|
||||
path: "deployments",
|
||||
lazy: lazy(() => import("@/pages/Deployments")),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: lazy(() => import("@/pages/Users")),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <EmptyLayout />,
|
||||
hydrateFallbackElement,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
path: "login",
|
||||
lazy: lazy(() => import("@/pages/Login")),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ basename: "/" }
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,10 @@
|
||||
import axios from "axios"
|
||||
import dayjs from "@/lib/dayjs"
|
||||
|
||||
const WebClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
export default WebClient
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createSlice } from "@reduxjs/toolkit"
|
||||
|
||||
export interface AuthState {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
authenticated: false,
|
||||
}
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: "auth",
|
||||
initialState,
|
||||
reducers: {
|
||||
login(state) {
|
||||
state.authenticated = true
|
||||
},
|
||||
logout(state) {
|
||||
state.authenticated = false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { login: loginAction, logout: logoutAction } = authSlice.actions
|
||||
export const authReducer = authSlice.reducer
|
||||
|
||||
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.authenticated
|
||||
@@ -0,0 +1,44 @@
|
||||
import { configureStore, combineReducers } from "@reduxjs/toolkit"
|
||||
import {
|
||||
persistStore,
|
||||
persistReducer,
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
} from "redux-persist"
|
||||
import createWebStorage from "redux-persist/es/storage/createWebStorage"
|
||||
import { authReducer } from "./auth-slice"
|
||||
|
||||
const storage = createWebStorage(import.meta.env.VITE_REDUX_STORAGE ?? "local")
|
||||
|
||||
const persistConfig = {
|
||||
key: "root",
|
||||
storage,
|
||||
whitelist: ["auth"],
|
||||
}
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
})
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer)
|
||||
|
||||
const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const persistor = persistStore(store)
|
||||
|
||||
export default store
|
||||
export type RootState = ReturnType<typeof rootReducer>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
export type AppStore = typeof store
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_REDUX_STORAGE?: "local" | "session"
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -22,8 +22,13 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"],
|
||||
"@/api/*": ["./src/api/*"],
|
||||
"@/shared/*": ["./src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -1,7 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"],
|
||||
"@/api/*": ["./src/api/*"],
|
||||
"@/shared/*": ["./src/shared/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
|
||||
Reference in New Issue
Block a user