feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Fleet Forge - Environment Configuration
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_PORT=3000
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://fleetforge:fleetforge@localhost:5432/fleetforge?schema=public
|
||||||
|
|
||||||
|
# Redis (optional for MVP)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
JWT_SECRET=change-me-in-production-use-a-real-secret
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
SESSION_SECRET=change-me-in-production-use-a-real-session-secret
|
||||||
|
|
||||||
|
# Enrollment
|
||||||
|
ENROLLMENT_TOKEN_EXPIRY=24h
|
||||||
|
|
||||||
|
# Agent
|
||||||
|
AGENT_HEARTBEAT_INTERVAL=30s
|
||||||
|
AGENT_HEARTBEAT_TIMEOUT=120s
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=http://localhost:3001
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Web
|
||||||
|
WEB_PORT=3001
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Compiled agent binaries / release artifacts (kept local, dir preserved via .gitkeep)
|
||||||
|
infrastructure/agent-releases/*/
|
||||||
|
infrastructure/software-releases/*/
|
||||||
|
agents/forge-agent/forge-agent
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# TLS infra (self-signed, generated locally)
|
||||||
|
infrastructure/certs/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.PHONY: setup dev test lint typecheck build migrate seed clean stop logs test-integration
|
||||||
|
|
||||||
|
setup:
|
||||||
|
cp -n .env.example .env 2>/dev/null || true
|
||||||
|
docker compose -f infrastructure/docker/docker-compose.dev.yml up -d postgres redis
|
||||||
|
@echo "Waiting for PostgreSQL..."
|
||||||
|
@sleep 3
|
||||||
|
pnpm install
|
||||||
|
pnpm run migrate --prefix apps/api
|
||||||
|
pnpm run seed --prefix apps/api
|
||||||
|
@echo "Setup complete. Run 'make dev' to start."
|
||||||
|
|
||||||
|
dev:
|
||||||
|
docker compose -f infrastructure/docker/docker-compose.dev.yml up -d postgres redis
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
pnpm run test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
pnpm run lint
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
pnpm run typecheck
|
||||||
|
|
||||||
|
build:
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
pnpm run migrate --prefix apps/api
|
||||||
|
|
||||||
|
seed:
|
||||||
|
pnpm run seed --prefix apps/api
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker compose -f infrastructure/docker/docker-compose.dev.yml down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose -f infrastructure/docker/docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
|
clean:
|
||||||
|
docker compose -f infrastructure/docker/docker-compose.dev.yml down -v
|
||||||
|
rm -rf node_modules apps/*/node_modules packages/*/node_modules
|
||||||
|
|
||||||
|
test-integration: migrate
|
||||||
|
pnpm --filter @fleetforge/api test
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
bash tests/e2e.sh
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/fleetforge/forge-agent/internal/agent"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/client"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/config"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/inventory"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/winsvc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AgentVersion = "0.1.0"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Forge Agent v%s\n", AgentVersion)
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " forge-agent enroll -token <token> -server <url> Enroll this device\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " forge-agent run [--service] Run the agent\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " forge-agent install -server <url> -token <token> Install as Windows Service\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " forge-agent uninstall Uninstall Windows Service\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " forge-agent version Print version\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "enroll":
|
||||||
|
cmdEnroll(os.Args[2:])
|
||||||
|
case "run":
|
||||||
|
cmdRun(os.Args[2:])
|
||||||
|
case "install":
|
||||||
|
cmdInstall(os.Args[2:])
|
||||||
|
case "uninstall":
|
||||||
|
cmdUninstall()
|
||||||
|
case "version":
|
||||||
|
fmt.Printf("Forge Agent v%s\n", AgentVersion)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1])
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdEnroll(args []string) {
|
||||||
|
fs := flag.NewFlagSet("enroll", flag.ExitOnError)
|
||||||
|
token := fs.String("token", "", "Enrollment token")
|
||||||
|
server := fs.String("server", "http://localhost:3002", "Server URL")
|
||||||
|
label := fs.String("label", "", "Device label")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if *token == "" {
|
||||||
|
log.Fatal("Error: -token is required")
|
||||||
|
}
|
||||||
|
performEnrollment(*server, *token, *label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRun(args []string) {
|
||||||
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
serviceMode := fs.Bool("service", false, "Run as Windows Service (use with --service flag)")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v\nRun 'forge-agent enroll' or 'forge-agent install' first.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.EnrollmentToken != "" && cfg.DeviceID == "" {
|
||||||
|
log.Println("First run detected: attempting auto-enrollment...")
|
||||||
|
if err := performEnrollment(cfg.ServerURL, cfg.EnrollmentToken, cfg.Label); err != nil {
|
||||||
|
log.Printf("WARNING: Auto-enrollment failed: %v", err)
|
||||||
|
log.Println("Agent will retry enrollment on next heartbeat cycle.")
|
||||||
|
} else {
|
||||||
|
if loaded, loadErr := config.Load(); loadErr == nil {
|
||||||
|
cfg = loaded
|
||||||
|
}
|
||||||
|
log.Println("Auto-enrollment successful.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *serviceMode {
|
||||||
|
if err := winsvc.RunAsService(); err != nil {
|
||||||
|
log.Fatalf("Service error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a := agent.New(cfg)
|
||||||
|
if err := a.Run(); err != nil {
|
||||||
|
log.Fatalf("Agent error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdInstall(args []string) {
|
||||||
|
fs := flag.NewFlagSet("install", flag.ExitOnError)
|
||||||
|
server := fs.String("server", "", "Server URL")
|
||||||
|
token := fs.String("token", "", "Enrollment token")
|
||||||
|
label := fs.String("label", "", "Device label")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if *server == "" || *token == "" {
|
||||||
|
log.Fatal("Error: -server and -token are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := winsvc.InstallService(*server, *token, *label); err != nil {
|
||||||
|
log.Fatalf("Install failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdUninstall() {
|
||||||
|
if err := winsvc.UninstallService(); err != nil {
|
||||||
|
log.Fatalf("Uninstall failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func performEnrollment(serverURL, token, label string) error {
|
||||||
|
fmt.Println("Collecting system information...")
|
||||||
|
info, err := inventory.CollectSystemInfo()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to collect system info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Hostname: %s\n", info.Hostname)
|
||||||
|
fmt.Printf("OS: %s %s\n", info.OSName, info.OSVersion)
|
||||||
|
fmt.Printf("Arch: %s\n", info.Architecture)
|
||||||
|
fmt.Printf("Server: %s\n", serverURL)
|
||||||
|
fmt.Println("Enrolling...")
|
||||||
|
|
||||||
|
c := client.New(serverURL, "")
|
||||||
|
resp, err := c.Enroll(client.EnrollRequest{
|
||||||
|
Token: token,
|
||||||
|
Hostname: info.Hostname,
|
||||||
|
Platform: info.Platform,
|
||||||
|
OSName: info.OSName,
|
||||||
|
OSVersion: info.OSVersion,
|
||||||
|
Architecture: info.Architecture,
|
||||||
|
KernelVersion: info.KernelVersion,
|
||||||
|
CPUModel: info.CPUModel,
|
||||||
|
CPUCores: info.CPUCores,
|
||||||
|
TotalMemoryBytes: info.TotalMemory,
|
||||||
|
TotalDiskBytes: info.TotalDisk,
|
||||||
|
IPAddress: info.IPAddress,
|
||||||
|
AgentVersion: AgentVersion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enrollment failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
cfg = &config.Config{}
|
||||||
|
}
|
||||||
|
cfg.ServerURL = serverURL
|
||||||
|
cfg.DeviceID = resp.DeviceID
|
||||||
|
cfg.Credential = resp.Credential
|
||||||
|
cfg.Label = label
|
||||||
|
cfg.EnrollmentToken = ""
|
||||||
|
if cfg.HeartbeatSec <= 0 {
|
||||||
|
cfg.HeartbeatSec = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nEnrollment successful!\n")
|
||||||
|
fmt.Printf("Device ID: %s\n", resp.DeviceID)
|
||||||
|
fmt.Printf("Config saved to: %s\n", config.ConfigPath())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module github.com/fleetforge/forge-agent
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
|
golang.org/x/sys v0.20.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fleetforge/forge-agent/internal/client"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/config"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/inventory"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/metrics"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/pkgmanager"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/svcmgr"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/tunnel"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/vnc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AgentVersion = "0.1.6"
|
||||||
|
|
||||||
|
var (
|
||||||
|
tunnelMu sync.Mutex
|
||||||
|
tunnels = map[string]*tunnel.Tunnel{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func storeTunnel(sessionID string, t *tunnel.Tunnel) {
|
||||||
|
tunnelMu.Lock()
|
||||||
|
defer tunnelMu.Unlock()
|
||||||
|
tunnels[sessionID] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTunnel(sessionID string) {
|
||||||
|
tunnelMu.Lock()
|
||||||
|
defer tunnelMu.Unlock()
|
||||||
|
delete(tunnels, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTunnel(sessionID string) {
|
||||||
|
tunnelMu.Lock()
|
||||||
|
t := tunnels[sessionID]
|
||||||
|
tunnelMu.Unlock()
|
||||||
|
if t != nil {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
cfg *config.Config
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) *Agent {
|
||||||
|
return &Agent{
|
||||||
|
cfg: cfg,
|
||||||
|
client: client.New(cfg.ServerURL, cfg.Credential),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) Run() error {
|
||||||
|
log.Println("Forge Agent starting...")
|
||||||
|
log.Printf("Server: %s", a.cfg.ServerURL)
|
||||||
|
log.Printf("Device ID: %s", a.cfg.DeviceID)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(a.cfg.HeartbeatSec) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
if a.cfg.DeviceID == "" {
|
||||||
|
log.Println("No DeviceID configured - waiting for enrollment")
|
||||||
|
} else {
|
||||||
|
if err := pkgmanager.BootstrapManagers(); err != nil {
|
||||||
|
log.Printf("WARNING: Manager bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
a.sendHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if a.cfg.DeviceID == "" || a.cfg.Credential == "" {
|
||||||
|
a.tryEnroll()
|
||||||
|
} else {
|
||||||
|
a.sendHeartbeat()
|
||||||
|
a.checkJobs()
|
||||||
|
}
|
||||||
|
case sig := <-sigCh:
|
||||||
|
log.Printf("Received signal %v, shutting down...", sig)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) tryEnroll() {
|
||||||
|
if a.cfg.EnrollmentToken == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("Attempting auto-enrollment...")
|
||||||
|
|
||||||
|
info, err := inventory.CollectSystemInfo()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Enrollment failed (inventory): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := client.New(a.cfg.ServerURL, "")
|
||||||
|
resp, err := c.Enroll(client.EnrollRequest{
|
||||||
|
Token: a.cfg.EnrollmentToken,
|
||||||
|
Hostname: info.Hostname,
|
||||||
|
Platform: info.Platform,
|
||||||
|
OSName: info.OSName,
|
||||||
|
OSVersion: info.OSVersion,
|
||||||
|
Architecture: info.Architecture,
|
||||||
|
KernelVersion: info.KernelVersion,
|
||||||
|
CPUModel: info.CPUModel,
|
||||||
|
CPUCores: info.CPUCores,
|
||||||
|
TotalMemoryBytes: info.TotalMemory,
|
||||||
|
TotalDiskBytes: info.TotalDisk,
|
||||||
|
IPAddress: info.IPAddress,
|
||||||
|
AgentVersion: AgentVersion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Enrollment failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.cfg.DeviceID = resp.DeviceID
|
||||||
|
a.cfg.Credential = resp.Credential
|
||||||
|
a.cfg.EnrollmentToken = ""
|
||||||
|
if err := config.Save(a.cfg); err != nil {
|
||||||
|
log.Printf("Failed to save config after enrollment: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.client = client.New(a.cfg.ServerURL, a.cfg.Credential)
|
||||||
|
log.Printf("Enrollment successful! Device ID: %s", resp.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) sendHeartbeat() {
|
||||||
|
info, err := inventory.CollectSystemInfo()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to collect system info: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
met, err := metrics.Collect()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to collect metrics: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := client.HeartbeatRequest{
|
||||||
|
Hostname: info.Hostname,
|
||||||
|
OSVersion: info.OSVersion,
|
||||||
|
KernelVersion: info.KernelVersion,
|
||||||
|
AgentVersion: AgentVersion,
|
||||||
|
IPAddress: info.IPAddress,
|
||||||
|
Metrics: &client.MetricsData{
|
||||||
|
CPUPercent: met.CPUPercent,
|
||||||
|
MemoryPercent: met.MemoryPercent,
|
||||||
|
MemoryUsed: met.MemoryUsed,
|
||||||
|
MemoryTotal: met.MemoryTotal,
|
||||||
|
DiskPercent: met.DiskPercent,
|
||||||
|
DiskUsed: met.DiskUsed,
|
||||||
|
DiskTotal: met.DiskTotal,
|
||||||
|
Load1: met.Load1,
|
||||||
|
Load5: met.Load5,
|
||||||
|
Load15: met.Load15,
|
||||||
|
UptimeSeconds: met.UptimeSeconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(info.Packages) > 0 || len(info.Services) > 0 || len(info.NetworkInterfaces) > 0 {
|
||||||
|
inv := &client.InventoryData{}
|
||||||
|
for _, p := range info.Packages {
|
||||||
|
inv.Packages = append(inv.Packages, client.PackageData{
|
||||||
|
Name: p.Name,
|
||||||
|
Version: p.Version,
|
||||||
|
Manager: p.Manager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, s := range info.Services {
|
||||||
|
inv.Services = append(inv.Services, client.ServiceData{
|
||||||
|
Name: s.Name,
|
||||||
|
Status: s.Status,
|
||||||
|
Enabled: s.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, ni := range info.NetworkInterfaces {
|
||||||
|
inv.NetworkInterfaces = append(inv.NetworkInterfaces, client.NetworkInterfaceData{
|
||||||
|
Name: ni.Name,
|
||||||
|
Addresses: ni.Addresses,
|
||||||
|
MacAddress: ni.MAC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
req.Inventory = inv
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.client.Heartbeat(req); err != nil {
|
||||||
|
log.Printf("Heartbeat failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Heartbeat sent successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) checkJobs() {
|
||||||
|
jobs, err := a.client.FetchJobs()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to fetch jobs: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
a.executeJob(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) executeJob(job client.JobInfo) {
|
||||||
|
log.Printf("Executing job %s (type: %s)", job.JobID, job.JobType)
|
||||||
|
|
||||||
|
result := client.JobResult{
|
||||||
|
Status: "succeeded",
|
||||||
|
Result: map[string]interface{}{},
|
||||||
|
}
|
||||||
|
result.TargetID = job.TargetID
|
||||||
|
|
||||||
|
if err := a.client.ReportStart(job.JobID, job.TargetID); err != nil {
|
||||||
|
log.Printf("Failed to report start for job %s: %v", job.JobID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch job.JobType {
|
||||||
|
case "update_inventory":
|
||||||
|
a.sendHeartbeat()
|
||||||
|
result.Result["inventory_refreshed"] = true
|
||||||
|
|
||||||
|
case "update_system_info":
|
||||||
|
met, err := metrics.Collect()
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
} else {
|
||||||
|
result.Result["cpu_percent"] = met.CPUPercent
|
||||||
|
result.Result["memory_percent"] = met.MemoryPercent
|
||||||
|
result.Result["disk_percent"] = met.DiskPercent
|
||||||
|
result.Result["uptime"] = met.UptimeSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
case "install_package":
|
||||||
|
pkgName, _ := job.Parameters["package_name"].(string)
|
||||||
|
if pkgName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: package_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := pkgmanager.InstallPackage(pkgName)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["package"] = pkgName
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "update_package":
|
||||||
|
pkgName, _ := job.Parameters["package_name"].(string)
|
||||||
|
if pkgName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: package_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := pkgmanager.UpdatePackage(pkgName)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["package"] = pkgName
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "remove_package":
|
||||||
|
pkgName, _ := job.Parameters["package_name"].(string)
|
||||||
|
if pkgName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: package_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
source, _ := job.Parameters["source"].(string)
|
||||||
|
var manager string
|
||||||
|
switch source {
|
||||||
|
case "winget":
|
||||||
|
manager = "winget"
|
||||||
|
case "chocolatey":
|
||||||
|
manager = "choco"
|
||||||
|
}
|
||||||
|
var output string
|
||||||
|
var err error
|
||||||
|
if manager != "" {
|
||||||
|
output, err = pkgmanager.RemovePackageWithManager(pkgName, manager)
|
||||||
|
} else {
|
||||||
|
output, err = pkgmanager.RemovePackage(pkgName)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["package"] = pkgName
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "start_service":
|
||||||
|
svcName, _ := job.Parameters["service_name"].(string)
|
||||||
|
if svcName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: service_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := svcmgr.StartService(svcName)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["service"] = svcName
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "stop_service":
|
||||||
|
svcName, _ := job.Parameters["service_name"].(string)
|
||||||
|
if svcName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: service_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := svcmgr.StopService(svcName)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["service"] = svcName
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "restart_service":
|
||||||
|
svcName, _ := job.Parameters["service_name"].(string)
|
||||||
|
if svcName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: service_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := svcmgr.RestartService(svcName)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["service"] = svcName
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "check_service_status":
|
||||||
|
svcName, _ := job.Parameters["service_name"].(string)
|
||||||
|
if svcName == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: service_name"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
status, enabled, err := svcmgr.GetServiceStatus(svcName)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["service"] = svcName
|
||||||
|
result.Result["status"] = status
|
||||||
|
result.Result["enabled"] = enabled
|
||||||
|
|
||||||
|
case "reboot_system":
|
||||||
|
log.Println("System reboot requested, initiating reboot...")
|
||||||
|
result.Result["message"] = "reboot initiated"
|
||||||
|
if err := a.client.SubmitResult(job.JobID, result); err != nil {
|
||||||
|
log.Printf("Failed to submit result for reboot job: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("Rebooting system in 5 seconds...")
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
svcmgr.Reboot()
|
||||||
|
return
|
||||||
|
|
||||||
|
case "shutdown_system":
|
||||||
|
log.Println("System shutdown requested, initiating shutdown...")
|
||||||
|
result.Result["message"] = "shutdown initiated"
|
||||||
|
if err := a.client.SubmitResult(job.JobID, result); err != nil {
|
||||||
|
log.Printf("Failed to submit result for shutdown job: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("Shutting down system in 5 seconds...")
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
svcmgr.Shutdown()
|
||||||
|
return
|
||||||
|
|
||||||
|
case "install_registry_package":
|
||||||
|
source, _ := job.Parameters["source"].(string)
|
||||||
|
pkgID, _ := job.Parameters["package_id"].(string)
|
||||||
|
if source == "" || pkgID == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: source/package_id"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var manager string
|
||||||
|
switch source {
|
||||||
|
case "winget":
|
||||||
|
manager = "winget"
|
||||||
|
case "chocolatey":
|
||||||
|
manager = "choco"
|
||||||
|
default:
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = fmt.Sprintf("unsupported source: %s", source)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if result.Status == "failed" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
version, _ := job.Parameters["package_version"].(string)
|
||||||
|
output, err := pkgmanager.InstallPackageWithManager(pkgID, manager, version)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["package"] = pkgID
|
||||||
|
result.Result["source"] = source
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "start_remote_session":
|
||||||
|
sessionID, _ := job.Parameters["session_id"].(string)
|
||||||
|
if sessionID == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: session_id"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
port := 5900
|
||||||
|
if p, ok := job.Parameters["vnc_port"].(float64); ok && p > 0 {
|
||||||
|
port = int(p)
|
||||||
|
}
|
||||||
|
password, _ := job.Parameters["vnc_password"].(string)
|
||||||
|
|
||||||
|
vncServer, err := vnc.Ensure(port, password)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("VNC server started on port %d for session %s", port, sessionID)
|
||||||
|
|
||||||
|
result.Result["session_id"] = sessionID
|
||||||
|
result.Result["vnc_port"] = port
|
||||||
|
result.Result["vnc_started"] = true
|
||||||
|
|
||||||
|
// Keep the session alive in a background goroutine; VNC is stopped when
|
||||||
|
// the tunnel closes.
|
||||||
|
go func(sid string, p int, vs *vnc.Server) {
|
||||||
|
t := tunnel.New(a.cfg.ServerURL, a.cfg.Credential, sid, p)
|
||||||
|
storeTunnel(sid, t)
|
||||||
|
defer func() {
|
||||||
|
removeTunnel(sid)
|
||||||
|
vs.Stop()
|
||||||
|
log.Printf("VNC server stopped after tunnel close for session %s", sid)
|
||||||
|
}()
|
||||||
|
if err := t.Run(); err != nil {
|
||||||
|
log.Printf("Tunnel for session %s closed: %v", sid, err)
|
||||||
|
}
|
||||||
|
}(sessionID, port, vncServer)
|
||||||
|
|
||||||
|
case "stop_remote_session":
|
||||||
|
sessionID, _ := job.Parameters["session_id"].(string)
|
||||||
|
if sessionID == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: session_id"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stopTunnel(sessionID)
|
||||||
|
result.Result["session_id"] = sessionID
|
||||||
|
result.Result["vnc_stopped"] = true
|
||||||
|
|
||||||
|
case "install_from_file":
|
||||||
|
filePath, _ := job.Parameters["file_path"].(string)
|
||||||
|
if filePath == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: file_path"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := pkgmanager.InstallPackageFromFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
result.Result["file_path"] = filePath
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
case "download_and_install":
|
||||||
|
downloadURL, _ := job.Parameters["download_url"].(string)
|
||||||
|
packageId, _ := job.Parameters["package_id"].(string)
|
||||||
|
if downloadURL == "" && packageId == "" {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = "missing parameter: download_url or package_id"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if downloadURL == "" && packageId != "" {
|
||||||
|
downloadURL = a.cfg.ServerURL + "/api/v1/agent/software/" + packageId + "/download"
|
||||||
|
}
|
||||||
|
tmpDir := os.TempDir()
|
||||||
|
destPath := tmpDir + "/forge-download-" + packageId + ".pkg"
|
||||||
|
log.Printf("Downloading package from %s to %s", downloadURL, destPath)
|
||||||
|
if err := pkgmanager.DownloadFile(downloadURL, destPath); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = fmt.Sprintf("download failed: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output, err := pkgmanager.InstallPackageFromFile(destPath)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
os.Remove(destPath)
|
||||||
|
result.Result["download_url"] = downloadURL
|
||||||
|
result.Result["output"] = truncateOutput(output)
|
||||||
|
|
||||||
|
default:
|
||||||
|
result.Status = "failed"
|
||||||
|
result.ErrorMessage = fmt.Sprintf("unsupported job type: %s", job.JobType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.client.IsJobCancelled(job.JobID) {
|
||||||
|
log.Printf("Job %s cancelled on server; discarding result", job.JobID)
|
||||||
|
result.Status = "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.client.SubmitResult(job.JobID, result); err != nil {
|
||||||
|
log.Printf("Failed to submit result for job %s: %v", job.JobID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Job %s completed with status: %s", job.JobID, result.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateOutput(output string) string {
|
||||||
|
const maxLen = 4096
|
||||||
|
if len(output) > maxLen {
|
||||||
|
return output[:maxLen] + "... (truncated)"
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
serverURL string
|
||||||
|
credential string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(serverURL, credential string) *Client {
|
||||||
|
return &Client{
|
||||||
|
serverURL: serverURL,
|
||||||
|
credential: credential,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doPost(path string, body interface{}, out interface{}) error {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.serverURL+path, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.credential != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("execute request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out != nil {
|
||||||
|
var apiResp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if !apiResp.Success {
|
||||||
|
return fmt.Errorf("api returned success=false")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(apiResp.Data, out); err != nil {
|
||||||
|
return fmt.Errorf("decode data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doGet(path string, out interface{}) error {
|
||||||
|
req, err := http.NewRequest("GET", c.serverURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.credential)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("execute request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out != nil {
|
||||||
|
var apiResp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(apiResp.Data, out); err != nil {
|
||||||
|
return fmt.Errorf("decode data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrollRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
OSName string `json:"os_name"`
|
||||||
|
OSVersion string `json:"os_version"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
KernelVersion string `json:"kernel_version,omitempty"`
|
||||||
|
CPUModel string `json:"cpu_model,omitempty"`
|
||||||
|
CPUCores int `json:"cpu_cores,omitempty"`
|
||||||
|
TotalMemoryBytes uint64 `json:"total_memory_bytes,omitempty"`
|
||||||
|
TotalDiskBytes uint64 `json:"total_disk_bytes,omitempty"`
|
||||||
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
|
AgentVersion string `json:"agent_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrollResponse struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Enroll(req EnrollRequest) (*EnrollResponse, error) {
|
||||||
|
var resp EnrollResponse
|
||||||
|
if err := c.doPost("/api/v1/agent/enroll", &req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricsData struct {
|
||||||
|
CPUPercent float64 `json:"cpu_usage_percent"`
|
||||||
|
MemoryPercent float64 `json:"memory_usage_percent"`
|
||||||
|
MemoryUsed uint64 `json:"memory_used_bytes"`
|
||||||
|
MemoryTotal uint64 `json:"memory_total_bytes"`
|
||||||
|
DiskPercent float64 `json:"disk_usage_percent"`
|
||||||
|
DiskUsed uint64 `json:"disk_used_bytes"`
|
||||||
|
DiskTotal uint64 `json:"disk_total_bytes"`
|
||||||
|
Load1 float64 `json:"load_average_1m"`
|
||||||
|
Load5 float64 `json:"load_average_5m"`
|
||||||
|
Load15 float64 `json:"load_average_15m"`
|
||||||
|
UptimeSeconds uint64 `json:"uptime_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeartbeatRequest struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
OSVersion string `json:"os_version"`
|
||||||
|
KernelVersion string `json:"kernel_version"`
|
||||||
|
AgentVersion string `json:"agent_version"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Metrics *MetricsData `json:"metrics,omitempty"`
|
||||||
|
Inventory *InventoryData `json:"inventory,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryData struct {
|
||||||
|
Packages []PackageData `json:"packages,omitempty"`
|
||||||
|
Services []ServiceData `json:"services,omitempty"`
|
||||||
|
NetworkInterfaces []NetworkInterfaceData `json:"network_interfaces,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackageData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Manager string `json:"manager"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkInterfaceData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
MacAddress string `json:"mac_address,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Heartbeat(req HeartbeatRequest) error {
|
||||||
|
return c.doPost("/api/v1/agent/heartbeat", &req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobInfo struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
JobType string `json:"jobType"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchJobs() ([]JobInfo, error) {
|
||||||
|
var jobs []JobInfo
|
||||||
|
if err := c.doGet("/api/v1/agent/jobs", &jobs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobResult struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Result map[string]interface{} `json:"result,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReportStart(jobID, targetID string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/agent/jobs/%s/start", jobID)
|
||||||
|
body := map[string]string{"target_id": targetID}
|
||||||
|
return c.doPost(path, &body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) IsJobCancelled(jobID string) bool {
|
||||||
|
var status struct {
|
||||||
|
JobStatus string `json:"job_status"`
|
||||||
|
TargetStatus string `json:"target_status"`
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/agent/jobs/%s/status", jobID)
|
||||||
|
if err := c.doGet(path, &status); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return status.JobStatus == "cancelled" || status.TargetStatus == "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SubmitResult(jobID string, result JobResult) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/agent/jobs/%s/result", jobID)
|
||||||
|
return c.doPost(path, &result, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
EnrollmentToken string `json:"enrollment_token,omitempty"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Credential string `json:"credential"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
HeartbeatSec int `json:"heartbeat_interval_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDir() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
if p := os.Getenv("ProgramData"); p != "" {
|
||||||
|
return filepath.Join(p, "FleetForge")
|
||||||
|
}
|
||||||
|
return `C:\ProgramData\FleetForge`
|
||||||
|
case "darwin":
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, "Library", "Application Support", "FleetForge")
|
||||||
|
default:
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".config", "forge-agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigPath() string {
|
||||||
|
return filepath.Join(configDir(), "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
data, err := os.ReadFile(ConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.HeartbeatSec <= 0 {
|
||||||
|
cfg.HeartbeatSec = 30
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *Config) error {
|
||||||
|
dir := filepath.Dir(ConfigPath())
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(ConfigPath(), data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Delete() error {
|
||||||
|
return os.Remove(ConfigPath())
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fleetforge/forge-agent/internal/pkgmanager"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/svcmgr"
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
|
"github.com/shirou/gopsutil/v3/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func execCmd(name string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkInterface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
MAC string `json:"mac_address,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemInfo struct {
|
||||||
|
Hostname string
|
||||||
|
Platform string
|
||||||
|
OSName string
|
||||||
|
OSVersion string
|
||||||
|
Architecture string
|
||||||
|
KernelVersion string
|
||||||
|
CPUModel string
|
||||||
|
CPUCores int
|
||||||
|
TotalMemory uint64
|
||||||
|
TotalDisk uint64
|
||||||
|
IPAddress string
|
||||||
|
NetworkInterfaces []NetworkInterface
|
||||||
|
Packages []pkgmanager.PackageInfo
|
||||||
|
Services []svcmgr.ServiceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func CollectSystemInfo() (*SystemInfo, error) {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
|
||||||
|
info := &SystemInfo{
|
||||||
|
Hostname: hostname,
|
||||||
|
Platform: runtime.GOOS,
|
||||||
|
Architecture: runtime.GOARCH,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
info.OSName = "Linux"
|
||||||
|
if data, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||||
|
info.OSName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "VERSION_ID=") {
|
||||||
|
info.OSVersion = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile("/proc/version"); err == nil {
|
||||||
|
parts := strings.Fields(string(data))
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
info.KernelVersion = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
info.OSName = "macOS"
|
||||||
|
if out, err := execCmd("sw_vers", "-productVersion"); err == nil {
|
||||||
|
info.OSVersion = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
if out, err := execCmd("uname", "-r"); err == nil {
|
||||||
|
info.KernelVersion = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
info.OSName = "Windows"
|
||||||
|
if out, err := execCmd("cmd", "/c", "ver"); err == nil {
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
if start := strings.Index(out, "[Version "); start != -1 {
|
||||||
|
ver := out[start+len("[Version "):]
|
||||||
|
if end := strings.Index(ver, "]"); end != -1 {
|
||||||
|
info.OSVersion = ver[:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.OSVersion == "" {
|
||||||
|
info.OSVersion = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err := execCmd("cmd", "/c", "ver"); err == nil {
|
||||||
|
_ = out
|
||||||
|
}
|
||||||
|
if out, err := execCmd("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
"(Get-CimInstance Win32_OperatingSystem).Caption"); err == nil {
|
||||||
|
caption := strings.TrimSpace(out)
|
||||||
|
if caption != "" {
|
||||||
|
info.OSName = caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err := execCmd("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
"$k = (Get-CimInstance Win32_OperatingSystem).Version; $b = (Get-CimInstance Win32_ComputerSystem).BuildNumber; $k"); err == nil {
|
||||||
|
if kb := strings.TrimSpace(out); kb != "" {
|
||||||
|
info.KernelVersion = kb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
|
||||||
|
info.CPUModel = cpuInfo[0].ModelName
|
||||||
|
info.CPUCores = len(cpuInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
info.TotalMemory = v.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
if d, err := disk.Usage("/"); err == nil {
|
||||||
|
info.TotalDisk = d.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
populateNetwork(info)
|
||||||
|
|
||||||
|
if info.OSVersion == "" {
|
||||||
|
info.OSVersion = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkgs, err := pkgmanager.ListPackages(); err == nil {
|
||||||
|
info.Packages = pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
if svcs, err := svcmgr.ListServices(); err == nil {
|
||||||
|
info.Services = svcs
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateNetwork(info *SystemInfo) {
|
||||||
|
ifs, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryIP string
|
||||||
|
for _, ifc := range ifs {
|
||||||
|
if !containsFlag(ifc.Flags, "up") || containsFlag(ifc.Flags, "loopback") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ni := NetworkInterface{
|
||||||
|
Name: ifc.Name,
|
||||||
|
Addresses: []string{},
|
||||||
|
MAC: ifc.HardwareAddr,
|
||||||
|
}
|
||||||
|
for _, addr := range ifc.Addrs {
|
||||||
|
if addr.Addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip := strings.TrimSpace(addr.Addr)
|
||||||
|
ni.Addresses = append(ni.Addresses, ip)
|
||||||
|
if primaryIP == "" && !strings.Contains(ip, ":") {
|
||||||
|
primaryIP = strings.Split(ip, "/")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ni.Addresses) > 0 {
|
||||||
|
info.NetworkInterfaces = append(info.NetworkInterfaces, ni)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if primaryIP != "" {
|
||||||
|
info.IPAddress = primaryIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFlag(flags []string, flag string) bool {
|
||||||
|
for _, f := range flags {
|
||||||
|
if strings.EqualFold(f, flag) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
|
"github.com/shirou/gopsutil/v3/load"
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Snapshot struct {
|
||||||
|
CPUPercent float64
|
||||||
|
MemoryPercent float64
|
||||||
|
MemoryUsed uint64
|
||||||
|
MemoryTotal uint64
|
||||||
|
DiskPercent float64
|
||||||
|
DiskUsed uint64
|
||||||
|
DiskTotal uint64
|
||||||
|
Load1 float64
|
||||||
|
Load5 float64
|
||||||
|
Load15 float64
|
||||||
|
UptimeSeconds uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func Collect() (*Snapshot, error) {
|
||||||
|
s := &Snapshot{}
|
||||||
|
|
||||||
|
if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
|
||||||
|
s.CPUPercent = percents[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
s.MemoryPercent = v.UsedPercent
|
||||||
|
s.MemoryUsed = v.Used
|
||||||
|
s.MemoryTotal = v.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
if d, err := disk.Usage("/"); err == nil {
|
||||||
|
s.DiskPercent = d.UsedPercent
|
||||||
|
s.DiskUsed = d.Used
|
||||||
|
s.DiskTotal = d.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
if avg, err := load.Avg(); err == nil {
|
||||||
|
s.Load1 = avg.Load1
|
||||||
|
s.Load5 = avg.Load5
|
||||||
|
s.Load15 = avg.Load15
|
||||||
|
}
|
||||||
|
|
||||||
|
if up, err := host.Uptime(); err == nil {
|
||||||
|
s.UptimeSeconds = up
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) >= 3 {
|
||||||
|
s.Load1, _ = strconv.ParseFloat(fields[0], 64)
|
||||||
|
s.Load5, _ = strconv.ParseFloat(fields[1], 64)
|
||||||
|
s.Load15, _ = strconv.ParseFloat(fields[2], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package pkgmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Manager string `json:"manager"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectManager() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
|
||||||
|
return "winget"
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(resolveManagerPath("choco", interactiveProfile())); err == nil {
|
||||||
|
return "choco"
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("msiexec"); err == nil {
|
||||||
|
return "msiexec"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("apt"); err == nil {
|
||||||
|
return "apt"
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("dnf"); err == nil {
|
||||||
|
return "dnf"
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("yum"); err == nil {
|
||||||
|
return "yum"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPackages() ([]PackageInfo, error) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return listWindowsPackages()
|
||||||
|
}
|
||||||
|
manager := DetectManager()
|
||||||
|
switch manager {
|
||||||
|
case "apt":
|
||||||
|
return listAptPackages()
|
||||||
|
case "yum", "dnf":
|
||||||
|
return listYumPackages()
|
||||||
|
case "winget":
|
||||||
|
return listWingetPackages()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported package manager: %s", manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAptPackages() ([]PackageInfo, error) {
|
||||||
|
cmd := exec.Command("dpkg-query", "-W", "-f", "${Package}\t${Version}\n")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dpkg-query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []PackageInfo
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
packages = append(packages, PackageInfo{
|
||||||
|
Name: parts[0],
|
||||||
|
Version: parts[1],
|
||||||
|
Manager: "apt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listYumPackages() ([]PackageInfo, error) {
|
||||||
|
cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\t%{VERSION}-%{RELEASE}\n")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rpm query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []PackageInfo
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
packages = append(packages, PackageInfo{
|
||||||
|
Name: parts[0],
|
||||||
|
Version: parts[1],
|
||||||
|
Manager: "yum",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listWingetPackages() ([]PackageInfo, error) {
|
||||||
|
cmd := exec.Command(resolveManagerPath("winget", interactiveProfile()), "list", "--accept-source-agreements", "--disable-interactivity")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("winget list failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []PackageInfo
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
inTable := false
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "Name") && strings.Contains(trimmed, "Id") {
|
||||||
|
inTable = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inTable && strings.Contains(trimmed, "---") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inTable && trimmed != "" {
|
||||||
|
fields := strings.Fields(trimmed)
|
||||||
|
if len(fields) >= 3 {
|
||||||
|
pkg := PackageInfo{
|
||||||
|
Name: fields[0],
|
||||||
|
Version: fields[len(fields)-1],
|
||||||
|
Manager: "winget",
|
||||||
|
}
|
||||||
|
packages = append(packages, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallPackage(name string) (string, error) {
|
||||||
|
return installPackageWithManager(name, DetectManager(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallPackageWithManager(name, manager, version string) (string, error) {
|
||||||
|
return installPackageWithManager(name, manager, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPackageWithManager(name, manager, version string) (string, error) {
|
||||||
|
var executable string
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
switch manager {
|
||||||
|
case "apt":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"apt-get", "install", "-y", name}
|
||||||
|
case "yum":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"yum", "install", "-y", name}
|
||||||
|
case "dnf":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"dnf", "install", "-y", name}
|
||||||
|
case "winget":
|
||||||
|
executable = "winget"
|
||||||
|
args = []string{"install", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
|
||||||
|
if version != "" {
|
||||||
|
args = append(args, "--version", version)
|
||||||
|
}
|
||||||
|
case "choco":
|
||||||
|
executable = "choco"
|
||||||
|
args = []string{"install", "-y", name}
|
||||||
|
if version != "" {
|
||||||
|
args = append(args, "--version", version)
|
||||||
|
}
|
||||||
|
case "msiexec":
|
||||||
|
executable = "msiexec"
|
||||||
|
args = []string{"/i", name, "/quiet", "/norestart"}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported package manager: %s", manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := runManagerCommand(executable, args)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallPackageFromFile(filePath string) (string, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
switch ext {
|
||||||
|
case ".msi":
|
||||||
|
cmd := exec.Command("msiexec", "/i", filePath, "/quiet", "/norestart")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("msi install failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
case ".exe":
|
||||||
|
cmd := exec.Command(filePath, "/S")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("exe install failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
case ".msix", ".appx":
|
||||||
|
cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Add-AppxPackage -Path '%s'", filePath))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("msix install failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
case "linux":
|
||||||
|
switch ext {
|
||||||
|
case ".deb":
|
||||||
|
cmd := exec.Command("sudo", "dpkg", "-i", filePath)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
cmd2 := exec.Command("sudo", "apt-get", "install", "-f", "-y")
|
||||||
|
out2, _ := cmd2.CombinedOutput()
|
||||||
|
return string(out) + "\n" + string(out2), fmt.Errorf("dpkg install failed: %w", err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
case ".rpm":
|
||||||
|
cmd := exec.Command("sudo", "rpm", "-i", filePath)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("rpm install failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
switch ext {
|
||||||
|
case ".dmg":
|
||||||
|
cmd := exec.Command("hdiutil", "attach", filePath, "-nobrowse", "-quiet")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("dmg mount failed: %w", err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
case ".pkg":
|
||||||
|
cmd := exec.Command("sudo", "installer", "-pkg", filePath, "-target", "/")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("pkg install failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file type %s for %s", ext, runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePackage(name string) (string, error) {
|
||||||
|
manager := DetectManager()
|
||||||
|
var executable string
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
switch manager {
|
||||||
|
case "apt":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"apt-get", "install", "-y", "--only-upgrade", name}
|
||||||
|
case "yum":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"yum", "update", "-y", name}
|
||||||
|
case "dnf":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"dnf", "update", "-y", name}
|
||||||
|
case "winget":
|
||||||
|
executable = "winget"
|
||||||
|
args = []string{"upgrade", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
|
||||||
|
case "choco":
|
||||||
|
executable = "choco"
|
||||||
|
args = []string{"upgrade", "-y", name}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported package manager: %s", manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := runManagerCommand(executable, args)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemovePackage(name string) (string, error) {
|
||||||
|
return removePackageWithManager(name, DetectManager())
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePackageWithManager(name, manager string) (string, error) {
|
||||||
|
var executable string
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
switch manager {
|
||||||
|
case "apt":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"apt-get", "remove", "-y", name}
|
||||||
|
case "yum":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"yum", "remove", "-y", name}
|
||||||
|
case "dnf":
|
||||||
|
executable = "sudo"
|
||||||
|
args = []string{"dnf", "remove", "-y", name}
|
||||||
|
case "winget":
|
||||||
|
executable = "winget"
|
||||||
|
args = []string{"uninstall", "--id", name, "--accept-source-agreements", "--disable-interactivity"}
|
||||||
|
case "choco":
|
||||||
|
executable = "choco"
|
||||||
|
args = []string{"uninstall", "-y", name}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported package manager: %s", manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := runManagerCommand(executable, args)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemovePackageWithManager(name, manager string) (string, error) {
|
||||||
|
return removePackageWithManager(name, manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadFile(url string, destPath string) error {
|
||||||
|
dir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if _, err := exec.LookPath("curl"); err == nil {
|
||||||
|
cmd = exec.Command("curl", "-L", "-o", destPath, url)
|
||||||
|
} else if _, err := exec.LookPath("wget"); err == nil {
|
||||||
|
cmd = exec.Command("wget", "-O", destPath, url)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("neither curl nor wget found")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package pkgmanager
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// BootstrapManagers is a no-op on non-Windows platforms.
|
||||||
|
// On Windows it installs the Microsoft App Installer (winget) and
|
||||||
|
// Chocolatey if they are not already present.
|
||||||
|
func BootstrapManagers() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listWindowsPackages is only implemented on Windows.
|
||||||
|
func listWindowsPackages() ([]PackageInfo, error) {
|
||||||
|
return nil, fmt.Errorf("windows package listing only available on Windows")
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package pkgmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listWindowsPackages() ([]PackageInfo, error) {
|
||||||
|
seen := make(map[string]PackageInfo)
|
||||||
|
|
||||||
|
if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
|
||||||
|
return nil, fmt.Errorf("enumerate current user uninstall failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
|
||||||
|
// non-fatal; 32-bit view may not exist
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
if err := enumerateRegistryUninstall(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
|
||||||
|
if wingetPkgs, err := listWingetPackages(); err == nil {
|
||||||
|
for _, p := range wingetPkgs {
|
||||||
|
key := p.Name + "@" + p.Manager
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
seen[key] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make([]PackageInfo, 0, len(seen))
|
||||||
|
for _, p := range seen {
|
||||||
|
packages = append(packages, p)
|
||||||
|
}
|
||||||
|
return packages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumerateRegistryUninstall(root registry.Key, path string, seen map[string]PackageInfo) error {
|
||||||
|
k, err := registry.OpenKey(root, path, registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
|
||||||
|
subKeys, err := k.ReadSubKeyNames(0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range subKeys {
|
||||||
|
sk, err := registry.OpenKey(k, name, registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displayName, _, err := sk.GetStringValue("DisplayName")
|
||||||
|
if err != nil || displayName == "" {
|
||||||
|
sk.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displayVersion, _, _ := sk.GetStringValue("DisplayVersion")
|
||||||
|
version := strings.TrimSpace(displayVersion)
|
||||||
|
if version == "" {
|
||||||
|
version = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
key := displayName + "@windows"
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
seen[key] = PackageInfo{
|
||||||
|
Name: displayName,
|
||||||
|
Version: version,
|
||||||
|
Manager: "windows",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sk.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BootstrapManagers() error {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if !lookupWinget() {
|
||||||
|
if err := installWingetViaAppInstaller(); err != nil {
|
||||||
|
errs = append(errs, "winget: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("choco"); err != nil {
|
||||||
|
if err := installChocolatey(); err != nil {
|
||||||
|
errs = append(errs, "chocolatey: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("bootstrap failed: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installChocolatey() error {
|
||||||
|
// Official Chocolatey install script (requires admin; agent runs as LocalSystem).
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
||||||
|
"Set-ExecutionPolicy Bypass -Scope Process -Force; "+
|
||||||
|
"[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; "+
|
||||||
|
"iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("chocolatey install script failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupWinget() bool {
|
||||||
|
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
appInstaller := `Microsoft.DesktopAppInstaller_8wekyb3d8bbwe`
|
||||||
|
appxReg, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore`,
|
||||||
|
registry.READ|registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err == nil {
|
||||||
|
defer appxReg.Close()
|
||||||
|
if keys, err := appxReg.ReadSubKeyNames(0); err == nil {
|
||||||
|
for _, keyName := range keys {
|
||||||
|
if strings.Contains(keyName, "DesktopAppInstaller") {
|
||||||
|
_ = appInstaller
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func installWingetViaAppInstaller() error {
|
||||||
|
// Microsoft App Installer download link (redirects to latest winget bundle)
|
||||||
|
installerURL := "https://aka.ms/getwinget"
|
||||||
|
appInstallerPath := filepath.Join(os.TempDir(), "DesktopAppInstaller.msixbundle")
|
||||||
|
|
||||||
|
// Download App Installer bundle
|
||||||
|
downloadCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
"[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; "+
|
||||||
|
"Invoke-WebRequest -Uri '"+installerURL+"' -OutFile '"+appInstallerPath+"' -UseBasicParsing")
|
||||||
|
if out, err := downloadCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("download App Installer failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install bundle (dependencies may need to be allowed; add-AppxPackage on msixbundle handles it)
|
||||||
|
installCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
"Add-AppxPackage -Path '"+appInstallerPath+"' -ForceUpdateFromAnyVersion")
|
||||||
|
if out, err := installCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("install App Installer failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package pkgmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveManagerPath(executable, profile string) string {
|
||||||
|
return executable
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactiveProfile() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func runManagerCommand(executable string, args []string) (string, error) {
|
||||||
|
cmd := exec.Command(executable, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LaunchInUserSession(exe string, args []string) (uint32, error) {
|
||||||
|
return 0, fmt.Errorf("LaunchInUserSession is not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunInUserSessionCapture(exe string, args []string) (string, int, error) {
|
||||||
|
return "", -1, fmt.Errorf("RunInUserSessionCapture is not supported on this platform")
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package pkgmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wingetPath returns a usable winget.exe for the given user profile, falling
|
||||||
|
// back to SYSTEM/global locations. winget is a UWP app execution alias that is
|
||||||
|
// commonly missing from the PATH of service accounts (e.g. LocalSystem).
|
||||||
|
func wingetPath(profile string) string {
|
||||||
|
if profile != "" {
|
||||||
|
if p := fileExists(filepath.Join(profile, "AppData", "Local", "Microsoft", "WindowsApps", "winget.exe")); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath("winget"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
for _, base := range []string{
|
||||||
|
os.ExpandEnv(`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`),
|
||||||
|
os.ExpandEnv(`%SystemRoot%\System32\config\systemprofile\AppData\Local\Microsoft\WindowsApps\winget.exe`),
|
||||||
|
} {
|
||||||
|
if p := fileExists(base); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(os.ExpandEnv(`%ProgramFiles%`), "WindowsApps", "Microsoft.DesktopAppInstaller_*", "winget.exe"))
|
||||||
|
if len(matches) > 0 {
|
||||||
|
sort.Strings(matches)
|
||||||
|
return matches[len(matches)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func chocoPath() string {
|
||||||
|
if p, err := exec.LookPath("choco"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if p := fileExists(filepath.Join(os.ExpandEnv(`%ProgramData%`), "chocolatey", "bin", "choco.exe")); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveManagerPath(executable, profile string) string {
|
||||||
|
switch executable {
|
||||||
|
case "winget":
|
||||||
|
if p := wingetPath(profile); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
case "choco":
|
||||||
|
if profile != "" {
|
||||||
|
if p := fileExists(filepath.Join(profile, "AppData", "Local", "chocolatey", "bin", "choco.exe")); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := chocoPath(); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// interactiveProfile returns the profile directory of the logged-on user, or ""
|
||||||
|
// when no interactive session exists.
|
||||||
|
func interactiveProfile() string {
|
||||||
|
token, profile, err := interactiveUser()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
token.Close()
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// runManagerCommand runs a package manager command. winget and choco runs are
|
||||||
|
// executed in the context of the interactive (logged-on) user so that the winget
|
||||||
|
// app execution alias resolves and per-user installers land in the user's own
|
||||||
|
// profile (and therefore are visible to them). If the user context lacks
|
||||||
|
// elevation, the command is retried in the SYSTEM context.
|
||||||
|
func runManagerCommand(executable string, args []string) (string, error) {
|
||||||
|
switch executable {
|
||||||
|
case "choco", "winget":
|
||||||
|
token, profile, err := interactiveUser()
|
||||||
|
if err == nil {
|
||||||
|
defer token.Close()
|
||||||
|
exe := resolveManagerPath(executable, profile)
|
||||||
|
if exe == "" {
|
||||||
|
return "", fmt.Errorf("%s not found in user context or system locations", executable)
|
||||||
|
}
|
||||||
|
out, code, spawnErr := runAsUser(token, profile, exe, args)
|
||||||
|
if spawnErr == nil {
|
||||||
|
if code == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if !needsElevation(out) {
|
||||||
|
return out, fmt.Errorf("%s failed with exit code %d\nOutput: %s", executable, code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// user context failed to launch or needs elevation: retry as SYSTEM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exe := resolveManagerPath(executable, "")
|
||||||
|
if exe == "" {
|
||||||
|
return "", fmt.Errorf("%s not found in any known location", executable)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(exe, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interactiveUser returns a duplicated primary token for the logged-on user and
|
||||||
|
// their profile directory. It fails when no interactive session exists.
|
||||||
|
func interactiveUser() (windows.Token, string, error) {
|
||||||
|
var sessionID uint32
|
||||||
|
var count uint32
|
||||||
|
var sessions *windows.WTS_SESSION_INFO
|
||||||
|
if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil {
|
||||||
|
sessionID = windows.WTSGetActiveConsoleSessionId()
|
||||||
|
} else {
|
||||||
|
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions)))
|
||||||
|
info := unsafe.Slice(sessions, count)
|
||||||
|
for _, s := range info {
|
||||||
|
if s.SessionID != 0 && (s.State == windows.WTSActive || s.State == windows.WTSConnected) {
|
||||||
|
sessionID = s.SessionID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sessionID == 0 {
|
||||||
|
return 0, "", fmt.Errorf("no interactive session")
|
||||||
|
}
|
||||||
|
|
||||||
|
var token windows.Token
|
||||||
|
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
||||||
|
return 0, "", fmt.Errorf("WTSQueryUserToken: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dup windows.Token
|
||||||
|
if err := windows.DuplicateTokenEx(token, windows.TOKEN_ALL_ACCESS, nil, windows.SecurityImpersonation, windows.TokenPrimary, &dup); err != nil {
|
||||||
|
token.Close()
|
||||||
|
return 0, "", fmt.Errorf("DuplicateTokenEx: %w", err)
|
||||||
|
}
|
||||||
|
token.Close()
|
||||||
|
|
||||||
|
profile, err := userProfileDir(dup)
|
||||||
|
if err != nil {
|
||||||
|
dup.Close()
|
||||||
|
return 0, "", fmt.Errorf("GetUserProfileDirectory: %w", err)
|
||||||
|
}
|
||||||
|
return dup, profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userProfileDir(token windows.Token) (string, error) {
|
||||||
|
buf := make([]uint16, 1024)
|
||||||
|
for {
|
||||||
|
size := uint32(len(buf))
|
||||||
|
err := windows.GetUserProfileDirectory(token, &buf[0], &size)
|
||||||
|
if err == nil {
|
||||||
|
return windows.UTF16ToString(buf), nil
|
||||||
|
}
|
||||||
|
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
buf = make([]uint16, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAsUser runs executable in the given user's context, redirecting stdout and
|
||||||
|
// stderr to a temp file via inherited handles. Returns combined output and the
|
||||||
|
// process exit code.
|
||||||
|
func runAsUser(token windows.Token, profile, exe string, args []string) (string, int, error) {
|
||||||
|
outFile, err := os.CreateTemp("", "ff-agent-out-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, fmt.Errorf("CreateTemp: %w", err)
|
||||||
|
}
|
||||||
|
outPath := outFile.Name()
|
||||||
|
outFile.Close()
|
||||||
|
defer os.Remove(outPath)
|
||||||
|
|
||||||
|
var sa windows.SecurityAttributes
|
||||||
|
sa.Length = uint32(unsafe.Sizeof(sa))
|
||||||
|
sa.InheritHandle = 1
|
||||||
|
|
||||||
|
shareMode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE)
|
||||||
|
outHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(outPath), windows.GENERIC_WRITE, shareMode, &sa, windows.CREATE_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, fmt.Errorf("CreateFile output: %w", err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(outHandle)
|
||||||
|
|
||||||
|
nulHandle, err := windows.CreateFile(windows.StringToUTF16Ptr("NUL"), windows.GENERIC_READ, shareMode, &sa, windows.OPEN_EXISTING, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, fmt.Errorf("CreateFile NUL: %w", err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(nulHandle)
|
||||||
|
|
||||||
|
commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args))
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, fmt.Errorf("UTF16PtrFromString: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var envBlock *uint16
|
||||||
|
if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil {
|
||||||
|
envBlock = nil
|
||||||
|
} else {
|
||||||
|
defer windows.DestroyEnvironmentBlock(envBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, _ := windows.UTF16PtrFromString(profile)
|
||||||
|
if cwd == nil || *cwd == 0 {
|
||||||
|
cwd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var si windows.StartupInfo
|
||||||
|
si.Cb = uint32(unsafe.Sizeof(si))
|
||||||
|
si.Flags = windows.STARTF_USESTDHANDLES
|
||||||
|
si.StdInput = nulHandle
|
||||||
|
si.StdOutput = outHandle
|
||||||
|
si.StdErr = outHandle
|
||||||
|
|
||||||
|
var pi windows.ProcessInformation
|
||||||
|
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
|
||||||
|
err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, true, flags, envBlock, cwd, &si, &pi)
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, fmt.Errorf("CreateProcessAsUser: %w", err)
|
||||||
|
}
|
||||||
|
windows.CloseHandle(pi.Thread)
|
||||||
|
defer windows.CloseHandle(pi.Process)
|
||||||
|
|
||||||
|
_, _ = windows.WaitForSingleObject(pi.Process, windows.INFINITE)
|
||||||
|
|
||||||
|
var exitCode uint32
|
||||||
|
_ = windows.GetExitCodeProcess(pi.Process, &exitCode)
|
||||||
|
|
||||||
|
out, _ := os.ReadFile(outPath)
|
||||||
|
return string(out), int(exitCode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsQuote(s string) string {
|
||||||
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunInUserSessionCapture runs an executable in the context of the logged-on
|
||||||
|
// interactive user and returns its combined output and exit code. It is used
|
||||||
|
// e.g. to write per-user registry settings for the VNC server.
|
||||||
|
func RunInUserSessionCapture(exe string, args []string) (string, int, error) {
|
||||||
|
token, profile, err := interactiveUser()
|
||||||
|
if err != nil {
|
||||||
|
return "", -1, err
|
||||||
|
}
|
||||||
|
defer token.Close()
|
||||||
|
return runAsUser(token, profile, exe, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchInUserSession launches an executable in the context of the logged-on
|
||||||
|
// interactive user without waiting for it to finish. It returns the process ID
|
||||||
|
// of the launched process, which can be used to terminate it later. This is
|
||||||
|
// used e.g. to start a VNC server inside the user's desktop session.
|
||||||
|
func LaunchInUserSession(exe string, args []string) (uint32, error) {
|
||||||
|
token, profile, err := interactiveUser()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer token.Close()
|
||||||
|
|
||||||
|
commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("UTF16PtrFromString: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var envBlock *uint16
|
||||||
|
if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil {
|
||||||
|
envBlock = nil
|
||||||
|
} else {
|
||||||
|
defer windows.DestroyEnvironmentBlock(envBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, _ := windows.UTF16PtrFromString(profile)
|
||||||
|
if cwd == nil || *cwd == 0 {
|
||||||
|
cwd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var si windows.StartupInfo
|
||||||
|
si.Cb = uint32(unsafe.Sizeof(si))
|
||||||
|
si.Flags = windows.STARTF_USESHOWWINDOW
|
||||||
|
si.ShowWindow = windows.SW_SHOWNORMAL
|
||||||
|
|
||||||
|
var pi windows.ProcessInformation
|
||||||
|
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
|
||||||
|
err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, false, flags, envBlock, cwd, &si, &pi)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("CreateProcessAsUser: %w", err)
|
||||||
|
}
|
||||||
|
windows.CloseHandle(pi.Thread)
|
||||||
|
windows.CloseHandle(pi.Process)
|
||||||
|
return pi.ProcessId, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsQuoteArgs(args []string) string {
|
||||||
|
out := make([]string, 0, len(args))
|
||||||
|
for _, a := range args {
|
||||||
|
out = append(out, windowsQuote(a))
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) string {
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsElevation(output string) bool {
|
||||||
|
low := strings.ToLower(output)
|
||||||
|
for _, s := range []string{
|
||||||
|
"elevat",
|
||||||
|
"administrator",
|
||||||
|
"requires authentication",
|
||||||
|
"access is denied",
|
||||||
|
"access denied",
|
||||||
|
"run as admin",
|
||||||
|
} {
|
||||||
|
if strings.Contains(low, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package svcmgr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListServices() ([]ServiceInfo, error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return listWindowsServices()
|
||||||
|
case "darwin":
|
||||||
|
return listDarwinServices()
|
||||||
|
default:
|
||||||
|
return listLinuxServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetServiceStatus(name string) (string, bool, error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return getWindowsServiceStatus(name)
|
||||||
|
case "darwin":
|
||||||
|
return getDarwinServiceStatus(name)
|
||||||
|
default:
|
||||||
|
return getLinuxServiceStatus(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartService(name string) (string, error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return startWindowsService(name)
|
||||||
|
case "darwin":
|
||||||
|
return startDarwinService(name)
|
||||||
|
default:
|
||||||
|
return startLinuxService(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StopService(name string) (string, error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return stopWindowsService(name)
|
||||||
|
case "darwin":
|
||||||
|
return stopDarwinService(name)
|
||||||
|
default:
|
||||||
|
return stopLinuxService(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RestartService(name string) (string, error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return restartWindowsService(name)
|
||||||
|
case "darwin":
|
||||||
|
return restartDarwinService(name)
|
||||||
|
default:
|
||||||
|
return restartLinuxService(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Reboot() error {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return exec.Command("shutdown", "/r", "/t", "0").Run()
|
||||||
|
case "darwin":
|
||||||
|
return exec.Command("sudo", "shutdown", "-r", "now").Run()
|
||||||
|
default:
|
||||||
|
return exec.Command("sudo", "reboot").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Shutdown() error {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return exec.Command("shutdown", "/s", "/t", "0").Run()
|
||||||
|
case "darwin":
|
||||||
|
return exec.Command("sudo", "shutdown", "-h", "now").Run()
|
||||||
|
default:
|
||||||
|
return exec.Command("sudo", "poweroff").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listWindowsServices() ([]ServiceInfo, error) {
|
||||||
|
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
"Get-Service -ErrorAction SilentlyContinue | ForEach-Object { '{0}`t{1}`t{2}' -f $_.Name, $_.Status, $_.StartType }").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Get-Service failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []ServiceInfo
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
fields := strings.Split(strings.TrimSpace(line), "\t")
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
services = append(services, ServiceInfo{
|
||||||
|
Name: fields[0],
|
||||||
|
Status: strings.ToLower(fields[1]),
|
||||||
|
Enabled: strings.EqualFold(fields[2], "Automatic"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWindowsServiceStatus(name string) (string, bool, error) {
|
||||||
|
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
fmt.Sprintf("$s = Get-Service -Name '%s' -ErrorAction Stop; '{0}`t{1}' -f $s.Status, $s.StartType", name)).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("Get-Service failed: %w", err)
|
||||||
|
}
|
||||||
|
fields := strings.Split(strings.TrimSpace(string(out)), "\t")
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", false, fmt.Errorf("unexpected Get-Service output: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return strings.ToLower(fields[0]), strings.EqualFold(fields[1], "Automatic"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startWindowsService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
fmt.Sprintf("Start-Service -Name '%s' -ErrorAction Stop", name))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "started", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopWindowsService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
fmt.Sprintf("Stop-Service -Name '%s' -ErrorAction Stop", name))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "stopped", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartWindowsService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
fmt.Sprintf("Restart-Service -Name '%s' -ErrorAction Stop", name))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return string(out), fmt.Errorf("restart failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "restarted", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDarwinServices() ([]ServiceInfo, error) {
|
||||||
|
out, err := exec.Command("launchctl", "list").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("launchctl list failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []ServiceInfo
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n")[1:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status := "unknown"
|
||||||
|
if fields[0] != "-" {
|
||||||
|
status = "running"
|
||||||
|
}
|
||||||
|
services = append(services, ServiceInfo{
|
||||||
|
Name: fields[2],
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDarwinServiceStatus(name string) (string, bool, error) {
|
||||||
|
out, err := exec.Command("launchctl", "list").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("launchctl list failed: %w", err)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n")[1:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 3 && fields[2] == name {
|
||||||
|
if fields[0] != "-" {
|
||||||
|
return "running", true, nil
|
||||||
|
}
|
||||||
|
return "inactive", false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "not_found", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDarwinService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("launchctl", "load", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", name))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "started", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopDarwinService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", name))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "stopped", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartDarwinService(name string) (string, error) {
|
||||||
|
stopDarwinService(name)
|
||||||
|
return startDarwinService(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listLinuxServices() ([]ServiceInfo, error) {
|
||||||
|
cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-pager", "--no-legend", "--plain", "--no-accept-unknown")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("systemctl list-units failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []ServiceInfo
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
svcName := strings.TrimSuffix(fields[0], ".service")
|
||||||
|
status := fields[2]
|
||||||
|
enabled := fields[3] == "loaded"
|
||||||
|
|
||||||
|
services = append(services, ServiceInfo{
|
||||||
|
Name: svcName,
|
||||||
|
Status: status,
|
||||||
|
Enabled: enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLinuxServiceStatus(name string) (string, bool, error) {
|
||||||
|
cmd := exec.Command("systemctl", "is-active", name)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
status := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
enabledCmd := exec.Command("systemctl", "is-enabled", name)
|
||||||
|
enabledOut, _ := enabledCmd.Output()
|
||||||
|
enabled := strings.TrimSpace(string(enabledOut)) == "enabled"
|
||||||
|
|
||||||
|
if err != nil && status != "inactive" {
|
||||||
|
return status, enabled, fmt.Errorf("systemctl status failed: %w", err)
|
||||||
|
}
|
||||||
|
return status, enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startLinuxService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("sudo", "systemctl", "start", name)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "started", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopLinuxService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("sudo", "systemctl", "stop", name)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "stopped", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartLinuxService(name string) (string, error) {
|
||||||
|
cmd := exec.Command("sudo", "systemctl", "restart", name)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("restart failed: %w\nOutput: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return "restarted", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tunnel struct {
|
||||||
|
wsURL string
|
||||||
|
port int
|
||||||
|
sessionID string
|
||||||
|
ws *websocket.Conn
|
||||||
|
tcp net.Conn
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a tunnel that pipes traffic between the Fleet Forge server
|
||||||
|
// (WebSocket) and the local VNC server (127.0.0.1:port). It does not connect
|
||||||
|
// until Run is called.
|
||||||
|
func New(serverURL, credential, sessionID string, vncPort int) *Tunnel {
|
||||||
|
wsURL := strings.Replace(serverURL, "https://", "wss://", 1)
|
||||||
|
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
|
||||||
|
wsURL = strings.TrimSuffix(wsURL, "/")
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("session_id", sessionID)
|
||||||
|
q.Set("token", credential)
|
||||||
|
|
||||||
|
full := wsURL + "/api/v1/remote/agent?" + q.Encode()
|
||||||
|
|
||||||
|
return &Tunnel{
|
||||||
|
wsURL: full,
|
||||||
|
port: vncPort,
|
||||||
|
sessionID: sessionID,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run dials the server WebSocket and the local VNC port, then pipes bytes in
|
||||||
|
// both directions until either side closes or Stop is called.
|
||||||
|
func (t *Tunnel) Run() error {
|
||||||
|
ws, _, err := websocket.DefaultDialer.Dial(t.wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tunnel dial (server): %w", err)
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
t.ws = ws
|
||||||
|
|
||||||
|
tcp, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", t.port), 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tunnel dial (local VNC): %w", err)
|
||||||
|
}
|
||||||
|
defer tcp.Close()
|
||||||
|
t.tcp = tcp
|
||||||
|
|
||||||
|
log.Printf("[tunnel] connected for session %s (vnc 127.0.0.1:%d)", t.sessionID, t.port)
|
||||||
|
|
||||||
|
done := make(chan error, 2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, data, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tcp.Write(data); err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
for {
|
||||||
|
n, err := tcp.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-t.stop:
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the underlying connections, causing Run to return.
|
||||||
|
func (t *Tunnel) Stop() {
|
||||||
|
t.once.Do(func() {
|
||||||
|
close(t.stop)
|
||||||
|
if t.ws != nil {
|
||||||
|
_ = t.ws.Close()
|
||||||
|
}
|
||||||
|
if t.tcp != nil {
|
||||||
|
_ = t.tcp.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package vnc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/des"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fleetforge/forge-agent/internal/pkgmanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
const waitForPortTimeout = 25 * time.Second
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
port int
|
||||||
|
pid int
|
||||||
|
passFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure starts a VNC server on the given port with the given password and
|
||||||
|
// returns a handle that can be used to stop it again.
|
||||||
|
func Ensure(port int, password string) (*Server, error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return startTightVNC(port, password)
|
||||||
|
case "linux":
|
||||||
|
return startX11VNC(port, password)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("remote desktop is not supported on %s yet", runtime.GOOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
stopTightVNC(s)
|
||||||
|
default:
|
||||||
|
stopX11VNC(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Port() int {
|
||||||
|
return s.port
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Windows
|
||||||
|
|
||||||
|
func tightVNCPath() string {
|
||||||
|
for _, base := range []string{
|
||||||
|
os.ExpandEnv(`%ProgramFiles%\TightVNC\tvnserver.exe`),
|
||||||
|
os.ExpandEnv(`%ProgramFiles(x86)%\TightVNC\tvnserver.exe`),
|
||||||
|
`C:\Program Files\TightVNC\tvnserver.exe`,
|
||||||
|
`C:\Program Files (x86)\TightVNC\tvnserver.exe`,
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(base); err == nil {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickRegAdd(subkey, name, value, regType string) error {
|
||||||
|
cmd := exec.Command("reg", "add", subkey, "/v", name, "/t", regType, "/d", value, "/f")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("reg add %s: %w (%s)", name, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configTightVNC writes the TightVNC server settings to both the global (HKLM)
|
||||||
|
// and the current user (HKCU) registry locations. Application mode reads the
|
||||||
|
// user settings, while the (optional) service mode reads the global ones, so we
|
||||||
|
// configure both. HKCU is written as the interactive user because reg.exe under
|
||||||
|
// the SYSTEM account would target the system profile instead.
|
||||||
|
func configTightVNC(port int, enc []byte) error {
|
||||||
|
type entry struct {
|
||||||
|
name string
|
||||||
|
val string
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
entries := []entry{
|
||||||
|
{"RfbPort", strconv.Itoa(port), "REG_DWORD"},
|
||||||
|
{"UsePasswordAuthentication", "1", "REG_DWORD"},
|
||||||
|
{"AcceptLoopbackConnections", "1", "REG_DWORD"},
|
||||||
|
{"Password", hex.EncodeToString(enc), "REG_BINARY"},
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalKey = `HKLM\Software\TightVNC\Server`
|
||||||
|
const userKey = `HKCU\Software\TightVNC\Server`
|
||||||
|
|
||||||
|
write := func(subkey string, inUserSession bool) error {
|
||||||
|
for _, e := range entries {
|
||||||
|
args := []string{"add", subkey, "/v", e.name, "/t", e.typ, "/d", e.val, "/f"}
|
||||||
|
var err error
|
||||||
|
if inUserSession {
|
||||||
|
_, code, uerr := pkgmanager.RunInUserSessionCapture("reg", args)
|
||||||
|
if uerr != nil {
|
||||||
|
err = uerr
|
||||||
|
} else if code != 0 {
|
||||||
|
err = fmt.Errorf("reg add %s (as user) exited %d", e.name, code)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = quickRegAdd(subkey, e.name, e.val, e.typ)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set %s\\%s: %w", subkey, e.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := write(globalKey, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := write(userKey, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTightVNC(port int, password string) (*Server, error) {
|
||||||
|
exe := tightVNCPath()
|
||||||
|
if exe == "" {
|
||||||
|
if _, err := pkgmanager.InstallPackageWithManager("TightVNC.TightVNC", "winget", ""); err != nil {
|
||||||
|
if _, ferr := pkgmanager.InstallPackageWithManager("tightvnc", "choco", ""); ferr != nil {
|
||||||
|
return nil, fmt.Errorf("tightvnc not installed and automatic install failed: %v (fallback %v)", err, ferr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exe = tightVNCPath()
|
||||||
|
if exe == "" {
|
||||||
|
return nil, fmt.Errorf("failed to locate tvnserver.exe after installation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best effort: stop any TightVNC service so it does not fight on the port.
|
||||||
|
stopTightVNCServices()
|
||||||
|
|
||||||
|
enc, err := encryptPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vnc password encryption failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := configTightVNC(port, enc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start in the interactive user's session so the real desktop is served.
|
||||||
|
pid, err := pkgmanager.LaunchInUserSession(exe, []string{"-run"})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start tvnserver in user session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &Server{port: port, pid: int(pid)}
|
||||||
|
if err := waitForPort(port); err != nil {
|
||||||
|
srv.Stop()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTightVNCServices() {
|
||||||
|
for _, name := range []string{"tvnserver", "TightVNC"} {
|
||||||
|
_ = exec.Command("sc", "stop", name).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTightVNC(s *Server) {
|
||||||
|
stopTightVNCServices()
|
||||||
|
if s.pid > 0 {
|
||||||
|
if proc, err := os.FindProcess(s.pid); err == nil {
|
||||||
|
_ = proc.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptPassword produces the DES-encrypted 8-byte form used by the VNC
|
||||||
|
// servers to store the password (fixed key with bit-reversed bytes).
|
||||||
|
func encryptPassword(password string) ([]byte, error) {
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
copy(buf, []byte(password))
|
||||||
|
|
||||||
|
key := []byte{0xe8, 0x4a, 0xd6, 0x60, 0xc4, 0x72, 0x1a, 0xe3}
|
||||||
|
for i := range key {
|
||||||
|
key[i] = bitReverse(key[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := des.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, 8)
|
||||||
|
block.Encrypt(out, buf)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bitReverse(b byte) byte {
|
||||||
|
var r byte
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
r = r<<1 | (b & 1)
|
||||||
|
b >>= 1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Linux
|
||||||
|
|
||||||
|
func startX11VNC(port int, password string) (*Server, error) {
|
||||||
|
exe, err := exec.LookPath("x11vnc")
|
||||||
|
if err != nil {
|
||||||
|
if _, ierr := pkgmanager.InstallPackage("x11vnc"); ierr == nil {
|
||||||
|
exe, err = exec.LookPath("x11vnc")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("x11vnc is not installed and could not be installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
display := os.Getenv("DISPLAY")
|
||||||
|
if display == "" {
|
||||||
|
display = ":0"
|
||||||
|
}
|
||||||
|
|
||||||
|
passFile := filepath.Join(os.TempDir(), fmt.Sprintf("ff-vnc-pass-%d.txt", time.Now().UnixNano()))
|
||||||
|
if out, err := exec.Command(exe, "-storepasswd", password, passFile).CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("x11vnc -storepasswd failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
exe,
|
||||||
|
"-display", display,
|
||||||
|
"-rfbport", strconv.Itoa(port),
|
||||||
|
"-rfbauth", passFile,
|
||||||
|
"-forever",
|
||||||
|
"-shared",
|
||||||
|
)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
os.Remove(passFile)
|
||||||
|
return nil, fmt.Errorf("failed to start x11vnc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &Server{port: port, pid: cmd.Process.Pid}
|
||||||
|
srv.passFile = passFile
|
||||||
|
if err := waitForPort(port); err != nil {
|
||||||
|
srv.Stop()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopX11VNC(s *Server) {
|
||||||
|
if s.pid > 0 {
|
||||||
|
if proc, err := os.FindProcess(s.pid); err == nil {
|
||||||
|
_ = proc.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.passFile != "" {
|
||||||
|
os.Remove(s.passFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPort(port int) error {
|
||||||
|
deadline := time.Now().Add(waitForPortTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 2*time.Second)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("VNC server did not start listening on port %d", port)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fleetforge/forge-agent/internal/config"
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
"golang.org/x/sys/windows/svc/eventlog"
|
||||||
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InstallService(serverURL, enrollmentToken, label string) error {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
exePath, err = filepath.EvalSymlinks(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
EnrollmentToken: enrollmentToken,
|
||||||
|
Label: label,
|
||||||
|
HeartbeatSec: 30,
|
||||||
|
}
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Config saved to: %s\n", config.ConfigPath())
|
||||||
|
|
||||||
|
scm, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to Service Control Manager: %w", err)
|
||||||
|
}
|
||||||
|
defer scm.Disconnect()
|
||||||
|
|
||||||
|
existing, err := scm.OpenService(ServiceName)
|
||||||
|
if err == nil {
|
||||||
|
existing.Close()
|
||||||
|
return fmt.Errorf("service already exists: %s", ServiceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := scm.CreateService(ServiceName, exePath, mgr.Config{
|
||||||
|
DisplayName: ServiceDisplayName,
|
||||||
|
Description: ServiceDescription,
|
||||||
|
StartType: mgr.StartAutomatic,
|
||||||
|
ErrorControl: mgr.ErrorNormal,
|
||||||
|
}, "run", "--service")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
if err := eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil {
|
||||||
|
s.Delete()
|
||||||
|
return fmt.Errorf("failed to install event log source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Service '%s' installed successfully.\n", ServiceDisplayName)
|
||||||
|
fmt.Println("The service will start automatically on system boot.")
|
||||||
|
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
fmt.Printf("Warning: Could not start service: %v\n", err)
|
||||||
|
} else {
|
||||||
|
waitUntilRunning(s)
|
||||||
|
fmt.Println("Service started.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UninstallService() error {
|
||||||
|
scm, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to Service Control Manager: %w", err)
|
||||||
|
}
|
||||||
|
defer scm.Disconnect()
|
||||||
|
|
||||||
|
s, err := scm.OpenService(ServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("service does not exist: %s", ServiceName)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
st, err := s.Control(svc.Stop)
|
||||||
|
if err == nil {
|
||||||
|
waitUntilStopped(s, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := eventlog.Remove(ServiceName); err != nil {
|
||||||
|
fmt.Printf("Warning: Could not remove event log source: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Delete(); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Service '%s' removed successfully.\n", ServiceDisplayName)
|
||||||
|
|
||||||
|
if err := config.Delete(); err != nil && !os.IsNotExist(err) {
|
||||||
|
fmt.Printf("Warning: Could not remove config file: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Config file removed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilStopped(s *mgr.Service, initialStatus svc.Status) {
|
||||||
|
state := initialStatus.State
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if state == svc.Stopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
st, err := s.Query()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = st.State
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilRunning(s *mgr.Service) {
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
st, err := s.Query()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.State == svc.Running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fleetforge/forge-agent/internal/agent"
|
||||||
|
"github.com/fleetforge/forge-agent/internal/config"
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
"golang.org/x/sys/windows/svc/eventlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ServiceName = "FleetForgeAgent"
|
||||||
|
const ServiceDisplayName = "Fleet Forge Agent"
|
||||||
|
const ServiceDescription = "Fleet Forge endpoint management agent."
|
||||||
|
|
||||||
|
type agentService struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
|
||||||
|
status <- svc.Status{State: svc.StartPending, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
||||||
|
|
||||||
|
elog, err := eventlog.Open(ServiceName)
|
||||||
|
if err == nil {
|
||||||
|
defer elog.Close()
|
||||||
|
elog.Info(1, "Fleet Forge Agent service starting")
|
||||||
|
}
|
||||||
|
|
||||||
|
a := agent.New(s.cfg)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- a.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[service] Agent stopped with error: %v", err)
|
||||||
|
if elog != nil {
|
||||||
|
elog.Error(1, fmt.Sprintf("Agent stopped: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
|
||||||
|
case c := <-r:
|
||||||
|
switch c.Cmd {
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
log.Println("[service] Stop/Shutdown received")
|
||||||
|
status <- svc.Status{State: svc.StopPending}
|
||||||
|
return false, 0
|
||||||
|
case svc.Interrogate:
|
||||||
|
status <- c.CurrentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunAsService() error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcInst := &agentService{cfg: cfg}
|
||||||
|
return svc.Run(ServiceName, svcInst)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package winsvc
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func RunAsService() error {
|
||||||
|
return fmt.Errorf("windows service is not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallService(serverURL, enrollmentToken, label string) error {
|
||||||
|
return fmt.Errorf("windows service installation is not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func UninstallService() error {
|
||||||
|
return fmt.Errorf("windows service uninstallation is not supported on this platform")
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "../../infrastructure/migrations",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@fleetforge/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
|
"seed": "tsx src/db/seed.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fleetforge/shared": "workspace:*",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-orm": "^0.38.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"rate-limiter-flexible": "^5.0.4",
|
||||||
|
"uuid": "^11.0.0",
|
||||||
|
"ws": "^8.21.3",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getTestPool,
|
||||||
|
setupTestDb,
|
||||||
|
cleanTestDb,
|
||||||
|
teardownTestDb,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
let pool: pg.Pool;
|
||||||
|
let orgId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = await getTestPool();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
const seeded = await setupTestDb();
|
||||||
|
orgId = seeded.orgId;
|
||||||
|
userId = seeded.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return user for valid credentials", async () => {
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const passwordHash = await bcrypt.hash("testpass123", 10);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT id, email, role FROM users WHERE email = $1 AND is_active = true",
|
||||||
|
["admin@test.local"]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
expect(rows[0].email).toBe("admin@test.local");
|
||||||
|
expect(rows[0].role).toBe("owner");
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare("testpass123", passwordHash);
|
||||||
|
expect(valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid password", async () => {
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT password_hash FROM users WHERE email = $1",
|
||||||
|
["admin@test.local"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare("wrongpassword", rows[0].password_hash);
|
||||||
|
expect(valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject non-existent user", async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT id FROM users WHERE email = $1 AND is_active = true",
|
||||||
|
["nonexistent@test.local"]
|
||||||
|
);
|
||||||
|
expect(rows.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject inactive user", async () => {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE users SET is_active = false WHERE id = $1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT id FROM users WHERE email = $1 AND is_active = true",
|
||||||
|
["admin@test.local"]
|
||||||
|
);
|
||||||
|
expect(rows.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate and verify JWT token", async () => {
|
||||||
|
const jwt = await import("jsonwebtoken");
|
||||||
|
const token = jwt.default.sign(
|
||||||
|
{ userId, organizationId: orgId, role: "owner", email: "admin@test.local" },
|
||||||
|
process.env.JWT_SECRET || "test-secret",
|
||||||
|
{ expiresIn: "1h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const decoded = jwt.default.verify(token, process.env.JWT_SECRET || "test-secret") as any;
|
||||||
|
expect(decoded.userId).toBe(userId);
|
||||||
|
expect(decoded.organizationId).toBe(orgId);
|
||||||
|
expect(decoded.role).toBe("owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject expired JWT", async () => {
|
||||||
|
const jwt = await import("jsonwebtoken");
|
||||||
|
const token = jwt.default.sign(
|
||||||
|
{ userId, organizationId: orgId, role: "owner" },
|
||||||
|
process.env.JWT_SECRET || "test-secret",
|
||||||
|
{ expiresIn: "0s" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
jwt.default.verify(token, process.env.JWT_SECRET || "test-secret");
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getTestPool,
|
||||||
|
setupTestDb,
|
||||||
|
cleanTestDb,
|
||||||
|
teardownTestDb,
|
||||||
|
uuidv4,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
describe("Authorization & Roles", () => {
|
||||||
|
let pool: pg.Pool;
|
||||||
|
let orgId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = await getTestPool();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
const seeded = await setupTestDb();
|
||||||
|
orgId = seeded.orgId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<string, number> = {
|
||||||
|
owner: 4,
|
||||||
|
admin: 3,
|
||||||
|
operator: 2,
|
||||||
|
read_only: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should define correct role hierarchy", () => {
|
||||||
|
expect(ROLE_HIERARCHY.owner).toBeGreaterThan(ROLE_HIERARCHY.admin);
|
||||||
|
expect(ROLE_HIERARCHY.admin).toBeGreaterThan(ROLE_HIERARCHY.operator);
|
||||||
|
expect(ROLE_HIERARCHY.operator).toBeGreaterThan(ROLE_HIERARCHY.read_only);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create users with different roles", async () => {
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const hash = await bcrypt.hash("password123", 10);
|
||||||
|
|
||||||
|
const roles = ["admin", "operator", "read_only"];
|
||||||
|
for (const role of roles) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[orgId, `${role}@roles.test.local`, hash, `${role} User`, role]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT role FROM users WHERE organization_id = $1 ORDER BY role",
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(4);
|
||||||
|
const userRoles = rows.map((r: any) => r.role);
|
||||||
|
expect(userRoles).toContain("admin");
|
||||||
|
expect(userRoles).toContain("operator");
|
||||||
|
expect(userRoles).toContain("read_only");
|
||||||
|
expect(userRoles).toContain("owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enforce unique email per organization", async () => {
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const hash = await bcrypt.hash("password123", 10);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
|
||||||
|
VALUES ($1, 'unique@test.local', $2, 'User 1', 'admin')`,
|
||||||
|
[orgId, hash]
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pool.query(
|
||||||
|
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
|
||||||
|
VALUES ($1, 'unique@test.local', $2, 'User 2', 'operator')`,
|
||||||
|
[orgId, hash]
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should isolate organizations", async () => {
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const hash = await bcrypt.hash("password123", 10);
|
||||||
|
|
||||||
|
const otherOrgId = uuidv4();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Other Org', 'other')`,
|
||||||
|
[otherOrgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
|
||||||
|
VALUES ($1, 'user@org1.com', $2, 'Org1 User', 'admin')`,
|
||||||
|
[orgId, hash]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
|
||||||
|
VALUES ($1, 'user@org2.com', $2, 'Org2 User', 'admin')`,
|
||||||
|
[otherOrgId, hash]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: org1Users } = await pool.query(
|
||||||
|
"SELECT id FROM users WHERE organization_id = $1",
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
const { rows: org2Users } = await pool.query(
|
||||||
|
"SELECT id FROM users WHERE organization_id = $1",
|
||||||
|
[otherOrgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(org1Users.length).toBe(2);
|
||||||
|
expect(org2Users.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow deactivating users", async () => {
|
||||||
|
const userId = uuidv4();
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const hash = await bcrypt.hash("password123", 10);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (id, organization_id, email, password_hash, display_name, role, is_active)
|
||||||
|
VALUES ($1, $2, 'deactivate@test.local', $3, 'Deactivate Me', 'operator', true)`,
|
||||||
|
[userId, orgId, hash]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query("UPDATE users SET is_active = false WHERE id = $1", [userId]);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT is_active FROM users WHERE id = $1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
expect(rows[0].is_active).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getTestPool,
|
||||||
|
setupTestDb,
|
||||||
|
cleanTestDb,
|
||||||
|
teardownTestDb,
|
||||||
|
generateEnrollmentToken,
|
||||||
|
hashToken,
|
||||||
|
generateDeviceCredential,
|
||||||
|
hashCredential,
|
||||||
|
uuidv4,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
describe("Device Registration E2E", () => {
|
||||||
|
let pool: pg.Pool;
|
||||||
|
let orgId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = await getTestPool();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
const seeded = await setupTestDb();
|
||||||
|
orgId = seeded.orgId;
|
||||||
|
userId = seeded.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enroll a device via enrollment token", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: tokenRows } = await pool.query(
|
||||||
|
`SELECT * FROM enrollment_tokens
|
||||||
|
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
expect(tokenRows.length).toBe(1);
|
||||||
|
|
||||||
|
const deviceId = uuidv4();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status, last_heartbeat_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`,
|
||||||
|
[deviceId, orgId, "test-server-01", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0", "online"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawCred = generateDeviceCredential();
|
||||||
|
const credHash = hashCredential(rawCred);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO device_credentials (device_id, credential_type, credential_hash, expires_at)
|
||||||
|
VALUES ($1, 'device_key', $2, NOW() + INTERVAL '1 year')`,
|
||||||
|
[deviceId, credHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1",
|
||||||
|
[tokenRows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: devices } = await pool.query(
|
||||||
|
"SELECT * FROM devices WHERE id = $1",
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
expect(devices.length).toBe(1);
|
||||||
|
expect(devices[0].name).toBe("test-server-01");
|
||||||
|
expect(devices[0].status).toBe("online");
|
||||||
|
|
||||||
|
const { rows: creds } = await pool.query(
|
||||||
|
"SELECT * FROM device_credentials WHERE device_id = $1",
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
expect(creds.length).toBe(1);
|
||||||
|
expect(creds[0].credential_type).toBe("device_key");
|
||||||
|
|
||||||
|
const verified = await pool.query(
|
||||||
|
"SELECT * FROM device_credentials WHERE credential_hash = $1",
|
||||||
|
[credHash]
|
||||||
|
);
|
||||||
|
expect(verified.rows.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should record heartbeat and metrics", async () => {
|
||||||
|
const deviceId = uuidv4();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'online')`,
|
||||||
|
[deviceId, orgId, "hb-test", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0"]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO metric_snapshots (device_id, reported_at, cpu_usage_percent, memory_usage_percent, disk_usage_percent, uptime_seconds)
|
||||||
|
VALUES ($1, NOW(), 45, 67, 32, 86400)`,
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO inventory_snapshots (device_id, reported_at, packages, services, network_interfaces, platform_data)
|
||||||
|
VALUES ($1, NOW(), $2, $3, $4, $5)`,
|
||||||
|
[
|
||||||
|
deviceId,
|
||||||
|
JSON.stringify([{ name: "nginx", version: "1.18.0", manager: "apt" }]),
|
||||||
|
JSON.stringify([{ name: "nginx", status: "active", enabled: true }]),
|
||||||
|
JSON.stringify([{ name: "eth0", addresses: ["192.168.1.100"], mac_address: "00:11:22:33:44:55" }]),
|
||||||
|
JSON.stringify({}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE devices SET status = 'online', last_heartbeat_at = NOW() WHERE id = $1",
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: metrics } = await pool.query(
|
||||||
|
"SELECT * FROM metric_snapshots WHERE device_id = $1 ORDER BY reported_at DESC",
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
expect(metrics.length).toBe(1);
|
||||||
|
expect(metrics[0].cpu_usage_percent).toBe(45);
|
||||||
|
expect(metrics[0].memory_usage_percent).toBe(67);
|
||||||
|
|
||||||
|
const { rows: inventory } = await pool.query(
|
||||||
|
"SELECT * FROM inventory_snapshots WHERE device_id = $1",
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
expect(inventory.length).toBe(1);
|
||||||
|
expect(inventory[0].packages.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle full enrollment flow", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const { rows: tokenRows } = await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
const tokenId = tokenRows[0].id;
|
||||||
|
|
||||||
|
const { rows: foundToken } = await pool.query(
|
||||||
|
`SELECT * FROM enrollment_tokens
|
||||||
|
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
expect(foundToken.length).toBe(1);
|
||||||
|
|
||||||
|
const deviceId = uuidv4();
|
||||||
|
const rawCred = generateDeviceCredential();
|
||||||
|
const credHash = hashCredential(rawCred);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status, last_heartbeat_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'online', NOW())`,
|
||||||
|
[deviceId, orgId, "new-host", "linux_server", "linux", "Ubuntu", "24.04", "amd64", "0.1.0"]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO device_credentials (device_id, credential_type, credential_hash, expires_at)
|
||||||
|
VALUES ($1, 'device_key', $2, NOW() + INTERVAL '1 year')`,
|
||||||
|
[deviceId, credHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1",
|
||||||
|
[tokenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO metric_snapshots (device_id, reported_at, cpu_usage_percent, memory_usage_percent, disk_usage_percent, uptime_seconds)
|
||||||
|
VALUES ($1, NOW(), 10, 30, 25, 3600)`,
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: device } = await pool.query("SELECT * FROM devices WHERE id = $1", [deviceId]);
|
||||||
|
expect(device.length).toBe(1);
|
||||||
|
expect(device[0].organization_id).toBe(orgId);
|
||||||
|
|
||||||
|
const { rows: cred } = await pool.query(
|
||||||
|
"SELECT * FROM device_credentials WHERE credential_hash = $1",
|
||||||
|
[credHash]
|
||||||
|
);
|
||||||
|
expect(cred.length).toBe(1);
|
||||||
|
|
||||||
|
const { rows: metrics } = await pool.query(
|
||||||
|
"SELECT * FROM metric_snapshots WHERE device_id = $1",
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
expect(metrics.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getTestPool,
|
||||||
|
setupTestDb,
|
||||||
|
cleanTestDb,
|
||||||
|
teardownTestDb,
|
||||||
|
generateEnrollmentToken,
|
||||||
|
hashToken,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
describe("Enrollment Tokens", () => {
|
||||||
|
let pool: pg.Pool;
|
||||||
|
let orgId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = await getTestPool();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
const seeded = await setupTestDb();
|
||||||
|
orgId = seeded.orgId;
|
||||||
|
userId = seeded.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an enrollment token", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, max_uses, label, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, token_hash`,
|
||||||
|
[orgId, tokenHash, expiresAt, 1, "test-token", userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
expect(rows[0].token_hash).toBe(tokenHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate a valid enrollment token", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM enrollment_tokens
|
||||||
|
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject expired enrollment token", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() - 1000);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM enrollment_tokens
|
||||||
|
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject revoked enrollment token", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, is_revoked, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, true, $4)`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM enrollment_tokens
|
||||||
|
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enforce max_uses limit", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, max_uses, use_count, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, 2, 2, $4)`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM enrollment_tokens
|
||||||
|
WHERE token_hash = $1 AND is_revoked = false
|
||||||
|
AND (max_uses IS NULL OR use_count < max_uses)`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should revoke a token", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const { rows: inserted } = await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE enrollment_tokens SET is_revoked = true WHERE id = $1",
|
||||||
|
[inserted[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT is_revoked FROM enrollment_tokens WHERE id = $1",
|
||||||
|
[inserted[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows[0].is_revoked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should increment use_count on enrollment", async () => {
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const { rows: inserted } = await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, use_count, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, 0, $4) RETURNING id`,
|
||||||
|
[orgId, tokenHash, expiresAt, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1",
|
||||||
|
[inserted[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT use_count FROM enrollment_tokens WHERE id = $1",
|
||||||
|
[inserted[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows[0].use_count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config({ path: ".env.test" });
|
||||||
|
|
||||||
|
import pg from "pg";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { hashToken, generateEnrollmentToken, generateDeviceCredential, hashCredential } from "../lib/tokens.js";
|
||||||
|
|
||||||
|
const TEST_DB_URL = process.env.DATABASE_URL || "postgresql://fleetforge:fleetforge@localhost:5432/fleetforge_test?schema=public";
|
||||||
|
|
||||||
|
let pool: pg.Pool;
|
||||||
|
|
||||||
|
export async function getTestPool(): Promise<pg.Pool> {
|
||||||
|
if (!pool) {
|
||||||
|
pool = new pg.Pool({ connectionString: TEST_DB_URL });
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupTestDb(): Promise<{ orgId: string; userId: string }> {
|
||||||
|
const p = await getTestPool();
|
||||||
|
await cleanTestDb(p);
|
||||||
|
return await seedTestDb(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanTestDb(p: pg.Pool) {
|
||||||
|
const tables = [
|
||||||
|
"audit_events", "api_tokens", "alerts", "compliance_results",
|
||||||
|
"job_targets", "jobs", "policies", "metric_snapshots",
|
||||||
|
"inventory_snapshots", "device_credentials", "devices",
|
||||||
|
"device_groups", "enrollment_tokens", "users", "organizations",
|
||||||
|
];
|
||||||
|
for (const table of tables) {
|
||||||
|
await p.query(`TRUNCATE TABLE ${table} CASCADE`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedTestDb(p: pg.Pool): Promise<{ orgId: string; userId: string }> {
|
||||||
|
const orgId = uuidv4();
|
||||||
|
await p.query(
|
||||||
|
`INSERT INTO organizations (id, name, slug) VALUES ($1, $2, $3)`,
|
||||||
|
[orgId, "Test Org", "test-org"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash("testpass123", 10);
|
||||||
|
const userId = uuidv4();
|
||||||
|
await p.query(
|
||||||
|
`INSERT INTO users (id, organization_id, email, password_hash, display_name, role, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[userId, orgId, "admin@test.local", passwordHash, "Test Admin", "owner", true]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { orgId, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teardownTestDb() {
|
||||||
|
if (pool) {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
await pool.end();
|
||||||
|
pool = undefined as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hashToken, generateEnrollmentToken, generateDeviceCredential, hashCredential, uuidv4 };
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getTestPool,
|
||||||
|
setupTestDb,
|
||||||
|
cleanTestDb,
|
||||||
|
teardownTestDb,
|
||||||
|
uuidv4,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
describe("Jobs", () => {
|
||||||
|
let pool: pg.Pool;
|
||||||
|
let orgId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = await getTestPool();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
const seeded = await setupTestDb();
|
||||||
|
orgId = seeded.orgId;
|
||||||
|
userId = seeded.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a job with targets", async () => {
|
||||||
|
const deviceId = uuidv4();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[deviceId, orgId, "job-test", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0", "online"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: jobRows } = await pool.query(
|
||||||
|
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
|
||||||
|
VALUES ($1, $2, 'update_inventory', 'queued', '{}') RETURNING id`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
const jobId = jobRows[0].id;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'queued')`,
|
||||||
|
[jobId, deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: jobs } = await pool.query(
|
||||||
|
"SELECT * FROM jobs WHERE id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
expect(jobs.length).toBe(1);
|
||||||
|
expect(jobs[0].job_type).toBe("update_inventory");
|
||||||
|
expect(jobs[0].status).toBe("queued");
|
||||||
|
|
||||||
|
const { rows: targets } = await pool.query(
|
||||||
|
"SELECT * FROM job_targets WHERE job_id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
expect(targets.length).toBe(1);
|
||||||
|
expect(targets[0].device_id).toBe(deviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition job through states", async () => {
|
||||||
|
const { rows: jobRows } = await pool.query(
|
||||||
|
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
|
||||||
|
VALUES ($1, $2, 'update_system_info', 'queued', '{}') RETURNING id`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
const jobId = jobRows[0].id;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE jobs SET status = 'dispatched', dispatched_at = NOW() WHERE id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: step1 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
|
||||||
|
expect(step1[0].status).toBe("dispatched");
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE jobs SET status = 'running', started_at = NOW() WHERE id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: step2 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
|
||||||
|
expect(step2[0].status).toBe("running");
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE jobs SET status = 'succeeded', completed_at = NOW() WHERE id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: step3 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
|
||||||
|
expect(step3[0].status).toBe("succeeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cancel queued jobs", async () => {
|
||||||
|
const { rows: jobRows } = await pool.query(
|
||||||
|
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
|
||||||
|
VALUES ($1, $2, 'reboot_system', 'queued', '{}') RETURNING id`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
const jobId = jobRows[0].id;
|
||||||
|
|
||||||
|
const { rows: updated } = await pool.query(
|
||||||
|
`UPDATE jobs SET status = 'cancelled', completed_at = NOW()
|
||||||
|
WHERE id = $1 AND status = 'queued' RETURNING id`,
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
expect(updated.length).toBe(1);
|
||||||
|
|
||||||
|
const { rows: step } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
|
||||||
|
expect(step[0].status).toBe("cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not cancel running jobs", async () => {
|
||||||
|
const { rows: jobRows } = await pool.query(
|
||||||
|
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
|
||||||
|
VALUES ($1, $2, 'update_inventory', 'running', '{}') RETURNING id`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
const jobId = jobRows[0].id;
|
||||||
|
|
||||||
|
const { rows: updated } = await pool.query(
|
||||||
|
`UPDATE jobs SET status = 'cancelled', completed_at = NOW()
|
||||||
|
WHERE id = $1 AND status = 'queued' RETURNING id`,
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
expect(updated.length).toBe(0);
|
||||||
|
|
||||||
|
const { rows: step } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
|
||||||
|
expect(step[0].status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete job when all targets finish", async () => {
|
||||||
|
const deviceId1 = uuidv4();
|
||||||
|
const deviceId2 = uuidv4();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
|
||||||
|
VALUES ($1, $2, 'multi-1', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`,
|
||||||
|
[deviceId1, orgId]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
|
||||||
|
VALUES ($1, $2, 'multi-2', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`,
|
||||||
|
[deviceId2, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: jobRows } = await pool.query(
|
||||||
|
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
|
||||||
|
VALUES ($1, $2, 'update_inventory', 'dispatched', '{}') RETURNING id`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
const jobId = jobRows[0].id;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'dispatched'), ($1, $3, 'dispatched')`,
|
||||||
|
[jobId, deviceId1, deviceId2]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2",
|
||||||
|
[jobId, deviceId1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: partial } = await pool.query(
|
||||||
|
"SELECT status FROM jobs WHERE id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
expect(partial[0].status).toBe("dispatched");
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2",
|
||||||
|
[jobId, deviceId2]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: targets } = await pool.query(
|
||||||
|
"SELECT status FROM job_targets WHERE job_id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
const allDone = targets.every((t: any) => !["queued", "dispatched", "running"].includes(t.status));
|
||||||
|
expect(allDone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail job when any target fails", async () => {
|
||||||
|
const deviceId1 = uuidv4();
|
||||||
|
const deviceId2 = uuidv4();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
|
||||||
|
VALUES ($1, $2, 'fail-1', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online'),
|
||||||
|
($3, $2, 'fail-2', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`,
|
||||||
|
[deviceId1, orgId, deviceId2]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: jobRows } = await pool.query(
|
||||||
|
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
|
||||||
|
VALUES ($1, $2, 'reboot_system', 'dispatched', '{}') RETURNING id`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
const jobId = jobRows[0].id;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'dispatched'), ($1, $3, 'dispatched')`,
|
||||||
|
[jobId, deviceId1, deviceId2]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2",
|
||||||
|
[jobId, deviceId1]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE job_targets SET status = 'failed', completed_at = NOW(), result = '{"error": "timeout"}' WHERE job_id = $1 AND device_id = $2`,
|
||||||
|
[jobId, deviceId2]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: targets } = await pool.query(
|
||||||
|
"SELECT status FROM job_targets WHERE job_id = $1",
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
const anyFailed = targets.some((t: any) => t.status === "failed");
|
||||||
|
expect(anyFailed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
getTestPool,
|
||||||
|
cleanTestDb,
|
||||||
|
teardownTestDb,
|
||||||
|
uuidv4,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
describe("Multi-tenancy", () => {
|
||||||
|
let pool: pg.Pool;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = await getTestPool();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardownTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanTestDb(pool);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should isolate devices between organizations", async () => {
|
||||||
|
const org1 = uuidv4();
|
||||||
|
const org2 = uuidv4();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org 1', 'org1'), ($2, 'Org 2', 'org2')`,
|
||||||
|
[org1, org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
|
||||||
|
VALUES ($1, $2, 'org1-device', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online'),
|
||||||
|
($3, $4, 'org2-device', 'linux_server', 'linux', 'CentOS', '8', 'amd64', '0.1.0', 'online')`,
|
||||||
|
[uuidv4(), org1, uuidv4(), org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: org1Devices } = await pool.query(
|
||||||
|
"SELECT * FROM devices WHERE organization_id = $1",
|
||||||
|
[org1]
|
||||||
|
);
|
||||||
|
const { rows: org2Devices } = await pool.query(
|
||||||
|
"SELECT * FROM devices WHERE organization_id = $1",
|
||||||
|
[org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(org1Devices.length).toBe(1);
|
||||||
|
expect(org2Devices.length).toBe(1);
|
||||||
|
expect(org1Devices[0].name).toBe("org1-device");
|
||||||
|
expect(org2Devices[0].name).toBe("org2-device");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should isolate audit events between organizations", async () => {
|
||||||
|
const org1 = uuidv4();
|
||||||
|
const org2 = uuidv4();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org A', 'orga'), ($2, 'Org B', 'orgb')`,
|
||||||
|
[org1, org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO audit_events (organization_id, action, target_type, result, correlation_id)
|
||||||
|
VALUES ($1, 'test.action', 'test', 'success', 'corr-1'),
|
||||||
|
($2, 'test.action', 'test', 'success', 'corr-2')`,
|
||||||
|
[org1, org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: org1Events } = await pool.query(
|
||||||
|
"SELECT * FROM audit_events WHERE organization_id = $1",
|
||||||
|
[org1]
|
||||||
|
);
|
||||||
|
const { rows: org2Events } = await pool.query(
|
||||||
|
"SELECT * FROM audit_events WHERE organization_id = $1",
|
||||||
|
[org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(org1Events.length).toBe(1);
|
||||||
|
expect(org2Events.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should isolate enrollment tokens between organizations", async () => {
|
||||||
|
const org1 = uuidv4();
|
||||||
|
const org2 = uuidv4();
|
||||||
|
const userId = uuidv4();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org X', 'orgx'), ($2, 'Org Y', 'orgy')`,
|
||||||
|
[org1, org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bcrypt = await import("bcrypt");
|
||||||
|
const hash = await bcrypt.hash("pass", 10);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (id, organization_id, email, password_hash, display_name, role)
|
||||||
|
VALUES ($1, $2, 'admin@x.com', $3, 'Admin X', 'owner')`,
|
||||||
|
[userId, org1, hash]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, 'hash1', NOW() + INTERVAL '1 day', $3),
|
||||||
|
($2, 'hash2', NOW() + INTERVAL '1 day', $3)`,
|
||||||
|
[org1, org2, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: org1Tokens } = await pool.query(
|
||||||
|
"SELECT * FROM enrollment_tokens WHERE organization_id = $1",
|
||||||
|
[org1]
|
||||||
|
);
|
||||||
|
const { rows: org2Tokens } = await pool.query(
|
||||||
|
"SELECT * FROM enrollment_tokens WHERE organization_id = $1",
|
||||||
|
[org2]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(org1Tokens.length).toBe(1);
|
||||||
|
expect(org2Tokens.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import pg from "pg";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(pool, { schema });
|
||||||
|
export { pool };
|
||||||
|
export function getPool() { return pool; }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = new pg.Client({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Checking if migrations table exists...");
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const migrationsDir = path.resolve(__dirname, "../../../../infrastructure/migrations");
|
||||||
|
const files = fs.readdirSync(migrationsDir)
|
||||||
|
.filter(f => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
"SELECT 1 FROM _migrations WHERE name = $1",
|
||||||
|
[file]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
console.log(`Migration ${file} already applied, skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Applying migration: ${file}`);
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
|
||||||
|
|
||||||
|
const statements = sql
|
||||||
|
.split("--> statement-breakpoint")
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(s => s.length > 0);
|
||||||
|
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await client.query(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO _migrations (name) VALUES ($1)",
|
||||||
|
[file]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Migration ${file} applied successfully.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All migrations complete.");
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Migration failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
real,
|
||||||
|
bigint,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const organizations = pgTable("organizations", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const users = pgTable(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||||
|
displayName: varchar("display_name", { length: 255 }).notNull(),
|
||||||
|
role: varchar("role", { length: 50 }).notNull().default("read_only"),
|
||||||
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
lastLoginAt: timestamp("last_login_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_users_email_org").on(table.email, table.organizationId),
|
||||||
|
index("idx_users_organization_id").on(table.organizationId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const enrollmentTokens = pgTable(
|
||||||
|
"enrollment_tokens",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
maxUses: integer("max_uses"),
|
||||||
|
useCount: integer("use_count").notNull().default(0),
|
||||||
|
isRevoked: boolean("is_revoked").notNull().default(false),
|
||||||
|
label: varchar("label", { length: 255 }),
|
||||||
|
createdByUserId: uuid("created_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_enrollment_tokens_org").on(table.organizationId),
|
||||||
|
index("idx_enrollment_tokens_hash").on(table.tokenHash),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deviceGroups = pgTable(
|
||||||
|
"device_groups",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_device_groups_name_org").on(
|
||||||
|
table.name,
|
||||||
|
table.organizationId
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const devices = pgTable(
|
||||||
|
"devices",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
deviceGroupId: uuid("device_group_id").references(() => deviceGroups.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
deviceType: varchar("device_type", { length: 50 }).notNull(),
|
||||||
|
platform: varchar("platform", { length: 50 }).notNull(),
|
||||||
|
osName: varchar("os_name", { length: 100 }).notNull(),
|
||||||
|
osVersion: varchar("os_version", { length: 100 }).notNull(),
|
||||||
|
architecture: varchar("architecture", { length: 50 }).notNull(),
|
||||||
|
kernelVersion: varchar("kernel_version", { length: 100 }),
|
||||||
|
cpuModel: varchar("cpu_model", { length: 255 }),
|
||||||
|
cpuCores: integer("cpu_cores"),
|
||||||
|
totalMemoryBytes: bigint("total_memory_bytes", { mode: "number" }),
|
||||||
|
totalDiskBytes: bigint("total_disk_bytes", { mode: "number" }),
|
||||||
|
ipAddress: varchar("ip_address", { length: 45 }),
|
||||||
|
agentVersion: varchar("agent_version", { length: 50 }).notNull(),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("offline"),
|
||||||
|
lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }),
|
||||||
|
enrolledAt: timestamp("enrolled_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
isDisabled: boolean("is_disabled").notNull().default(false),
|
||||||
|
isArchived: boolean("is_archived").notNull().default(false),
|
||||||
|
tags: jsonb("tags").$type<string[]>().default([]).notNull(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_devices_org").on(table.organizationId),
|
||||||
|
index("idx_devices_status").on(table.status),
|
||||||
|
index("idx_devices_group").on(table.deviceGroupId),
|
||||||
|
index("idx_devices_type").on(table.deviceType),
|
||||||
|
index("idx_devices_last_heartbeat").on(table.lastHeartbeatAt),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deviceCredentials = pgTable(
|
||||||
|
"device_credentials",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
credentialType: varchar("credential_type", { length: 50 }).notNull(),
|
||||||
|
credentialHash: varchar("credential_hash", { length: 255 }).notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_device_credentials_device").on(table.deviceId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const inventorySnapshots = pgTable(
|
||||||
|
"inventory_snapshots",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
reportedAt: timestamp("reported_at", { withTimezone: true }).notNull(),
|
||||||
|
packages: jsonb("packages").$type<unknown[]>().default([]).notNull(),
|
||||||
|
services: jsonb("services").$type<unknown[]>().default([]).notNull(),
|
||||||
|
networkInterfaces: jsonb("network_interfaces").$type<unknown[]>().default([]).notNull(),
|
||||||
|
platformData: jsonb("platform_data").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_inventory_device").on(table.deviceId),
|
||||||
|
index("idx_inventory_reported_at").on(table.reportedAt),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const metricSnapshots = pgTable(
|
||||||
|
"metric_snapshots",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
reportedAt: timestamp("reported_at", { withTimezone: true }).notNull(),
|
||||||
|
cpuUsagePercent: real("cpu_usage_percent"),
|
||||||
|
memoryUsagePercent: real("memory_usage_percent"),
|
||||||
|
memoryUsedBytes: bigint("memory_used_bytes", { mode: "number" }),
|
||||||
|
memoryTotalBytes: bigint("memory_total_bytes", { mode: "number" }),
|
||||||
|
diskUsagePercent: real("disk_usage_percent"),
|
||||||
|
diskUsedBytes: bigint("disk_used_bytes", { mode: "number" }),
|
||||||
|
diskTotalBytes: bigint("disk_total_bytes", { mode: "number" }),
|
||||||
|
loadAverage1m: real("load_average_1m"),
|
||||||
|
loadAverage5m: real("load_average_5m"),
|
||||||
|
loadAverage15m: real("load_average_15m"),
|
||||||
|
uptimeSeconds: bigint("uptime_seconds", { mode: "number" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_metrics_device").on(table.deviceId),
|
||||||
|
index("idx_metrics_reported_at").on(table.reportedAt),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const policies = pgTable(
|
||||||
|
"policies",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
rules: jsonb("rules").$type<unknown[]>().default([]).notNull(),
|
||||||
|
isEnabled: boolean("is_enabled").notNull().default(true),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_policies_org").on(table.organizationId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const complianceResults = pgTable(
|
||||||
|
"compliance_results",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
policyId: uuid("policy_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => policies.id, { onDelete: "cascade" }),
|
||||||
|
status: varchar("status", { length: 50 }).notNull(),
|
||||||
|
details: jsonb("details").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
evaluatedAt: timestamp("evaluated_at", { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_compliance_device").on(table.deviceId),
|
||||||
|
index("idx_compliance_policy").on(table.policyId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const jobs = pgTable(
|
||||||
|
"jobs",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
createdByUserId: uuid("created_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
jobType: varchar("job_type", { length: 100 }).notNull(),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("queued"),
|
||||||
|
parameters: jsonb("parameters").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_jobs_org").on(table.organizationId),
|
||||||
|
index("idx_jobs_status").on(table.status),
|
||||||
|
index("idx_jobs_type").on(table.jobType),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const jobTargets = pgTable(
|
||||||
|
"job_targets",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
jobId: uuid("job_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => jobs.id, { onDelete: "cascade" }),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("queued"),
|
||||||
|
result: jsonb("result").$type<Record<string, unknown>>(),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_job_targets_job").on(table.jobId),
|
||||||
|
index("idx_job_targets_device").on(table.deviceId),
|
||||||
|
index("idx_job_targets_status").on(table.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const remoteSessions = pgTable(
|
||||||
|
"remote_sessions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
createdByUserId: uuid("created_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
jobId: uuid("job_id").references(() => jobs.id),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("pending"),
|
||||||
|
vncPort: integer("vnc_port").notNull().default(5900),
|
||||||
|
vncPassword: varchar("vnc_password", { length: 64 }),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
endedAt: timestamp("ended_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_remote_sessions_org").on(table.organizationId),
|
||||||
|
index("idx_remote_sessions_device").on(table.deviceId),
|
||||||
|
index("idx_remote_sessions_status").on(table.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const alerts = pgTable(
|
||||||
|
"alerts",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
deviceId: uuid("device_id").references(() => devices.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
alertType: varchar("alert_type", { length: 100 }).notNull(),
|
||||||
|
severity: varchar("severity", { length: 20 }).notNull(),
|
||||||
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
isResolved: boolean("is_resolved").notNull().default(false),
|
||||||
|
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||||
|
resolvedByUserId: uuid("resolved_by_user_id"),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_alerts_org").on(table.organizationId),
|
||||||
|
index("idx_alerts_device").on(table.deviceId),
|
||||||
|
index("idx_alerts_resolved").on(table.isResolved),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const auditEvents = pgTable(
|
||||||
|
"audit_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
userId: uuid("user_id").references(() => users.id),
|
||||||
|
action: varchar("action", { length: 255 }).notNull(),
|
||||||
|
targetType: varchar("target_type", { length: 100 }).notNull(),
|
||||||
|
targetId: uuid("target_id"),
|
||||||
|
deviceIds: jsonb("device_ids").$type<string[]>().default([]).notNull(),
|
||||||
|
result: varchar("result", { length: 50 }).notNull(),
|
||||||
|
ipAddress: varchar("ip_address", { length: 45 }),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
correlationId: varchar("correlation_id", { length: 255 }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_audit_org").on(table.organizationId),
|
||||||
|
index("idx_audit_user").on(table.userId),
|
||||||
|
index("idx_audit_action").on(table.action),
|
||||||
|
index("idx_audit_created_at").on(table.createdAt),
|
||||||
|
index("idx_audit_correlation").on(table.correlationId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const apiTokens = pgTable(
|
||||||
|
"api_tokens",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_api_tokens_org").on(table.organizationId),
|
||||||
|
index("idx_api_tokens_hash").on(table.tokenHash),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateRequests = pgTable(
|
||||||
|
"update_requests",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
packageName: varchar("package_name", { length: 255 }).notNull(),
|
||||||
|
packageManager: varchar("package_manager", { length: 50 }).notNull().default("apt"),
|
||||||
|
currentVersion: varchar("current_version", { length: 100 }),
|
||||||
|
targetVersion: varchar("target_version", { length: 100 }).notNull(),
|
||||||
|
requestedByUserId: uuid("requested_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("pending"),
|
||||||
|
approvedByUserId: uuid("approved_by_user_id").references(() => users.id),
|
||||||
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||||
|
rejectionReason: text("rejection_reason"),
|
||||||
|
deploymentId: uuid("deployment_id"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_update_requests_org").on(table.organizationId),
|
||||||
|
index("idx_update_requests_device").on(table.deviceId),
|
||||||
|
index("idx_update_requests_status").on(table.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deployments = pgTable(
|
||||||
|
"deployments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
jobType: varchar("job_type", { length: 100 }).notNull(),
|
||||||
|
parameters: jsonb("parameters").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("draft"),
|
||||||
|
createdByUserId: uuid("created_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
approvedByUserId: uuid("approved_by_user_id").references(() => users.id),
|
||||||
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||||
|
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
totalTargets: integer("total_targets").notNull().default(0),
|
||||||
|
completedTargets: integer("completed_targets").notNull().default(0),
|
||||||
|
failedTargets: integer("failed_targets").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_deployments_org").on(table.organizationId),
|
||||||
|
index("idx_deployments_status").on(table.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const softwarePackages = pgTable(
|
||||||
|
"software_packages",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
organizationId: uuid("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
version: varchar("version", { length: 100 }).notNull(),
|
||||||
|
packageType: varchar("package_type", { length: 50 }).notNull(),
|
||||||
|
platform: varchar("platform", { length: 50 }).notNull(),
|
||||||
|
architecture: varchar("architecture", { length: 50 }).notNull(),
|
||||||
|
filename: varchar("filename", { length: 500 }).notNull(),
|
||||||
|
fileSize: bigint("file_size", { mode: "number" }).notNull(),
|
||||||
|
checksum: varchar("checksum", { length: 255 }),
|
||||||
|
installCommand: text("install_command"),
|
||||||
|
tags: jsonb("tags").$type<string[]>().default([]).notNull(),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
uploadedByUserId: uuid("uploaded_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdateFn(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_software_packages_org").on(table.organizationId),
|
||||||
|
index("idx_software_packages_platform").on(table.platform),
|
||||||
|
index("idx_software_packages_name").on(table.name),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deploymentTargets = pgTable(
|
||||||
|
"deployment_targets",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
deploymentId: uuid("deployment_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => deployments.id, { onDelete: "cascade" }),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
jobId: uuid("job_id").references(() => jobs.id),
|
||||||
|
status: varchar("status", { length: 50 }).notNull().default("pending"),
|
||||||
|
result: jsonb("result").$type<Record<string, unknown>>(),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_deployment_targets_deployment").on(table.deploymentId),
|
||||||
|
index("idx_deployment_targets_device").on(table.deviceId),
|
||||||
|
index("idx_deployment_targets_status").on(table.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { db } from "./index.js";
|
||||||
|
import { organizations, users } from "./schema.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
const [org] = await db
|
||||||
|
.insert(organizations)
|
||||||
|
.values({
|
||||||
|
name: "Default Organization",
|
||||||
|
slug: "default",
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
let organizationId: string;
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
organizationId = org.id;
|
||||||
|
console.log(`Created organization: ${org.name} (${org.id})`);
|
||||||
|
} else {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.slug, "default"))
|
||||||
|
.limit(1);
|
||||||
|
organizationId = existing!.id;
|
||||||
|
console.log(`Organization already exists: ${existing!.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, "admin@fleetforge.local"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
const passwordHash = await bcrypt.hash("admin123", 12);
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
organizationId,
|
||||||
|
email: "admin@fleetforge.local",
|
||||||
|
passwordHash,
|
||||||
|
displayName: "Fleet Admin",
|
||||||
|
role: "owner",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
console.log(`Created admin user: ${user.email}`);
|
||||||
|
console.log("Login credentials: admin@fleetforge.local / admin123");
|
||||||
|
} else {
|
||||||
|
console.log("Admin user already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch((err) => {
|
||||||
|
console.error("Seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (err) => {
|
||||||
|
console.error("[process] Unhandled rejection:", err);
|
||||||
|
});
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
console.error("[process] Uncaught exception:", err);
|
||||||
|
});
|
||||||
|
import { corsConfig } from "./middleware/cors.js";
|
||||||
|
import { errorHandler, notFoundHandler } from "./middleware/errors.js";
|
||||||
|
import { correlationMiddleware } from "./middleware/correlation.js";
|
||||||
|
import { rateLimitAuth, rateLimitApi, rateLimitEnroll } from "./middleware/rateLimit.js";
|
||||||
|
import { authRouter } from "./routes/auth.js";
|
||||||
|
import { devicesRouter } from "./routes/devices.js";
|
||||||
|
import { enrollmentTokensRouter } from "./routes/enrollment-tokens.js";
|
||||||
|
import { groupsRouter } from "./routes/groups.js";
|
||||||
|
import { jobsRouter } from "./routes/jobs.js";
|
||||||
|
import { agentRouter } from "./routes/agent.js";
|
||||||
|
import { auditRouter } from "./routes/audit.js";
|
||||||
|
import { alertsRouter } from "./routes/alerts.js";
|
||||||
|
import { policiesRouter } from "./routes/policies.js";
|
||||||
|
import { dashboardRouter } from "./routes/dashboard.js";
|
||||||
|
import { usersRouter } from "./routes/users.js";
|
||||||
|
import { complianceRouter } from "./routes/compliance.js";
|
||||||
|
import { updateRequestsRouter } from "./routes/update-requests.js";
|
||||||
|
import { deploymentsRouter } from "./routes/deployments.js";
|
||||||
|
import { softwareRouter } from "./routes/software.js";
|
||||||
|
import { appCatalogRouter } from "./routes/appcatalog.js";
|
||||||
|
import { downloadsRouter } from "./routes/downloads.js";
|
||||||
|
import { installersRouter } from "./routes/installers.js";
|
||||||
|
import { remoteRouter } from "./routes/remote.js";
|
||||||
|
import { attachRelay } from "./ws/relay.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = parseInt(process.env.APP_PORT || "3000", 10);
|
||||||
|
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}));
|
||||||
|
app.use(cors(corsConfig));
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
app.use(correlationMiddleware);
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok", version: "0.1.0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const openapiPath = path.resolve(__dirname, "../../../docs/openapi.json");
|
||||||
|
if (fs.existsSync(openapiPath)) {
|
||||||
|
const spec = JSON.parse(fs.readFileSync(openapiPath, "utf-8"));
|
||||||
|
app.get("/api/docs", (_req, res) => {
|
||||||
|
res.send(`<!DOCTYPE html>
|
||||||
|
<html><head><title>Fleet Forge API Docs</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
</head><body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
SwaggerUIBundle({ spec: ${JSON.stringify(spec)}, dom_id: '#swagger-ui', presets: [SwaggerUIBundle.presets.apis], layout: "BaseLayout" });
|
||||||
|
};
|
||||||
|
</script></body></html>`);
|
||||||
|
});
|
||||||
|
app.get("/api/openapi.json", (_req, res) => res.json(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use("/api/v1/auth/login", rateLimitAuth);
|
||||||
|
app.use("/api/v1/agent/enroll", rateLimitEnroll);
|
||||||
|
app.use("/api/v1", rateLimitApi);
|
||||||
|
|
||||||
|
app.use("/api/v1/auth", authRouter);
|
||||||
|
app.use("/api/v1/users", usersRouter);
|
||||||
|
app.use("/api/v1/devices", devicesRouter);
|
||||||
|
app.use("/api/v1/enrollment-tokens", enrollmentTokensRouter);
|
||||||
|
app.use("/api/v1/groups", groupsRouter);
|
||||||
|
app.use("/api/v1/jobs", jobsRouter);
|
||||||
|
app.use("/api/v1/policies", policiesRouter);
|
||||||
|
app.use("/api/v1/alerts", alertsRouter);
|
||||||
|
app.use("/api/v1/audit-events", auditRouter);
|
||||||
|
app.use("/api/v1/dashboard", dashboardRouter);
|
||||||
|
app.use("/api/v1/compliance", complianceRouter);
|
||||||
|
app.use("/api/v1/update-requests", updateRequestsRouter);
|
||||||
|
app.use("/api/v1/deployments", deploymentsRouter);
|
||||||
|
app.use("/api/v1/software", softwareRouter);
|
||||||
|
app.use("/api/v1/appcatalog", appCatalogRouter);
|
||||||
|
app.use("/api/v1/downloads", downloadsRouter);
|
||||||
|
app.use("/api/v1/installers", installersRouter);
|
||||||
|
app.use("/api/v1/agent", agentRouter);
|
||||||
|
app.use("/api/v1/remote", remoteRouter);
|
||||||
|
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
const server = app.listen(port, "0.0.0.0", () => {
|
||||||
|
console.log(`Fleet Forge API running on port ${port}`);
|
||||||
|
});
|
||||||
|
attachRelay(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { auditEvents } from "../db/schema.js";
|
||||||
|
import type { AuthenticatedUser } from "@fleetforge/shared";
|
||||||
|
|
||||||
|
export async function auditLog(params: {
|
||||||
|
req?: Request;
|
||||||
|
userId?: string;
|
||||||
|
organizationId: string;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId?: string;
|
||||||
|
deviceIds?: string[];
|
||||||
|
result: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
correlationId?: string;
|
||||||
|
}) {
|
||||||
|
const correlationId =
|
||||||
|
params.correlationId ||
|
||||||
|
(params.req ? ((params.req as any).correlationId as string) : "unknown");
|
||||||
|
const ipAddress = params.req?.ip || params.req?.socket?.remoteAddress;
|
||||||
|
const user = params.req
|
||||||
|
? ((params.req as any).user as AuthenticatedUser)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await db.insert(auditEvents).values({
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
userId: params.userId || user?.userId || null,
|
||||||
|
action: params.action,
|
||||||
|
targetType: params.targetType,
|
||||||
|
targetId: params.targetId || null,
|
||||||
|
deviceIds: params.deviceIds || [],
|
||||||
|
result: params.result,
|
||||||
|
ipAddress: ipAddress || null,
|
||||||
|
metadata: params.metadata || {},
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
interface PolicyRule {
|
||||||
|
type: string;
|
||||||
|
condition: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceRow {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
os_name: string;
|
||||||
|
os_version: string;
|
||||||
|
platform: string;
|
||||||
|
architecture: string;
|
||||||
|
agent_version: string;
|
||||||
|
cpu_cores: number | null;
|
||||||
|
total_memory_bytes: number | null;
|
||||||
|
total_disk_bytes: number | null;
|
||||||
|
status: string;
|
||||||
|
tags: string[];
|
||||||
|
kernel_version: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricRow {
|
||||||
|
cpu_usage_percent: number | null;
|
||||||
|
memory_usage_percent: number | null;
|
||||||
|
disk_usage_percent: number | null;
|
||||||
|
load_average_1m: number | null;
|
||||||
|
uptime_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicyRow {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
name: string;
|
||||||
|
rules: PolicyRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryRow {
|
||||||
|
packages: { name: string; version: string }[];
|
||||||
|
services: { name: string; status: string; enabled: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const pa = a.split(".").map(Number);
|
||||||
|
const pb = b.split(".").map(Number);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const na = pa[i] || 0;
|
||||||
|
const nb = pb[i] || 0;
|
||||||
|
if (na > nb) return 1;
|
||||||
|
if (na < nb) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceField(
|
||||||
|
device: DeviceRow,
|
||||||
|
metrics: MetricRow | null,
|
||||||
|
inventory: InventoryRow | null,
|
||||||
|
fieldType: string
|
||||||
|
): unknown {
|
||||||
|
switch (fieldType) {
|
||||||
|
case "os_version":
|
||||||
|
return device.os_version;
|
||||||
|
case "os_name":
|
||||||
|
return device.os_name;
|
||||||
|
case "platform":
|
||||||
|
return device.platform;
|
||||||
|
case "architecture":
|
||||||
|
return device.architecture;
|
||||||
|
case "agent_version":
|
||||||
|
return device.agent_version;
|
||||||
|
case "cpu_cores":
|
||||||
|
return device.cpu_cores;
|
||||||
|
case "memory_total":
|
||||||
|
return device.total_memory_bytes;
|
||||||
|
case "disk_total":
|
||||||
|
return device.total_disk_bytes;
|
||||||
|
case "status":
|
||||||
|
return device.status;
|
||||||
|
case "tags":
|
||||||
|
return device.tags || [];
|
||||||
|
case "kernel_version":
|
||||||
|
return device.kernel_version;
|
||||||
|
case "cpu_usage":
|
||||||
|
return metrics?.cpu_usage_percent;
|
||||||
|
case "memory_usage":
|
||||||
|
return metrics?.memory_usage_percent;
|
||||||
|
case "disk_usage":
|
||||||
|
return metrics?.disk_usage_percent;
|
||||||
|
case "load_average":
|
||||||
|
return metrics?.load_average_1m;
|
||||||
|
case "uptime":
|
||||||
|
return metrics?.uptime_seconds;
|
||||||
|
case "package_count":
|
||||||
|
return inventory?.packages?.length ?? 0;
|
||||||
|
case "has_package":
|
||||||
|
return inventory?.packages?.map((p) => p.name) ?? [];
|
||||||
|
case "service_count":
|
||||||
|
return inventory?.services?.length ?? 0;
|
||||||
|
case "service_status":
|
||||||
|
return inventory?.services?.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
enabled: s.enabled,
|
||||||
|
})) ?? [];
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateRule(
|
||||||
|
actual: unknown,
|
||||||
|
condition: string,
|
||||||
|
expected: string | number | boolean
|
||||||
|
): boolean {
|
||||||
|
if (actual === undefined || actual === null) {
|
||||||
|
return condition === "not_equals" && expected !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (condition) {
|
||||||
|
case "equals":
|
||||||
|
return String(actual) === String(expected);
|
||||||
|
case "not_equals":
|
||||||
|
return String(actual) !== String(expected);
|
||||||
|
case "greater_than":
|
||||||
|
return Number(actual) > Number(expected);
|
||||||
|
case "less_than":
|
||||||
|
return Number(actual) < Number(expected);
|
||||||
|
case "greater_than_or_equal":
|
||||||
|
return Number(actual) >= Number(expected);
|
||||||
|
case "less_than_or_equal":
|
||||||
|
return Number(actual) <= Number(expected);
|
||||||
|
case "contains":
|
||||||
|
if (Array.isArray(actual)) {
|
||||||
|
return actual.includes(String(expected));
|
||||||
|
}
|
||||||
|
return String(actual).includes(String(expected));
|
||||||
|
case "not_contains":
|
||||||
|
if (Array.isArray(actual)) {
|
||||||
|
return !actual.includes(String(expected));
|
||||||
|
}
|
||||||
|
return !String(actual).includes(String(expected));
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(expected)) {
|
||||||
|
return expected.map(String).includes(String(actual));
|
||||||
|
}
|
||||||
|
return String(actual) === String(expected);
|
||||||
|
case "not_in":
|
||||||
|
if (Array.isArray(expected)) {
|
||||||
|
return !expected.map(String).includes(String(actual));
|
||||||
|
}
|
||||||
|
return String(actual) !== String(expected);
|
||||||
|
case "version_gte":
|
||||||
|
return compareVersions(String(actual), String(expected)) >= 0;
|
||||||
|
case "version_lte":
|
||||||
|
return compareVersions(String(actual), String(expected)) <= 0;
|
||||||
|
case "version_gt":
|
||||||
|
return compareVersions(String(actual), String(expected)) > 0;
|
||||||
|
case "version_lt":
|
||||||
|
return compareVersions(String(actual), String(expected)) < 0;
|
||||||
|
case "is_true":
|
||||||
|
return actual === true || actual === "true";
|
||||||
|
case "is_false":
|
||||||
|
return actual === false || actual === "false";
|
||||||
|
case "is_empty":
|
||||||
|
if (Array.isArray(actual)) return actual.length === 0;
|
||||||
|
if (typeof actual === "string") return actual.length === 0;
|
||||||
|
return actual === null || actual === undefined;
|
||||||
|
case "is_not_empty":
|
||||||
|
if (Array.isArray(actual)) return actual.length > 0;
|
||||||
|
if (typeof actual === "string") return actual.length > 0;
|
||||||
|
return actual !== null && actual !== undefined;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluatePolicies(
|
||||||
|
pool: pg.Pool,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<{ evaluated: number; compliant: number; nonCompliant: number; unknown: number }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
let evaluated = 0;
|
||||||
|
let compliant = 0;
|
||||||
|
let nonCompliant = 0;
|
||||||
|
let unknown = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows: policies } = await client.query<PolicyRow>(
|
||||||
|
`SELECT id, organization_id, name, rules
|
||||||
|
FROM policies
|
||||||
|
WHERE organization_id = $1 AND is_enabled = true`,
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (policies.length === 0) {
|
||||||
|
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: devices } = await client.query<DeviceRow>(
|
||||||
|
`SELECT id, organization_id, os_name, os_version, platform, architecture,
|
||||||
|
agent_version, cpu_cores, total_memory_bytes, total_disk_bytes,
|
||||||
|
status, tags, kernel_version
|
||||||
|
FROM devices
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND is_disabled = false
|
||||||
|
AND is_archived = false`,
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceIds = devices.map((d) => d.id);
|
||||||
|
const metricsMap = new Map<string, MetricRow>();
|
||||||
|
const inventoryMap = new Map<string, InventoryRow>();
|
||||||
|
|
||||||
|
if (deviceIds.length > 0) {
|
||||||
|
const { rows: metricsRows } = await client.query(
|
||||||
|
`SELECT DISTINCT ON (device_id)
|
||||||
|
device_id, cpu_usage_percent, memory_usage_percent,
|
||||||
|
disk_usage_percent, load_average_1m, uptime_seconds
|
||||||
|
FROM metric_snapshots
|
||||||
|
WHERE device_id = ANY($1)
|
||||||
|
ORDER BY device_id, reported_at DESC`,
|
||||||
|
[deviceIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of metricsRows) {
|
||||||
|
metricsMap.set(row.device_id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: inventoryRows } = await client.query(
|
||||||
|
`SELECT DISTINCT ON (device_id)
|
||||||
|
device_id, packages, services
|
||||||
|
FROM inventory_snapshots
|
||||||
|
WHERE device_id = ANY($1)
|
||||||
|
ORDER BY device_id, reported_at DESC`,
|
||||||
|
[deviceIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of inventoryRows) {
|
||||||
|
inventoryMap.set(row.device_id, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const upserts: Array<{
|
||||||
|
deviceId: string;
|
||||||
|
policyId: string;
|
||||||
|
status: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
const metrics = metricsMap.get(device.id) || null;
|
||||||
|
const inventory = inventoryMap.get(device.id) || null;
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
const rules = (policy.rules || []) as PolicyRule[];
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
upserts.push({
|
||||||
|
deviceId: device.id,
|
||||||
|
policyId: policy.id,
|
||||||
|
status: "compliant",
|
||||||
|
details: { message: "No rules defined, defaulting to compliant" },
|
||||||
|
});
|
||||||
|
compliant++;
|
||||||
|
evaluated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
let hasUnknown = false;
|
||||||
|
const ruleResults: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const actual = getDeviceField(device, metrics, inventory, rule.type);
|
||||||
|
|
||||||
|
if (actual === undefined) {
|
||||||
|
ruleResults[rule.type] = {
|
||||||
|
condition: rule.condition,
|
||||||
|
expected: rule.value,
|
||||||
|
actual: null,
|
||||||
|
passed: false,
|
||||||
|
reason: "field_not_found",
|
||||||
|
};
|
||||||
|
hasUnknown = true;
|
||||||
|
allPassed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = evaluateRule(actual, rule.condition, rule.value);
|
||||||
|
ruleResults[rule.type] = {
|
||||||
|
condition: rule.condition,
|
||||||
|
expected: rule.value,
|
||||||
|
actual,
|
||||||
|
passed,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: string;
|
||||||
|
if (hasUnknown && !allPassed) {
|
||||||
|
status = "unknown";
|
||||||
|
unknown++;
|
||||||
|
} else if (allPassed) {
|
||||||
|
status = "compliant";
|
||||||
|
compliant++;
|
||||||
|
} else {
|
||||||
|
status = "non_compliant";
|
||||||
|
nonCompliant++;
|
||||||
|
}
|
||||||
|
|
||||||
|
upserts.push({
|
||||||
|
deviceId: device.id,
|
||||||
|
policyId: policy.id,
|
||||||
|
status,
|
||||||
|
details: ruleResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
evaluated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const u of upserts) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (device_id, policy_id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
details = EXCLUDED.details,
|
||||||
|
evaluated_at = EXCLUDED.evaluated_at`,
|
||||||
|
[u.deviceId, u.policyId, u.status, JSON.stringify(u.details), now]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { evaluated, compliant, nonCompliant, unknown };
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluatePoliciesForDevice(
|
||||||
|
pool: pg.Pool,
|
||||||
|
deviceId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<{ evaluated: number; compliant: number; nonCompliant: number; unknown: number }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
let evaluated = 0;
|
||||||
|
let compliant = 0;
|
||||||
|
let nonCompliant = 0;
|
||||||
|
let unknown = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows: devices } = await client.query<DeviceRow>(
|
||||||
|
`SELECT id, organization_id, os_name, os_version, platform, architecture,
|
||||||
|
agent_version, cpu_cores, total_memory_bytes, total_disk_bytes,
|
||||||
|
status, tags, kernel_version
|
||||||
|
FROM devices
|
||||||
|
WHERE id = $1 AND organization_id = $2`,
|
||||||
|
[deviceId, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = devices[0];
|
||||||
|
|
||||||
|
const { rows: policies } = await client.query<PolicyRow>(
|
||||||
|
`SELECT id, organization_id, name, rules
|
||||||
|
FROM policies
|
||||||
|
WHERE organization_id = $1 AND is_enabled = true`,
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (policies.length === 0) {
|
||||||
|
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: metricsRows } = await client.query(
|
||||||
|
`SELECT cpu_usage_percent, memory_usage_percent,
|
||||||
|
disk_usage_percent, load_average_1m, uptime_seconds
|
||||||
|
FROM metric_snapshots
|
||||||
|
WHERE device_id = $1
|
||||||
|
ORDER BY reported_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const metrics: MetricRow | null = metricsRows[0] || null;
|
||||||
|
|
||||||
|
const { rows: inventoryRows } = await client.query(
|
||||||
|
`SELECT packages, services
|
||||||
|
FROM inventory_snapshots
|
||||||
|
WHERE device_id = $1
|
||||||
|
ORDER BY reported_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inventory: InventoryRow | null = inventoryRows[0] || null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
const rules = (policy.rules || []) as PolicyRule[];
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (device_id, policy_id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`,
|
||||||
|
[deviceId, policy.id, "compliant", JSON.stringify({ message: "No rules defined" }), now]
|
||||||
|
);
|
||||||
|
compliant++;
|
||||||
|
evaluated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
let hasUnknown = false;
|
||||||
|
const ruleResults: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const actual = getDeviceField(device, metrics, inventory, rule.type);
|
||||||
|
|
||||||
|
if (actual === undefined) {
|
||||||
|
ruleResults[rule.type] = {
|
||||||
|
condition: rule.condition,
|
||||||
|
expected: rule.value,
|
||||||
|
actual: null,
|
||||||
|
passed: false,
|
||||||
|
reason: "field_not_found",
|
||||||
|
};
|
||||||
|
hasUnknown = true;
|
||||||
|
allPassed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = evaluateRule(actual, rule.condition, rule.value);
|
||||||
|
ruleResults[rule.type] = {
|
||||||
|
condition: rule.condition,
|
||||||
|
expected: rule.value,
|
||||||
|
actual,
|
||||||
|
passed,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: string;
|
||||||
|
if (hasUnknown && !allPassed) {
|
||||||
|
status = "unknown";
|
||||||
|
unknown++;
|
||||||
|
} else if (allPassed) {
|
||||||
|
status = "compliant";
|
||||||
|
compliant++;
|
||||||
|
} else {
|
||||||
|
status = "non_compliant";
|
||||||
|
nonCompliant++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (device_id, policy_id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`,
|
||||||
|
[deviceId, policy.id, status, JSON.stringify(ruleResults), now]
|
||||||
|
);
|
||||||
|
|
||||||
|
evaluated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { evaluated, compliant, nonCompliant, unknown };
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
correlationId?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
error?: {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntry(entry: LogEntry): string {
|
||||||
|
return JSON.stringify(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogger(defaultContext?: Record<string, unknown>) {
|
||||||
|
const log = (level: LogLevel, message: string, context?: Record<string, unknown>, correlationId?: string, error?: Error) => {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
...((defaultContext || context) && { context: { ...defaultContext, ...context } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (correlationId) entry.correlationId = correlationId;
|
||||||
|
if (error) {
|
||||||
|
entry.error = {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = formatEntry(entry);
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "error":
|
||||||
|
console.error(line);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
console.warn(line);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
debug: (message: string, context?: Record<string, unknown>, correlationId?: string) =>
|
||||||
|
log("debug", message, context, correlationId),
|
||||||
|
info: (message: string, context?: Record<string, unknown>, correlationId?: string) =>
|
||||||
|
log("info", message, context, correlationId),
|
||||||
|
warn: (message: string, context?: Record<string, unknown>, correlationId?: string) =>
|
||||||
|
log("warn", message, context, correlationId),
|
||||||
|
error: (message: string, context?: Record<string, unknown>, correlationId?: string, error?: Error) =>
|
||||||
|
log("error", message, context, correlationId, error),
|
||||||
|
child: (childContext: Record<string, unknown>) =>
|
||||||
|
createLogger({ ...defaultContext, ...childContext }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = createLogger({ service: "fleetforge-api" });
|
||||||
|
export const workerLogger = createLogger({ service: "fleetforge-worker" });
|
||||||
|
export { createLogger };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEnrollmentToken(): string {
|
||||||
|
return `ff-${crypto.randomBytes(32).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDeviceCredential(): string {
|
||||||
|
return `fc-${crypto.randomBytes(32).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashCredential(credential: string): string {
|
||||||
|
return crypto.createHash("sha256").update(credential).digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import type { AuthenticatedUser, Role } from "@fleetforge/shared";
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<Role, number> = {
|
||||||
|
owner: 4,
|
||||||
|
admin: 3,
|
||||||
|
operator: 2,
|
||||||
|
read_only: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateToken(user: AuthenticatedUser): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
userId: user.userId,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
role: user.role,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: (process.env.JWT_EXPIRES_IN || "24h") as any }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): AuthenticatedUser {
|
||||||
|
return jwt.verify(token, JWT_SECRET) as AuthenticatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Missing or invalid token" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const user = verifyToken(token);
|
||||||
|
(req as any).user = user;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Invalid or expired token" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorize(...allowedRoles: Role[]) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const user = (req as any).user as AuthenticatedUser;
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Not authenticated" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedRoles.length > 0) {
|
||||||
|
const hasRole = allowedRoles.some(
|
||||||
|
(role) => ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[role]
|
||||||
|
);
|
||||||
|
if (!hasRole) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "FORBIDDEN", message: "Insufficient permissions" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
export function correlationMiddleware(
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const correlationId =
|
||||||
|
(req.headers["x-correlation-id"] as string) || uuidv4();
|
||||||
|
(req as any).correlationId = correlationId;
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const corsConfig = {
|
||||||
|
origin: process.env.CORS_ORIGINS
|
||||||
|
? process.env.CORS_ORIGINS.split(",")
|
||||||
|
: [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"http://localhost:3002",
|
||||||
|
"http://localhost:3003",
|
||||||
|
"http://localhost:3004",
|
||||||
|
"http://localhost:3005",
|
||||||
|
"http://10.0.0.194:3000",
|
||||||
|
"http://10.0.0.194:3001",
|
||||||
|
"http://10.0.0.194:3002",
|
||||||
|
"http://10.0.0.194:3003",
|
||||||
|
"http://10.0.0.194:3004",
|
||||||
|
"http://10.0.0.194:3005",
|
||||||
|
],
|
||||||
|
credentials: true,
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization", "X-Correlation-ID"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
err: Error,
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
) {
|
||||||
|
console.error("Unhandled error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "An internal error occurred" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notFoundHandler(_req: Request, res: Response) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Endpoint not found" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { RateLimiterMemory } from "rate-limiter-flexible";
|
||||||
|
|
||||||
|
const authLimiter = new RateLimiterMemory({
|
||||||
|
points: 10,
|
||||||
|
duration: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiLimiter = new RateLimiterMemory({
|
||||||
|
points: 100,
|
||||||
|
duration: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrollLimiter = new RateLimiterMemory({
|
||||||
|
points: 5,
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rateLimitAuth(req: any, res: any, next: any) {
|
||||||
|
try {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
|
await authLimiter.consume(`auth:${ip}`);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "RATE_LIMITED", message: "Too many authentication attempts. Please try again later." },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rateLimitApi(req: any, res: any, next: any) {
|
||||||
|
try {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
|
const user = (req as any).user;
|
||||||
|
const key = user ? `api:${user.userId}` : `api:${ip}`;
|
||||||
|
await apiLimiter.consume(key);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "RATE_LIMITED", message: "Rate limit exceeded. Please slow down." },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rateLimitEnroll(req: any, res: any, next: any) {
|
||||||
|
try {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
|
await enrollLimiter.consume(`enroll:${ip}`);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "RATE_LIMITED", message: "Too many enrollment attempts." },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import {
|
||||||
|
enrollmentTokens,
|
||||||
|
devices,
|
||||||
|
deviceCredentials,
|
||||||
|
inventorySnapshots,
|
||||||
|
metricSnapshots,
|
||||||
|
jobTargets,
|
||||||
|
jobs,
|
||||||
|
softwarePackages,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import {
|
||||||
|
hashToken,
|
||||||
|
generateDeviceCredential,
|
||||||
|
hashCredential,
|
||||||
|
} from "../lib/tokens.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and, gt, inArray, notInArray } from "drizzle-orm";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
device?: any;
|
||||||
|
deviceCredential?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentRouter = Router();
|
||||||
|
|
||||||
|
const agentAuthMiddleware = async (req: any, res: any, next: any) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Missing device credential" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = authHeader.substring(7);
|
||||||
|
const credHash = hashCredential(credential);
|
||||||
|
|
||||||
|
const [deviceCred] = await db
|
||||||
|
.select()
|
||||||
|
.from(deviceCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deviceCredentials.credentialHash, credHash),
|
||||||
|
eq(deviceCredentials.credentialType, "device_key")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!deviceCred) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Invalid device credential" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceCred.expiresAt && new Date(deviceCred.expiresAt) < new Date()) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "CREDENTIAL_EXPIRED", message: "Device credential expired" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.id, deviceCred.deviceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device || device.isDisabled) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "DEVICE_DISABLED", message: "Device is disabled or not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.device = device;
|
||||||
|
req.deviceCredential = deviceCred;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrollSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
hostname: z.string().min(1).max(255),
|
||||||
|
platform: z.string().min(1).max(50),
|
||||||
|
os_name: z.string().min(1).max(100),
|
||||||
|
os_version: z.string().max(100).default("unknown"),
|
||||||
|
architecture: z.string().min(1).max(50),
|
||||||
|
kernel_version: z.string().max(100).optional(),
|
||||||
|
cpu_model: z.string().max(255).optional(),
|
||||||
|
cpu_cores: z.number().int().positive().optional(),
|
||||||
|
total_memory_bytes: z.number().positive().optional(),
|
||||||
|
total_disk_bytes: z.number().positive().optional(),
|
||||||
|
ip_address: z.string().max(45).optional(),
|
||||||
|
agent_version: z.string().min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRouter.post("/enroll", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = enrollSchema.parse(req.body);
|
||||||
|
|
||||||
|
const tokenHash = hashToken(body.token);
|
||||||
|
|
||||||
|
const [enrollmentToken] = await db
|
||||||
|
.select()
|
||||||
|
.from(enrollmentTokens)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(enrollmentTokens.tokenHash, tokenHash),
|
||||||
|
eq(enrollmentTokens.isRevoked, false),
|
||||||
|
gt(enrollmentTokens.expiresAt, new Date())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!enrollmentToken) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INVALID_TOKEN", message: "Invalid, expired, or revoked enrollment token" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
enrollmentToken.maxUses &&
|
||||||
|
enrollmentToken.useCount >= enrollmentToken.maxUses
|
||||||
|
) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "TOKEN_EXHAUSTED", message: "Enrollment token has been used up" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceType = "linux_server";
|
||||||
|
if (body.platform === "windows") deviceType = "windows_server";
|
||||||
|
if (body.platform === "darwin") deviceType = "macos";
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.insert(devices)
|
||||||
|
.values({
|
||||||
|
organizationId: enrollmentToken.organizationId,
|
||||||
|
name: body.hostname,
|
||||||
|
deviceType,
|
||||||
|
platform: body.platform,
|
||||||
|
osName: body.os_name,
|
||||||
|
osVersion: body.os_version,
|
||||||
|
architecture: body.architecture,
|
||||||
|
kernelVersion: body.kernel_version || null,
|
||||||
|
cpuModel: body.cpu_model || null,
|
||||||
|
cpuCores: body.cpu_cores || null,
|
||||||
|
totalMemoryBytes: body.total_memory_bytes || null,
|
||||||
|
totalDiskBytes: body.total_disk_bytes || null,
|
||||||
|
ipAddress: body.ip_address || null,
|
||||||
|
agentVersion: body.agent_version,
|
||||||
|
status: "online",
|
||||||
|
lastHeartbeatAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const rawCredential = generateDeviceCredential();
|
||||||
|
const credentialHash = hashCredential(rawCredential);
|
||||||
|
|
||||||
|
await db.insert(deviceCredentials).values({
|
||||||
|
deviceId: device.id,
|
||||||
|
credentialType: "device_key",
|
||||||
|
credentialHash,
|
||||||
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(enrollmentTokens)
|
||||||
|
.set({ useCount: enrollmentToken.useCount + 1 })
|
||||||
|
.where(eq(enrollmentTokens.id, enrollmentToken.id));
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
organizationId: enrollmentToken.organizationId,
|
||||||
|
action: "device.enroll",
|
||||||
|
targetType: "device",
|
||||||
|
targetId: device.id,
|
||||||
|
deviceIds: [device.id],
|
||||||
|
result: "success",
|
||||||
|
metadata: {
|
||||||
|
hostname: body.hostname,
|
||||||
|
platform: body.platform,
|
||||||
|
os: body.os_name,
|
||||||
|
agentVersion: body.agent_version,
|
||||||
|
},
|
||||||
|
correlationId: req.headers["x-correlation-id"] as string || "enrollment",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
device_id: device.id,
|
||||||
|
credential: rawCredential,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
message: "Invalid enrollment data",
|
||||||
|
details: err.errors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[enroll] Error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "Enrollment failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeatSchema = z.object({
|
||||||
|
hostname: z.string().optional(),
|
||||||
|
os_version: z.string().optional(),
|
||||||
|
kernel_version: z.string().optional(),
|
||||||
|
agent_version: z.string().optional(),
|
||||||
|
ip_address: z.string().optional(),
|
||||||
|
metrics: z
|
||||||
|
.object({
|
||||||
|
cpu_usage_percent: z.number().min(0).max(100).optional(),
|
||||||
|
memory_usage_percent: z.number().min(0).max(100).optional(),
|
||||||
|
memory_used_bytes: z.number().optional(),
|
||||||
|
memory_total_bytes: z.number().optional(),
|
||||||
|
disk_usage_percent: z.number().min(0).max(100).optional(),
|
||||||
|
disk_used_bytes: z.number().optional(),
|
||||||
|
disk_total_bytes: z.number().optional(),
|
||||||
|
load_average_1m: z.number().optional(),
|
||||||
|
load_average_5m: z.number().optional(),
|
||||||
|
load_average_15m: z.number().optional(),
|
||||||
|
uptime_seconds: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
inventory: z
|
||||||
|
.object({
|
||||||
|
packages: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
manager: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
services: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
network_interfaces: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
addresses: z.array(z.string()),
|
||||||
|
mac_address: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRouter.post("/heartbeat", agentAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = heartbeatSchema.parse(req.body);
|
||||||
|
const device = req.device;
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
status: "online",
|
||||||
|
lastHeartbeatAt: new Date(),
|
||||||
|
};
|
||||||
|
if (body.hostname) updateData.name = body.hostname;
|
||||||
|
if (body.os_version) updateData.osVersion = body.os_version;
|
||||||
|
if (body.kernel_version) updateData.kernelVersion = body.kernel_version;
|
||||||
|
if (body.agent_version) updateData.agentVersion = body.agent_version;
|
||||||
|
if (body.ip_address) updateData.ipAddress = body.ip_address;
|
||||||
|
|
||||||
|
await db.update(devices).set(updateData).where(eq(devices.id, device.id));
|
||||||
|
|
||||||
|
if (body.metrics) {
|
||||||
|
await db.insert(metricSnapshots).values({
|
||||||
|
deviceId: device.id,
|
||||||
|
reportedAt: new Date(),
|
||||||
|
cpuUsagePercent: body.metrics.cpu_usage_percent ?? null,
|
||||||
|
memoryUsagePercent: body.metrics.memory_usage_percent ?? null,
|
||||||
|
memoryUsedBytes: body.metrics.memory_used_bytes ?? null,
|
||||||
|
memoryTotalBytes: body.metrics.memory_total_bytes ?? null,
|
||||||
|
diskUsagePercent: body.metrics.disk_usage_percent ?? null,
|
||||||
|
diskUsedBytes: body.metrics.disk_used_bytes ?? null,
|
||||||
|
diskTotalBytes: body.metrics.disk_total_bytes ?? null,
|
||||||
|
loadAverage1m: body.metrics.load_average_1m ?? null,
|
||||||
|
loadAverage5m: body.metrics.load_average_5m ?? null,
|
||||||
|
loadAverage15m: body.metrics.load_average_15m ?? null,
|
||||||
|
uptimeSeconds: body.metrics.uptime_seconds ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.inventory) {
|
||||||
|
await db.insert(inventorySnapshots).values({
|
||||||
|
deviceId: device.id,
|
||||||
|
reportedAt: new Date(),
|
||||||
|
packages: body.inventory.packages || [],
|
||||||
|
services: body.inventory.services || [],
|
||||||
|
networkInterfaces: body.inventory.network_interfaces || [],
|
||||||
|
platformData: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
message: "Invalid heartbeat data",
|
||||||
|
details: err.errors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[heartbeat] Error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "Heartbeat processing failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRouter.get("/jobs", agentAuthMiddleware, async (req, res) => {
|
||||||
|
const device = req.device;
|
||||||
|
|
||||||
|
const pendingTargets = await db
|
||||||
|
.select({
|
||||||
|
jobId: jobTargets.jobId,
|
||||||
|
targetId: jobTargets.id,
|
||||||
|
jobType: jobs.jobType,
|
||||||
|
parameters: jobs.parameters,
|
||||||
|
})
|
||||||
|
.from(jobTargets)
|
||||||
|
.innerJoin(jobs, eq(jobTargets.jobId, jobs.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobTargets.deviceId, device.id),
|
||||||
|
inArray(jobTargets.status, ["queued", "dispatched"]),
|
||||||
|
eq(jobs.status, "dispatched")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
res.json({ success: true, data: pendingTargets });
|
||||||
|
});
|
||||||
|
|
||||||
|
const startJobSchema = z.object({
|
||||||
|
target_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRouter.post("/jobs/:jobId/start", agentAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = startJobSchema.parse(req.body);
|
||||||
|
const device = req.device;
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select()
|
||||||
|
.from(jobTargets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobTargets.id, body.target_id),
|
||||||
|
eq(jobTargets.deviceId, device.id),
|
||||||
|
eq(jobTargets.jobId, req.params.jobId as string)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Job target not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.status !== "queued" && target.status !== "dispatched") {
|
||||||
|
res.json({ success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(jobTargets)
|
||||||
|
.set({ status: "running", startedAt: new Date() })
|
||||||
|
.where(eq(jobTargets.id, body.target_id));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(jobs)
|
||||||
|
.set({ status: "running", startedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(eq(jobs.id, req.params.jobId as string), eq(jobs.status, "dispatched"))
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid start data", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[jobs/start] Error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "Start processing failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRouter.get("/jobs/:jobId/status", agentAuthMiddleware, async (req, res) => {
|
||||||
|
const device = req.device;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
jobStatus: jobs.status,
|
||||||
|
targetStatus: jobTargets.status,
|
||||||
|
})
|
||||||
|
.from(jobTargets)
|
||||||
|
.innerJoin(jobs, eq(jobTargets.jobId, jobs.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobTargets.jobId, req.params.jobId as string),
|
||||||
|
eq(jobTargets.deviceId, device.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Job target not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: { job_status: row.jobStatus, target_status: row.targetStatus } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultSchema = z.object({
|
||||||
|
job_id: z.string().uuid(),
|
||||||
|
target_id: z.string().uuid(),
|
||||||
|
status: z.enum(["succeeded", "failed", "cancelled"]),
|
||||||
|
result: z.record(z.unknown()).optional(),
|
||||||
|
error_message: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
agentRouter.post("/jobs/:jobId/result", agentAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = resultSchema.parse({
|
||||||
|
...req.body,
|
||||||
|
job_id: req.params.jobId,
|
||||||
|
});
|
||||||
|
const device = req.device;
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select()
|
||||||
|
.from(jobTargets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobTargets.id, body.target_id),
|
||||||
|
eq(jobTargets.deviceId, device.id),
|
||||||
|
eq(jobTargets.jobId, body.job_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Job target not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultData = body.result || {};
|
||||||
|
if (body.error_message) {
|
||||||
|
resultData.error = body.error_message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(jobTargets)
|
||||||
|
.set({
|
||||||
|
status: body.status,
|
||||||
|
result: resultData,
|
||||||
|
completedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(jobTargets.id, body.target_id));
|
||||||
|
|
||||||
|
const allTargets = await db
|
||||||
|
.select()
|
||||||
|
.from(jobTargets)
|
||||||
|
.where(eq(jobTargets.jobId, body.job_id));
|
||||||
|
|
||||||
|
const allComplete = allTargets.every(
|
||||||
|
(t) => t.status !== "queued" && t.status !== "dispatched" && t.status !== "running"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allComplete) {
|
||||||
|
const anyFailed = allTargets.some((t) => t.status === "failed");
|
||||||
|
await db
|
||||||
|
.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: anyFailed ? "failed" : "succeeded",
|
||||||
|
completedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(jobs.id, body.job_id), notInArray(jobs.status, ["cancelled", "expired"]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid result data", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[jobs/result] Error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "Result processing failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const SOFTWARE_DIR = path.resolve(
|
||||||
|
process.env.SOFTWARE_DIR || path.join(__dirname, "../../../../infrastructure/software-releases")
|
||||||
|
);
|
||||||
|
|
||||||
|
agentRouter.get("/software/:packageId/download", agentAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const device = req.device;
|
||||||
|
const packageId = req.params.packageId as string;
|
||||||
|
|
||||||
|
const [pkg] = await db
|
||||||
|
.select()
|
||||||
|
.from(softwarePackages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(softwarePackages.id, packageId),
|
||||||
|
eq(softwarePackages.organizationId, device.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Package not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgDir = path.join(SOFTWARE_DIR, device.organizationId);
|
||||||
|
const filePath = path.join(orgDir, pkg.filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "File not found on disk" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${pkg.filename}"`);
|
||||||
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
|
res.setHeader("Content-Length", pkg.fileSize);
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/software] Error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "Download failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { alerts } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and, desc, count } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const alertsRouter = Router();
|
||||||
|
|
||||||
|
alertsRouter.use(authenticate);
|
||||||
|
|
||||||
|
alertsRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const resolved = req.query.resolved as string | undefined;
|
||||||
|
|
||||||
|
let whereClause = eq(alerts.organizationId, user.organizationId);
|
||||||
|
if (resolved !== undefined) {
|
||||||
|
whereClause = and(whereClause, eq(alerts.isResolved, resolved === "true"))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(alerts)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const allAlerts = await db
|
||||||
|
.select()
|
||||||
|
.from(alerts)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(alerts.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: allAlerts,
|
||||||
|
meta: {
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
total: totalResult.total,
|
||||||
|
totalPages: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
alertsRouter.patch("/:alertId/resolve", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const alertId = req.params.alertId as string;
|
||||||
|
|
||||||
|
const [resolved] = await db
|
||||||
|
.update(alerts)
|
||||||
|
.set({ isResolved: true, resolvedAt: new Date(), resolvedByUserId: user.userId })
|
||||||
|
.where(
|
||||||
|
and(eq(alerts.id, alertId), eq(alerts.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.returning({ id: alerts.id });
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Alert not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export const appCatalogRouter = Router();
|
||||||
|
appCatalogRouter.use(authenticate);
|
||||||
|
|
||||||
|
const WINGET_SEARCH_URL = "https://api.winget.run/v2/packages";
|
||||||
|
const CHOCOLATEY_SEARCH = "https://community.chocolatey.org/api/v2/Search()";
|
||||||
|
const CHOCOLATEY_FEED = "https://community.chocolatey.org/api/v2/Packages()";
|
||||||
|
const UPSTREAM_TIMEOUT_MS = 12_000;
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
const cache = new Map<string, { expiresAt: number; value: unknown }>();
|
||||||
|
|
||||||
|
function cacheGet<T>(key: string): T | undefined {
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && hit.expiresAt > Date.now()) return hit.value as T;
|
||||||
|
if (hit) cache.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(key: string, value: unknown) {
|
||||||
|
cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUpstream(url: string): Promise<string> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`upstream returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WingetPackage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
publisher: string;
|
||||||
|
latestVersion: string;
|
||||||
|
versions: string[];
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
iconUrl: string;
|
||||||
|
homepage: string;
|
||||||
|
license: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWinget(raw: any): WingetPackage {
|
||||||
|
const latest = raw.Latest || {};
|
||||||
|
return {
|
||||||
|
id: raw.Id || "",
|
||||||
|
name: latest.Name || raw.Id || "",
|
||||||
|
publisher: latest.Publisher || "",
|
||||||
|
latestVersion: Array.isArray(raw.Versions) && raw.Versions.length > 0 ? raw.Versions[0] : "",
|
||||||
|
versions: Array.isArray(raw.Versions) ? raw.Versions : [],
|
||||||
|
description: latest.Description || "",
|
||||||
|
tags: Array.isArray(latest.Tags) ? latest.Tags : [],
|
||||||
|
iconUrl: raw.IconUrl || "",
|
||||||
|
homepage: latest.Homepage || "",
|
||||||
|
license: latest.License || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChocolateyPackage {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
downloadCount: number;
|
||||||
|
iconUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldRegex = (base: string) =>
|
||||||
|
new RegExp(`<d:${base}[^>]*>([^<]*)</d:${base}>`);
|
||||||
|
|
||||||
|
function mapChocolatey(xml: string): ChocolateyPackage[] {
|
||||||
|
const packages: ChocolateyPackage[] = [];
|
||||||
|
const entries = xml.match(/<entry>[\s\S]*?<\/entry>/g) || [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const idMatch = entry.match(/Packages\(Id='([^']+)'/);
|
||||||
|
const title = entry.match(fieldRegex("Title"));
|
||||||
|
const version = entry.match(fieldRegex("Version"));
|
||||||
|
const description = entry.match(fieldRegex("Description"));
|
||||||
|
const downloadCount = entry.match(fieldRegex("DownloadCount"));
|
||||||
|
const iconUrl = entry.match(fieldRegex("IconUrl"));
|
||||||
|
if (!idMatch) continue;
|
||||||
|
packages.push({
|
||||||
|
id: decodeXmlEntities(idMatch[1]),
|
||||||
|
title: decodeXmlEntities(title ? title[1] : ""),
|
||||||
|
version: decodeXmlEntities(version ? version[1] : ""),
|
||||||
|
description: decodeXmlEntities(description ? description[1] : ""),
|
||||||
|
downloadCount: downloadCount ? parseInt(downloadCount[1], 10) || 0 : 0,
|
||||||
|
iconUrl: decodeXmlEntities(iconUrl ? iconUrl[1] : ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
appCatalogRouter.get("/winget", authorize("read_only"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const search = String(req.query.search || "").trim();
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 25, 1), 50);
|
||||||
|
const take = limit;
|
||||||
|
const skip = (page - 1) * take;
|
||||||
|
|
||||||
|
const cacheKey = `winget:${search}:${page}:${limit}`;
|
||||||
|
const cached = cacheGet<{ packages: WingetPackage[]; total: number }>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
res.json({ success: true, data: { ...cached, page, limit } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${WINGET_SEARCH_URL}?query=${encodeURIComponent(search)}&take=${take}&page=${skip}&sortField=SearchScore`;
|
||||||
|
const body = await fetchUpstream(url);
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
const rawPackages = Array.isArray(parsed.Packages) ? parsed.Packages : [];
|
||||||
|
const packages = rawPackages.map(mapWinget);
|
||||||
|
const total = typeof parsed.Total === "number" ? parsed.Total : 0;
|
||||||
|
|
||||||
|
cacheSet(cacheKey, { packages, total });
|
||||||
|
res.json({ success: true, data: { packages, total, page, limit } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UPSTREAM_ERROR", message: (err as Error).message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
appCatalogRouter.get("/chocolatey", authorize("read_only"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const search = String(req.query.search || "").trim();
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 25, 1), 50);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const cacheKey = `chocolatey:${search}:${page}:${limit}`;
|
||||||
|
const cached = cacheGet<{ packages: ChocolateyPackage[] }>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
res.json({ success: true, data: { ...cached, page, limit } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let packages: ChocolateyPackage[];
|
||||||
|
if (search) {
|
||||||
|
// Search() matches title/description/tags but returns one entry per version.
|
||||||
|
// Fetch a wider window, deduplicate (newest version first) and slice.
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
searchTerm: `'${search}'`,
|
||||||
|
targetFramework: "",
|
||||||
|
includePrerelease: "false",
|
||||||
|
$skip: "0",
|
||||||
|
$top: String(skip + limit * 4),
|
||||||
|
$select: "Id,Version,Title,Description,DownloadCount,IconUrl",
|
||||||
|
});
|
||||||
|
const xml = await fetchUpstream(`${CHOCOLATEY_SEARCH}?${params.toString()}`);
|
||||||
|
const raw = mapChocolatey(xml);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = raw.filter((p) => {
|
||||||
|
if (seen.has(p.id)) return false;
|
||||||
|
seen.add(p.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
packages = unique.length > skip ? unique.slice(skip, skip + limit) : [];
|
||||||
|
} else {
|
||||||
|
// Browse-all: one row per package (latest version only).
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
$filter: "IsLatestVersion",
|
||||||
|
$skip: String(skip),
|
||||||
|
$top: String(limit),
|
||||||
|
$select: "Id,Version,Title,Description,DownloadCount,IconUrl",
|
||||||
|
});
|
||||||
|
const xml = await fetchUpstream(`${CHOCOLATEY_FEED}?${params.toString()}`);
|
||||||
|
packages = mapChocolatey(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSet(cacheKey, { packages });
|
||||||
|
res.json({ success: true, data: { packages, page, limit } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UPSTREAM_ERROR", message: (err as Error).message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { auditEvents } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and, desc, ilike, count } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const auditRouter = Router();
|
||||||
|
|
||||||
|
auditRouter.use(authenticate);
|
||||||
|
|
||||||
|
auditRouter.get("/", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const action = req.query.action as string | undefined;
|
||||||
|
|
||||||
|
let whereClause = eq(auditEvents.organizationId, user.organizationId);
|
||||||
|
if (action) {
|
||||||
|
whereClause = and(whereClause, ilike(auditEvents.action, `%${action}%`))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(auditEvents)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const events = await db
|
||||||
|
.select()
|
||||||
|
.from(auditEvents)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(auditEvents.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: events,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalResult.total,
|
||||||
|
totalPages: Math.ceil(totalResult.total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { users } from "../db/schema.js";
|
||||||
|
import { generateToken } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
organizationSlug: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/login", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.email, body.email), eq(users.isActive, true)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValid = await bcrypt.compare(body.password, user.passwordHash);
|
||||||
|
if (!passwordValid) {
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "auth.login_failed",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: user.id,
|
||||||
|
result: "failure",
|
||||||
|
metadata: { reason: "invalid_password" },
|
||||||
|
});
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ lastLoginAt: new Date() })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const token = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
role: user.role as any,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "auth.login",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: user.id,
|
||||||
|
result: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/me", async (req, res) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Not authenticated" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { verifyToken } = await import("../middleware/auth.js");
|
||||||
|
const payload = verifyToken(authHeader.substring(7));
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, payload.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "User not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Invalid token" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { complianceResults, devices } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
import { evaluatePoliciesForDevice } from "../lib/compliance.js";
|
||||||
|
import { getPool } from "../db/index.js";
|
||||||
|
|
||||||
|
export const complianceRouter = Router();
|
||||||
|
|
||||||
|
complianceRouter.use(authenticate);
|
||||||
|
|
||||||
|
complianceRouter.get(
|
||||||
|
"/devices/:deviceId",
|
||||||
|
authorize("read_only"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.id, deviceId),
|
||||||
|
eq(devices.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(complianceResults)
|
||||||
|
.where(eq(complianceResults.deviceId, deviceId))
|
||||||
|
.orderBy(desc(complianceResults.evaluatedAt));
|
||||||
|
|
||||||
|
res.json({ success: true, data: results });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
complianceRouter.post(
|
||||||
|
"/devices/:deviceId/evaluate",
|
||||||
|
authorize("operator"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.id, deviceId),
|
||||||
|
eq(devices.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await evaluatePoliciesForDevice(
|
||||||
|
pool,
|
||||||
|
deviceId,
|
||||||
|
user.organizationId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
complianceRouter.post(
|
||||||
|
"/evaluate",
|
||||||
|
authorize("admin"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const { evaluatePolicies } = await import("../lib/compliance.js");
|
||||||
|
const result = await evaluatePolicies(pool, user.organizationId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
complianceRouter.get(
|
||||||
|
"/summary",
|
||||||
|
authorize("read_only"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(complianceResults)
|
||||||
|
.innerJoin(devices, eq(complianceResults.deviceId, devices.id))
|
||||||
|
.where(eq(devices.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
const total = results.length;
|
||||||
|
const compliant = results.filter(
|
||||||
|
(r) => r.compliance_results.status === "compliant"
|
||||||
|
).length;
|
||||||
|
const nonCompliant = results.filter(
|
||||||
|
(r) => r.compliance_results.status === "non_compliant"
|
||||||
|
).length;
|
||||||
|
const unknown = results.filter(
|
||||||
|
(r) => r.compliance_results.status === "unknown"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { total, compliant, nonCompliant, unknown },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { devices, alerts, jobs } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and, count, sql, desc } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const dashboardRouter = Router();
|
||||||
|
|
||||||
|
dashboardRouter.use(authenticate);
|
||||||
|
|
||||||
|
dashboardRouter.get("/stats", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const [totalDevices] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
const [onlineDevices] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(eq(devices.organizationId, user.organizationId), eq(devices.status, "online"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [offlineDevices] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.organizationId, user.organizationId),
|
||||||
|
eq(devices.status, "offline")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [warningDevices] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.organizationId, user.organizationId),
|
||||||
|
eq(devices.status, "warning")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [openAlerts] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(alerts)
|
||||||
|
.where(
|
||||||
|
and(eq(alerts.organizationId, user.organizationId), eq(alerts.isResolved, false))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pendingJobs] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(jobs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobs.organizationId, user.organizationId),
|
||||||
|
sql`${jobs.status} IN ('queued', 'dispatched', 'running')`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const osDistribution = await db
|
||||||
|
.select({
|
||||||
|
os: devices.osName,
|
||||||
|
total: count(),
|
||||||
|
})
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.organizationId, user.organizationId))
|
||||||
|
.groupBy(devices.osName);
|
||||||
|
|
||||||
|
const recentDevices = await db
|
||||||
|
.select({
|
||||||
|
id: devices.id,
|
||||||
|
name: devices.name,
|
||||||
|
status: devices.status,
|
||||||
|
osName: devices.osName,
|
||||||
|
lastHeartbeatAt: devices.lastHeartbeatAt,
|
||||||
|
})
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.organizationId, user.organizationId))
|
||||||
|
.orderBy(desc(devices.enrolledAt))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
const offlineThreshold = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
|
const [staleDevices] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.organizationId, user.organizationId),
|
||||||
|
eq(devices.status, "offline"),
|
||||||
|
sql`${devices.lastHeartbeatAt} < ${offlineThreshold}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalDevices: totalDevices.total,
|
||||||
|
onlineDevices: onlineDevices.total,
|
||||||
|
offlineDevices: offlineDevices.total,
|
||||||
|
warningDevices: warningDevices.total,
|
||||||
|
openAlerts: openAlerts.total,
|
||||||
|
pendingJobs: pendingJobs.total,
|
||||||
|
staleDevices: staleDevices.total,
|
||||||
|
osDistribution,
|
||||||
|
recentDevices,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { deployments, deploymentTargets, devices, users } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and, desc, count } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const deploymentsRouter = Router();
|
||||||
|
|
||||||
|
deploymentsRouter.use(authenticate);
|
||||||
|
|
||||||
|
deploymentsRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
|
||||||
|
const whereConditions = [eq(deployments.organizationId, user.organizationId)];
|
||||||
|
if (status) {
|
||||||
|
whereConditions.push(eq(deployments.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(deployments)
|
||||||
|
.where(and(...whereConditions));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: deployments.id,
|
||||||
|
organizationId: deployments.organizationId,
|
||||||
|
name: deployments.name,
|
||||||
|
description: deployments.description,
|
||||||
|
jobType: deployments.jobType,
|
||||||
|
parameters: deployments.parameters,
|
||||||
|
status: deployments.status,
|
||||||
|
createdByUserId: deployments.createdByUserId,
|
||||||
|
approvedByUserId: deployments.approvedByUserId,
|
||||||
|
approvedAt: deployments.approvedAt,
|
||||||
|
scheduledAt: deployments.scheduledAt,
|
||||||
|
startedAt: deployments.startedAt,
|
||||||
|
completedAt: deployments.completedAt,
|
||||||
|
totalTargets: deployments.totalTargets,
|
||||||
|
completedTargets: deployments.completedTargets,
|
||||||
|
failedTargets: deployments.failedTargets,
|
||||||
|
createdAt: deployments.createdAt,
|
||||||
|
updatedAt: deployments.updatedAt,
|
||||||
|
createdByName: users.displayName,
|
||||||
|
})
|
||||||
|
.from(deployments)
|
||||||
|
.leftJoin(users, eq(deployments.createdByUserId, users.id))
|
||||||
|
.where(and(...whereConditions))
|
||||||
|
.orderBy(desc(deployments.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalResult.total,
|
||||||
|
totalPages: Math.ceil(totalResult.total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDeploymentSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
jobType: z.enum([
|
||||||
|
"install_package", "update_package", "remove_package",
|
||||||
|
"start_service", "stop_service", "restart_service",
|
||||||
|
"check_service_status", "reboot_system", "shutdown_system",
|
||||||
|
"start_remote_session", "stop_remote_session",
|
||||||
|
"update_inventory", "update_system_info",
|
||||||
|
"install_from_file", "download_and_install", "install_registry_package",
|
||||||
|
]),
|
||||||
|
parameters: z.record(z.unknown()).default({}),
|
||||||
|
deviceIds: z.array(z.string().uuid()).min(1),
|
||||||
|
scheduledAt: z.string().datetime().optional(),
|
||||||
|
autoApprove: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentsRouter.post("/", authorize("operator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createDeploymentSchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const status = body.autoApprove ? "approved" : "draft";
|
||||||
|
|
||||||
|
const [deployment] = await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
name: body.name,
|
||||||
|
description: body.description || null,
|
||||||
|
jobType: body.jobType,
|
||||||
|
parameters: body.parameters,
|
||||||
|
status,
|
||||||
|
createdByUserId: authUser.userId,
|
||||||
|
approvedByUserId: body.autoApprove ? authUser.userId : null,
|
||||||
|
approvedAt: body.autoApprove ? new Date() : null,
|
||||||
|
scheduledAt: body.scheduledAt ? new Date(body.scheduledAt) : null,
|
||||||
|
totalTargets: body.deviceIds.length,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const targets = body.deviceIds.map((deviceId) => ({
|
||||||
|
deploymentId: deployment.id,
|
||||||
|
deviceId,
|
||||||
|
status: "pending" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(deploymentTargets).values(targets);
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
action: "deployment.create",
|
||||||
|
targetType: "deployment",
|
||||||
|
targetId: deployment.id,
|
||||||
|
deviceIds: body.deviceIds,
|
||||||
|
result: "success",
|
||||||
|
metadata: {
|
||||||
|
name: body.name,
|
||||||
|
jobType: body.jobType,
|
||||||
|
targetCount: body.deviceIds.length,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: deployment });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentsRouter.get("/:deploymentId", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deploymentId = req.params.deploymentId as string;
|
||||||
|
|
||||||
|
const [deployment] = await db
|
||||||
|
.select({
|
||||||
|
id: deployments.id,
|
||||||
|
organizationId: deployments.organizationId,
|
||||||
|
name: deployments.name,
|
||||||
|
description: deployments.description,
|
||||||
|
jobType: deployments.jobType,
|
||||||
|
parameters: deployments.parameters,
|
||||||
|
status: deployments.status,
|
||||||
|
createdByUserId: deployments.createdByUserId,
|
||||||
|
approvedByUserId: deployments.approvedByUserId,
|
||||||
|
approvedAt: deployments.approvedAt,
|
||||||
|
scheduledAt: deployments.scheduledAt,
|
||||||
|
startedAt: deployments.startedAt,
|
||||||
|
completedAt: deployments.completedAt,
|
||||||
|
totalTargets: deployments.totalTargets,
|
||||||
|
completedTargets: deployments.completedTargets,
|
||||||
|
failedTargets: deployments.failedTargets,
|
||||||
|
createdAt: deployments.createdAt,
|
||||||
|
updatedAt: deployments.updatedAt,
|
||||||
|
createdByName: users.displayName,
|
||||||
|
})
|
||||||
|
.from(deployments)
|
||||||
|
.leftJoin(users, eq(deployments.createdByUserId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deployments.id, deploymentId),
|
||||||
|
eq(deployments.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!deployment) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Deployment not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await db
|
||||||
|
.select({
|
||||||
|
id: deploymentTargets.id,
|
||||||
|
deploymentId: deploymentTargets.deploymentId,
|
||||||
|
deviceId: deploymentTargets.deviceId,
|
||||||
|
jobId: deploymentTargets.jobId,
|
||||||
|
status: deploymentTargets.status,
|
||||||
|
result: deploymentTargets.result,
|
||||||
|
startedAt: deploymentTargets.startedAt,
|
||||||
|
completedAt: deploymentTargets.completedAt,
|
||||||
|
createdAt: deploymentTargets.createdAt,
|
||||||
|
deviceName: devices.name,
|
||||||
|
})
|
||||||
|
.from(deploymentTargets)
|
||||||
|
.leftJoin(devices, eq(deploymentTargets.deviceId, devices.id))
|
||||||
|
.where(eq(deploymentTargets.deploymentId, deploymentId));
|
||||||
|
|
||||||
|
res.json({ success: true, data: { ...deployment, targets } });
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentsRouter.patch("/:deploymentId/approve", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deploymentId = req.params.deploymentId as string;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(deployments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deployments.id, deploymentId),
|
||||||
|
eq(deployments.organizationId, user.organizationId),
|
||||||
|
eq(deployments.status, "draft")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Deployment not found or not in draft status" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(deployments)
|
||||||
|
.set({
|
||||||
|
status: "approved",
|
||||||
|
approvedByUserId: user.userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(deployments.id, deploymentId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "deployment.approve",
|
||||||
|
targetType: "deployment",
|
||||||
|
targetId: deploymentId,
|
||||||
|
result: "success",
|
||||||
|
metadata: { name: existing.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentsRouter.patch("/:deploymentId/cancel", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deploymentId = req.params.deploymentId as string;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(deployments)
|
||||||
|
.set({ status: "cancelled", completedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deployments.id, deploymentId),
|
||||||
|
eq(deployments.organizationId, user.organizationId),
|
||||||
|
eq(deployments.status, "draft")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Deployment not found or not cancellable" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(deploymentTargets)
|
||||||
|
.set({ status: "cancelled" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deploymentTargets.deploymentId, deploymentId),
|
||||||
|
eq(deploymentTargets.status, "pending")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentsRouter.delete("/:deploymentId", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deploymentId = req.params.deploymentId as string;
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(deployments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deployments.id, deploymentId),
|
||||||
|
eq(deployments.organizationId, user.organizationId),
|
||||||
|
eq(deployments.status, "draft")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Deployment not found or not in draft status" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import {
|
||||||
|
devices,
|
||||||
|
inventorySnapshots,
|
||||||
|
metricSnapshots,
|
||||||
|
jobs,
|
||||||
|
jobTargets,
|
||||||
|
alerts,
|
||||||
|
complianceResults,
|
||||||
|
policies,
|
||||||
|
deviceCredentials,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and, desc, asc, ilike, count, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const devicesRouter = Router();
|
||||||
|
|
||||||
|
devicesRouter.use(authenticate);
|
||||||
|
|
||||||
|
devicesRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const deviceType = req.query.deviceType as string | undefined;
|
||||||
|
const sort = (req.query.sort as string) || "enrolled_at";
|
||||||
|
const order = req.query.order === "asc" ? asc : desc;
|
||||||
|
|
||||||
|
let whereClause = eq(devices.organizationId, user.organizationId);
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause = and(whereClause, ilike(devices.name, `%${search}%`))!;
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
whereClause = and(whereClause, eq(devices.status, status))!;
|
||||||
|
}
|
||||||
|
if (deviceType) {
|
||||||
|
whereClause = and(whereClause, eq(devices.deviceType, deviceType))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortColumn =
|
||||||
|
sort === "name"
|
||||||
|
? devices.name
|
||||||
|
: sort === "status"
|
||||||
|
? devices.status
|
||||||
|
: sort === "os_name"
|
||||||
|
? devices.osName
|
||||||
|
: devices.enrolledAt;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(devices)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const allDevices = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(order(sortColumn))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: allDevices,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalResult.total,
|
||||||
|
totalPages: Math.ceil(totalResult.total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesRouter.get("/:deviceId", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: device });
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesRouter.get(
|
||||||
|
"/:deviceId/inventory",
|
||||||
|
authorize("read_only"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.id, deviceId),
|
||||||
|
eq(devices.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshots = await db
|
||||||
|
.select()
|
||||||
|
.from(inventorySnapshots)
|
||||||
|
.where(eq(inventorySnapshots.deviceId, deviceId))
|
||||||
|
.orderBy(desc(inventorySnapshots.reportedAt))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
res.json({ success: true, data: snapshots });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
devicesRouter.get(
|
||||||
|
"/:deviceId/metrics",
|
||||||
|
authorize("read_only"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.id, deviceId),
|
||||||
|
eq(devices.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await db
|
||||||
|
.select()
|
||||||
|
.from(metricSnapshots)
|
||||||
|
.where(eq(metricSnapshots.deviceId, deviceId))
|
||||||
|
.orderBy(desc(metricSnapshots.reportedAt))
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
res.json({ success: true, data: metrics });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
devicesRouter.patch("/:deviceId", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
deviceGroupId: z.string().uuid().nullable().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
isDisabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = updateSchema.parse(req.body);
|
||||||
|
const [updated] = await db
|
||||||
|
.update(devices)
|
||||||
|
.set(body)
|
||||||
|
.where(
|
||||||
|
and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesRouter.delete("/:deviceId", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id, name: devices.name })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedJobIds = await db
|
||||||
|
.select({ jobId: jobTargets.jobId })
|
||||||
|
.from(jobTargets)
|
||||||
|
.where(eq(jobTargets.deviceId, deviceId));
|
||||||
|
|
||||||
|
const jobIdList = relatedJobIds.map((r) => r.jobId);
|
||||||
|
|
||||||
|
await db.delete(alerts).where(eq(alerts.deviceId, deviceId));
|
||||||
|
await db.delete(complianceResults).where(eq(complianceResults.deviceId, deviceId));
|
||||||
|
await db.delete(metricSnapshots).where(eq(metricSnapshots.deviceId, deviceId));
|
||||||
|
await db.delete(inventorySnapshots).where(eq(inventorySnapshots.deviceId, deviceId));
|
||||||
|
await db.delete(deviceCredentials).where(eq(deviceCredentials.deviceId, deviceId));
|
||||||
|
|
||||||
|
if (jobIdList.length > 0) {
|
||||||
|
await db.delete(jobTargets).where(eq(jobTargets.deviceId, deviceId));
|
||||||
|
|
||||||
|
const remainingTargets = await db
|
||||||
|
.select({ jobId: jobTargets.jobId })
|
||||||
|
.from(jobTargets)
|
||||||
|
.where(inArray(jobTargets.jobId, jobIdList));
|
||||||
|
|
||||||
|
const remainingJobIds = new Set(remainingTargets.map((r) => r.jobId));
|
||||||
|
const orphanedJobIds = jobIdList.filter((id) => !remainingJobIds.has(id));
|
||||||
|
|
||||||
|
if (orphanedJobIds.length > 0) {
|
||||||
|
await db.delete(jobs).where(inArray(jobs.id, orphanedJobIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(devices).where(eq(devices.id, deviceId));
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "device.delete",
|
||||||
|
targetType: "device",
|
||||||
|
targetId: deviceId,
|
||||||
|
deviceIds: [deviceId],
|
||||||
|
result: "success",
|
||||||
|
metadata: { name: device.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesRouter.get("/:deviceId/jobs", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id })
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceJobs = await db
|
||||||
|
.select({
|
||||||
|
id: jobs.id,
|
||||||
|
jobType: jobs.jobType,
|
||||||
|
status: jobs.status,
|
||||||
|
parameters: jobs.parameters,
|
||||||
|
targetStatus: jobTargets.status,
|
||||||
|
targetResult: jobTargets.result,
|
||||||
|
dispatchedAt: jobs.dispatchedAt,
|
||||||
|
startedAt: jobs.startedAt,
|
||||||
|
completedAt: jobs.completedAt,
|
||||||
|
createdAt: jobs.createdAt,
|
||||||
|
})
|
||||||
|
.from(jobTargets)
|
||||||
|
.innerJoin(jobs, eq(jobTargets.jobId, jobs.id))
|
||||||
|
.where(eq(jobTargets.deviceId, deviceId))
|
||||||
|
.orderBy(desc(jobs.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
res.json({ success: true, data: deviceJobs });
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesRouter.get("/:deviceId/alerts", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id })
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceAlerts = await db
|
||||||
|
.select()
|
||||||
|
.from(alerts)
|
||||||
|
.where(eq(alerts.deviceId, deviceId))
|
||||||
|
.orderBy(desc(alerts.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
res.json({ success: true, data: deviceAlerts });
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesRouter.get("/:deviceId/compliance", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id })
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select({
|
||||||
|
id: complianceResults.id,
|
||||||
|
status: complianceResults.status,
|
||||||
|
details: complianceResults.details,
|
||||||
|
evaluatedAt: complianceResults.evaluatedAt,
|
||||||
|
policyName: policies.name,
|
||||||
|
policyId: policies.id,
|
||||||
|
})
|
||||||
|
.from(complianceResults)
|
||||||
|
.innerJoin(policies, eq(complianceResults.policyId, policies.id))
|
||||||
|
.where(eq(complianceResults.deviceId, deviceId))
|
||||||
|
.orderBy(desc(complianceResults.evaluatedAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
res.json({ success: true, data: results });
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export const downloadsRouter = Router();
|
||||||
|
|
||||||
|
const AGENT_VERSION = "v0.1.0";
|
||||||
|
|
||||||
|
function resolveReleasesDir(): string {
|
||||||
|
const candidates = [
|
||||||
|
process.env.AGENT_RELEASES_DIR,
|
||||||
|
path.resolve(process.cwd(), "../../infrastructure/agent-releases"),
|
||||||
|
"/home/administrator/Projekte/FleetForge/infrastructure/agent-releases",
|
||||||
|
];
|
||||||
|
for (const dir of candidates) {
|
||||||
|
if (dir && fs.existsSync(dir)) return dir;
|
||||||
|
}
|
||||||
|
return candidates[candidates.length - 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELEASES_DIR = resolveReleasesDir();
|
||||||
|
console.log("[downloads] RELEASES_DIR:", RELEASES_DIR, "exists:", fs.existsSync(RELEASES_DIR));
|
||||||
|
|
||||||
|
const PLATFORMS = [
|
||||||
|
{
|
||||||
|
os: "linux",
|
||||||
|
arch: "amd64",
|
||||||
|
label: "Linux x86_64",
|
||||||
|
filename: "forge-agent-linux-amd64",
|
||||||
|
installHint: "chmod +x forge-agent-linux-amd64 && sudo mv forge-agent-linux-amd64 /usr/local/bin/forge-agent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "linux",
|
||||||
|
arch: "arm64",
|
||||||
|
label: "Linux ARM64",
|
||||||
|
filename: "forge-agent-linux-arm64",
|
||||||
|
installHint: "chmod +x forge-agent-linux-arm64 && sudo mv forge-agent-linux-arm64 /usr/local/bin/forge-agent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "windows",
|
||||||
|
arch: "amd64",
|
||||||
|
label: "Windows x86_64",
|
||||||
|
filename: "forge-agent-windows-amd64.exe",
|
||||||
|
installHint: "Move forge-agent-windows-amd64.exe to C:\\Program Files\\FleetForge\\forge-agent.exe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "darwin",
|
||||||
|
arch: "amd64",
|
||||||
|
label: "macOS Intel",
|
||||||
|
filename: "forge-agent-darwin-amd64",
|
||||||
|
installHint: "chmod +x forge-agent-darwin-amd64 && sudo mv forge-agent-darwin-amd64 /usr/local/bin/forge-agent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
os: "darwin",
|
||||||
|
arch: "arm64",
|
||||||
|
label: "macOS Apple Silicon",
|
||||||
|
filename: "forge-agent-darwin-arm64",
|
||||||
|
installHint: "chmod +x forge-agent-darwin-arm64 && sudo mv forge-agent-darwin-arm64 /usr/local/bin/forge-agent",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
downloadsRouter.use(authenticate);
|
||||||
|
|
||||||
|
downloadsRouter.get("/agent", authorize("read_only"), (_req, res) => {
|
||||||
|
const versionDir = path.join(RELEASES_DIR, AGENT_VERSION);
|
||||||
|
|
||||||
|
console.log("[downloads] RELEASES_DIR:", RELEASES_DIR);
|
||||||
|
console.log("[downloads] versionDir:", versionDir);
|
||||||
|
console.log("[downloads] exists:", fs.existsSync(versionDir));
|
||||||
|
|
||||||
|
const platforms = PLATFORMS.map((p) => {
|
||||||
|
const filePath = path.join(versionDir, p.filename);
|
||||||
|
const exists = fs.existsSync(filePath);
|
||||||
|
const size = exists ? fs.statSync(filePath).size : 0;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
available: exists,
|
||||||
|
size,
|
||||||
|
sizeFormatted: exists ? formatBytes(size) : "N/A",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
version: AGENT_VERSION,
|
||||||
|
platforms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadsRouter.get("/agent/:filename", authorize("read_only"), (req, res) => {
|
||||||
|
const filename = req.params.filename as string;
|
||||||
|
|
||||||
|
const platform = PLATFORMS.find((p) => p.filename === filename);
|
||||||
|
if (!platform) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "File not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(RELEASES_DIR, AGENT_VERSION, filename);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Binary not available for this platform" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader("Content-Length", fs.statSync(filePath).size);
|
||||||
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { enrollmentTokens } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { generateEnrollmentToken, hashToken } from "../lib/tokens.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const enrollmentTokensRouter = Router();
|
||||||
|
|
||||||
|
enrollmentTokensRouter.use(authenticate);
|
||||||
|
|
||||||
|
enrollmentTokensRouter.get("/", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const tokens = await db
|
||||||
|
.select({
|
||||||
|
id: enrollmentTokens.id,
|
||||||
|
expiresAt: enrollmentTokens.expiresAt,
|
||||||
|
maxUses: enrollmentTokens.maxUses,
|
||||||
|
useCount: enrollmentTokens.useCount,
|
||||||
|
isRevoked: enrollmentTokens.isRevoked,
|
||||||
|
label: enrollmentTokens.label,
|
||||||
|
createdAt: enrollmentTokens.createdAt,
|
||||||
|
})
|
||||||
|
.from(enrollmentTokens)
|
||||||
|
.where(eq(enrollmentTokens.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
res.json({ success: true, data: tokens });
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTokenSchema = z.object({
|
||||||
|
label: z.string().max(255).optional(),
|
||||||
|
expiresInHours: z.number().min(1).max(720).default(24),
|
||||||
|
maxUses: z.number().min(1).max(100).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
enrollmentTokensRouter.post("/", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createTokenSchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + body.expiresInHours);
|
||||||
|
|
||||||
|
const [token] = await db
|
||||||
|
.insert(enrollmentTokens)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
maxUses: body.maxUses ?? null,
|
||||||
|
label: body.label || null,
|
||||||
|
createdByUserId: authUser.userId,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: enrollmentTokens.id,
|
||||||
|
expiresAt: enrollmentTokens.expiresAt,
|
||||||
|
maxUses: enrollmentTokens.maxUses,
|
||||||
|
label: enrollmentTokens.label,
|
||||||
|
createdAt: enrollmentTokens.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
action: "enrollment_token.create",
|
||||||
|
targetType: "enrollment_token",
|
||||||
|
targetId: token.id,
|
||||||
|
result: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...token,
|
||||||
|
token: rawToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
enrollmentTokensRouter.delete(
|
||||||
|
"/:tokenId",
|
||||||
|
authorize("admin"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const tokenId = req.params.tokenId as string;
|
||||||
|
|
||||||
|
const [revoked] = await db
|
||||||
|
.update(enrollmentTokens)
|
||||||
|
.set({ isRevoked: true })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(enrollmentTokens.id, tokenId),
|
||||||
|
eq(enrollmentTokens.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: enrollmentTokens.id });
|
||||||
|
|
||||||
|
if (!revoked) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Token not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "enrollment_token.revoke",
|
||||||
|
targetType: "enrollment_token",
|
||||||
|
targetId: tokenId,
|
||||||
|
result: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { deviceGroups, devices } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const groupsRouter = Router();
|
||||||
|
|
||||||
|
groupsRouter.use(authenticate);
|
||||||
|
|
||||||
|
groupsRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const groups = await db
|
||||||
|
.select({
|
||||||
|
id: deviceGroups.id,
|
||||||
|
name: deviceGroups.name,
|
||||||
|
description: deviceGroups.description,
|
||||||
|
createdAt: deviceGroups.createdAt,
|
||||||
|
})
|
||||||
|
.from(deviceGroups)
|
||||||
|
.where(eq(deviceGroups.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
res.json({ success: true, data: groups });
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
groupsRouter.post("/", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createGroupSchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.insert(deviceGroups)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
name: body.name,
|
||||||
|
description: body.description || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: group });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
groupsRouter.delete("/:groupId", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const groupId = req.params.groupId as string;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(devices)
|
||||||
|
.set({ deviceGroupId: null })
|
||||||
|
.where(eq(devices.deviceGroupId, groupId));
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(deviceGroups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deviceGroups.id, groupId),
|
||||||
|
eq(deviceGroups.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: deviceGroups.id });
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Group not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { enrollmentTokens } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { generateEnrollmentToken, hashToken } from "../lib/tokens.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export const installersRouter = Router();
|
||||||
|
installersRouter.use(authenticate);
|
||||||
|
|
||||||
|
const AGENT_VERSION = "0.1.0";
|
||||||
|
const INSTALLER_DIR = path.resolve(
|
||||||
|
process.env.INSTALLER_DIR || path.join(process.cwd(), "../../infrastructure/installer")
|
||||||
|
);
|
||||||
|
const INSTALLER_CANDIDATES = [
|
||||||
|
INSTALLER_DIR,
|
||||||
|
"/home/administrator/Projekte/FleetForge/infrastructure/installer",
|
||||||
|
];
|
||||||
|
const AGENT_RELEASES_DIR = path.resolve(
|
||||||
|
process.env.AGENT_RELEASES_DIR || path.join(process.cwd(), "../../infrastructure/agent-releases")
|
||||||
|
);
|
||||||
|
const MSI_CACHE_DIR = path.resolve(
|
||||||
|
process.env.MSI_CACHE_DIR || path.join(os.tmpdir(), "fleetforge-msi-cache")
|
||||||
|
);
|
||||||
|
|
||||||
|
function getAgentBinaryPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(AGENT_RELEASES_DIR, AGENT_VERSION, "forge-agent-windows-amd64.exe"),
|
||||||
|
path.join(AGENT_RELEASES_DIR, "forge-agent-windows-amd64.exe"),
|
||||||
|
"/home/administrator/Projekte/FleetForge/infrastructure/agent-releases/v0.1.0/forge-agent-windows-amd64.exe",
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
throw new Error("Windows agent binary not found in " + AGENT_RELEASES_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSchema = z.object({
|
||||||
|
serverUrl: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
installersRouter.get("/", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const tokens = await db
|
||||||
|
.select({
|
||||||
|
id: enrollmentTokens.id,
|
||||||
|
expiresAt: enrollmentTokens.expiresAt,
|
||||||
|
maxUses: enrollmentTokens.maxUses,
|
||||||
|
useCount: enrollmentTokens.useCount,
|
||||||
|
isRevoked: enrollmentTokens.isRevoked,
|
||||||
|
label: enrollmentTokens.label,
|
||||||
|
createdAt: enrollmentTokens.createdAt,
|
||||||
|
})
|
||||||
|
.from(enrollmentTokens)
|
||||||
|
.where(eq(enrollmentTokens.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
const installerFiles = fs.existsSync(MSI_CACHE_DIR)
|
||||||
|
? fs.readdirSync(MSI_CACHE_DIR).filter((f) => f.endsWith(".msi"))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
version: AGENT_VERSION,
|
||||||
|
tokens: tokens.map((t) => ({
|
||||||
|
...t,
|
||||||
|
label: t.label || "Installer Token",
|
||||||
|
})),
|
||||||
|
cachedInstallers: installerFiles.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[installers] List error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: "Failed to list installers" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installersRouter.post("/generate", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const body = generateSchema.parse(req.body);
|
||||||
|
const serverUrl = body.serverUrl;
|
||||||
|
|
||||||
|
const rawToken = generateEnrollmentToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||||
|
|
||||||
|
const [token] = await db
|
||||||
|
.insert(enrollmentTokens)
|
||||||
|
.values({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
maxUses: 1,
|
||||||
|
label: `MSI Installer - ${serverUrl}`,
|
||||||
|
createdByUserId: user.userId,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: enrollmentTokens.id,
|
||||||
|
label: enrollmentTokens.label,
|
||||||
|
createdAt: enrollmentTokens.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ff-installer-"));
|
||||||
|
try {
|
||||||
|
const configData = {
|
||||||
|
server_url: serverUrl,
|
||||||
|
enrollment_token: rawToken,
|
||||||
|
device_id: "",
|
||||||
|
credential: "",
|
||||||
|
label: "",
|
||||||
|
heartbeat_interval_seconds: 30,
|
||||||
|
};
|
||||||
|
const configPath = path.join(tmpDir, "config.json");
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2));
|
||||||
|
|
||||||
|
const agentBinary = getAgentBinaryPath();
|
||||||
|
const filename = `forge-agent-${user.organizationId.slice(0, 8)}-${Date.now()}.msi`;
|
||||||
|
const outputPath = path.join(tmpDir, filename);
|
||||||
|
|
||||||
|
fs.copyFileSync(agentBinary, path.join(tmpDir, "forge-agent.exe"));
|
||||||
|
|
||||||
|
console.log(`[installers] Generating MSI for org ${user.organizationId}...`);
|
||||||
|
|
||||||
|
let wxsPath = "";
|
||||||
|
for (const dir of INSTALLER_CANDIDATES) {
|
||||||
|
const p = path.join(dir, "agent.wxs");
|
||||||
|
if (fs.existsSync(p)) { wxsPath = p; break; }
|
||||||
|
}
|
||||||
|
if (!wxsPath) {
|
||||||
|
throw new Error("WiX template not found at " + wxsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedWxsPath = path.join(tmpDir, "agent.wxs");
|
||||||
|
const wxsContent = fs.readFileSync(wxsPath, "utf-8")
|
||||||
|
.replace(/__VERSION__/g, AGENT_VERSION.replace("v", ""));
|
||||||
|
fs.writeFileSync(processedWxsPath, wxsContent);
|
||||||
|
|
||||||
|
const dockerCmd = `docker run --rm -v ${tmpDir}:/work fleetforge-wixl wixl -o /work/${filename} -D AgentBinaryPath=/work/forge-agent.exe -D ConfigFilePath=/work/config.json /work/agent.wxs`;
|
||||||
|
const { stdout, stderr } = await execFileAsync(
|
||||||
|
"sg",
|
||||||
|
["docker", "-c", dockerCmd],
|
||||||
|
{
|
||||||
|
timeout: 120000,
|
||||||
|
env: { ...process.env },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(outputPath)) {
|
||||||
|
throw new Error("MSI generation failed. Docker output: " + (stderr || stdout));
|
||||||
|
}
|
||||||
|
|
||||||
|
const msiSize = fs.statSync(outputPath).size;
|
||||||
|
if (msiSize < 1000) {
|
||||||
|
throw new Error("Generated MSI is too small (" + msiSize + " bytes), likely corrupt");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[installers] MSI generated: ${filename} (${msiSize} bytes)`);
|
||||||
|
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="forge-agent-${user.organizationId.slice(0, 8)}.msi"`);
|
||||||
|
res.setHeader("Content-Length", msiSize);
|
||||||
|
res.setHeader("Content-Type", "application/x-msi");
|
||||||
|
fs.createReadStream(outputPath).pipe(res);
|
||||||
|
|
||||||
|
res.on("finish", () => {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "installer.generate",
|
||||||
|
targetType: "installer",
|
||||||
|
targetId: token.id,
|
||||||
|
result: "success",
|
||||||
|
metadata: {
|
||||||
|
serverUrl,
|
||||||
|
tokenId: token.id,
|
||||||
|
filename,
|
||||||
|
fileSize: msiSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[installers] Generate error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: `Failed to generate installer: ${(err as Error).message}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { jobs, jobTargets, devices } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and, desc, count, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const jobsRouter = Router();
|
||||||
|
|
||||||
|
jobsRouter.use(authenticate);
|
||||||
|
|
||||||
|
jobsRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(jobs)
|
||||||
|
.where(eq(jobs.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
const allJobs = await db
|
||||||
|
.select()
|
||||||
|
.from(jobs)
|
||||||
|
.where(eq(jobs.organizationId, user.organizationId))
|
||||||
|
.orderBy(desc(jobs.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: allJobs,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalResult.total,
|
||||||
|
totalPages: Math.ceil(totalResult.total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createJobSchema = z.object({
|
||||||
|
jobType: z.enum([
|
||||||
|
"update_inventory",
|
||||||
|
"update_system_info",
|
||||||
|
"check_service_status",
|
||||||
|
"start_service",
|
||||||
|
"stop_service",
|
||||||
|
"restart_service",
|
||||||
|
"install_package",
|
||||||
|
"update_package",
|
||||||
|
"remove_package",
|
||||||
|
"reboot_system",
|
||||||
|
"shutdown_system",
|
||||||
|
"start_remote_session",
|
||||||
|
"stop_remote_session",
|
||||||
|
"install_from_file",
|
||||||
|
"download_and_install",
|
||||||
|
"install_registry_package",
|
||||||
|
]),
|
||||||
|
deviceIds: z.array(z.string().uuid()).min(1),
|
||||||
|
parameters: z.record(z.unknown()).default({}),
|
||||||
|
expiresInMinutes: z.number().min(1).max(1440).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
jobsRouter.post("/", authorize("operator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createJobSchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const expiresAt = body.expiresInMinutes
|
||||||
|
? new Date(Date.now() + body.expiresInMinutes * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [job] = await db
|
||||||
|
.insert(jobs)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
createdByUserId: authUser.userId,
|
||||||
|
jobType: body.jobType,
|
||||||
|
parameters: body.parameters,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const targets = body.deviceIds.map((deviceId) => ({
|
||||||
|
jobId: job.id,
|
||||||
|
deviceId,
|
||||||
|
status: "queued" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(jobTargets).values(targets);
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
action: "job.create",
|
||||||
|
targetType: "job",
|
||||||
|
targetId: job.id,
|
||||||
|
deviceIds: body.deviceIds,
|
||||||
|
result: "success",
|
||||||
|
metadata: { jobType: body.jobType },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: job });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jobsRouter.get("/:jobId", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const jobId = req.params.jobId as string;
|
||||||
|
|
||||||
|
const [job] = await db
|
||||||
|
.select()
|
||||||
|
.from(jobs)
|
||||||
|
.where(and(eq(jobs.id, jobId), eq(jobs.organizationId, user.organizationId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Job not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await db
|
||||||
|
.select({
|
||||||
|
id: jobTargets.id,
|
||||||
|
jobId: jobTargets.jobId,
|
||||||
|
deviceId: jobTargets.deviceId,
|
||||||
|
deviceName: devices.name,
|
||||||
|
status: jobTargets.status,
|
||||||
|
result: jobTargets.result,
|
||||||
|
startedAt: jobTargets.startedAt,
|
||||||
|
completedAt: jobTargets.completedAt,
|
||||||
|
createdAt: jobTargets.createdAt,
|
||||||
|
})
|
||||||
|
.from(jobTargets)
|
||||||
|
.leftJoin(devices, eq(jobTargets.deviceId, devices.id))
|
||||||
|
.where(eq(jobTargets.jobId, jobId));
|
||||||
|
|
||||||
|
res.json({ success: true, data: { ...job, targets } });
|
||||||
|
});
|
||||||
|
|
||||||
|
jobsRouter.patch("/:jobId/cancel", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const jobId = req.params.jobId as string;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(jobs)
|
||||||
|
.set({ status: "cancelled", completedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobs.id, jobId),
|
||||||
|
eq(jobs.organizationId, user.organizationId),
|
||||||
|
inArray(jobs.status, ["queued", "dispatched", "running"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: jobs.id });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Job not found or not cancellable" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(jobTargets)
|
||||||
|
.set({ status: "cancelled", completedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobTargets.jobId, jobId),
|
||||||
|
inArray(jobTargets.status, ["queued", "dispatched", "running"])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "job.cancel",
|
||||||
|
targetType: "job",
|
||||||
|
targetId: jobId,
|
||||||
|
result: "success",
|
||||||
|
metadata: { message: "Job cancelled" },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
jobsRouter.delete("/:jobId", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const jobId = req.params.jobId as string;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: jobs.id })
|
||||||
|
.from(jobs)
|
||||||
|
.where(
|
||||||
|
and(eq(jobs.id, jobId), eq(jobs.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Job not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(jobTargets).where(eq(jobTargets.jobId, jobId));
|
||||||
|
await db.delete(jobs).where(eq(jobs.id, jobId));
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "job.delete",
|
||||||
|
targetType: "job",
|
||||||
|
targetId: jobId,
|
||||||
|
result: "success",
|
||||||
|
metadata: { message: "Job deleted" },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { policies, complianceResults } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const policiesRouter = Router();
|
||||||
|
|
||||||
|
policiesRouter.use(authenticate);
|
||||||
|
|
||||||
|
policiesRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const allPolicies = await db
|
||||||
|
.select()
|
||||||
|
.from(policies)
|
||||||
|
.where(eq(policies.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
res.json({ success: true, data: allPolicies });
|
||||||
|
});
|
||||||
|
|
||||||
|
const policyRuleSchema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
condition: z.string(),
|
||||||
|
value: z.union([z.string(), z.number(), z.boolean()]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPolicySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
rules: z.array(policyRuleSchema).default([]),
|
||||||
|
isEnabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePolicySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
rules: z.array(policyRuleSchema).optional(),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
policiesRouter.get("/:policyId", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const policyId = req.params.policyId as string;
|
||||||
|
|
||||||
|
const [policy] = await db
|
||||||
|
.select()
|
||||||
|
.from(policies)
|
||||||
|
.where(
|
||||||
|
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Policy not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: policy });
|
||||||
|
});
|
||||||
|
|
||||||
|
policiesRouter.post("/", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createPolicySchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const [policy] = await db
|
||||||
|
.insert(policies)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
name: body.name,
|
||||||
|
description: body.description || null,
|
||||||
|
rules: body.rules,
|
||||||
|
isEnabled: body.isEnabled,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: policy });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
policiesRouter.put("/:policyId", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const policyId = req.params.policyId as string;
|
||||||
|
const body = updatePolicySchema.parse(req.body);
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(policies)
|
||||||
|
.where(
|
||||||
|
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Policy not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (body.name !== undefined) updates.name = body.name;
|
||||||
|
if (body.description !== undefined) updates.description = body.description;
|
||||||
|
if (body.rules !== undefined) updates.rules = body.rules;
|
||||||
|
if (body.isEnabled !== undefined) updates.isEnabled = body.isEnabled;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
res.json({ success: true, data: existing });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.updatedAt = new Date();
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(policies)
|
||||||
|
.set(updates)
|
||||||
|
.where(
|
||||||
|
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
policiesRouter.delete("/:policyId", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const policyId = req.params.policyId as string;
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(policies)
|
||||||
|
.where(
|
||||||
|
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.returning({ id: policies.id });
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Policy not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
policiesRouter.get(
|
||||||
|
"/:policyId/compliance",
|
||||||
|
authorize("read_only"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const policyId = req.params.policyId as string;
|
||||||
|
|
||||||
|
const [policy] = await db
|
||||||
|
.select()
|
||||||
|
.from(policies)
|
||||||
|
.where(
|
||||||
|
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Policy not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(complianceResults)
|
||||||
|
.where(eq(complianceResults.policyId, policyId))
|
||||||
|
.orderBy(desc(complianceResults.evaluatedAt));
|
||||||
|
|
||||||
|
res.json({ success: true, data: results });
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import {
|
||||||
|
remoteSessions,
|
||||||
|
jobs,
|
||||||
|
jobTargets,
|
||||||
|
devices,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
|
export const remoteRouter = Router();
|
||||||
|
|
||||||
|
remoteRouter.use(authenticate);
|
||||||
|
|
||||||
|
const createSessionSchema = z.object({
|
||||||
|
deviceId: z.string().uuid(),
|
||||||
|
vncPort: z.number().int().min(1).max(65535).default(5900),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ERROR_MSG = (code: string, message: string) =>
|
||||||
|
({ success: false, error: { code, message } });
|
||||||
|
|
||||||
|
remoteRouter.post("/sessions", authorize("operator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createSessionSchema.parse(req.body);
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(eq(devices.id, body.deviceId), eq(devices.organizationId, user.organizationId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json(ERROR_MSG("NOT_FOUND", "Device not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closed", endedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(remoteSessions.deviceId, body.deviceId),
|
||||||
|
eq(remoteSessions.status, "pending")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closed", endedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(remoteSessions.deviceId, body.deviceId),
|
||||||
|
eq(remoteSessions.status, "starting")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closed", endedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(remoteSessions.deviceId, body.deviceId),
|
||||||
|
eq(remoteSessions.status, "active")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closed", endedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(remoteSessions.deviceId, body.deviceId),
|
||||||
|
eq(remoteSessions.status, "closing")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const vncPassword = randomBytes(4).toString("hex");
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.insert(remoteSessions)
|
||||||
|
.values({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
deviceId: body.deviceId,
|
||||||
|
createdByUserId: user.userId,
|
||||||
|
status: "pending",
|
||||||
|
vncPort: body.vncPort,
|
||||||
|
vncPassword,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [job] = await db
|
||||||
|
.insert(jobs)
|
||||||
|
.values({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
createdByUserId: user.userId,
|
||||||
|
jobType: "start_remote_session",
|
||||||
|
parameters: {
|
||||||
|
session_id: session.id,
|
||||||
|
vnc_port: body.vncPort,
|
||||||
|
vnc_password: vncPassword,
|
||||||
|
},
|
||||||
|
status: "dispatched",
|
||||||
|
dispatchedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(jobTargets).values({
|
||||||
|
jobId: job.id,
|
||||||
|
deviceId: body.deviceId,
|
||||||
|
status: "dispatched",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ jobId: job.id })
|
||||||
|
.where(eq(remoteSessions.id, session.id));
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "remote.session.create",
|
||||||
|
targetType: "remote_session",
|
||||||
|
targetId: session.id,
|
||||||
|
deviceIds: [body.deviceId],
|
||||||
|
result: "success",
|
||||||
|
metadata: { vncPort: body.vncPort },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: session.id,
|
||||||
|
deviceId: session.deviceId,
|
||||||
|
status: session.status,
|
||||||
|
vncPort: session.vncPort,
|
||||||
|
vncPassword: session.vncPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json(ERROR_MSG("VALIDATION_ERROR", "Invalid request body"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
remoteRouter.get("/sessions", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const all = await db
|
||||||
|
.select({
|
||||||
|
id: remoteSessions.id,
|
||||||
|
deviceId: remoteSessions.deviceId,
|
||||||
|
status: remoteSessions.status,
|
||||||
|
vncPort: remoteSessions.vncPort,
|
||||||
|
startedAt: remoteSessions.startedAt,
|
||||||
|
endedAt: remoteSessions.endedAt,
|
||||||
|
createdAt: remoteSessions.createdAt,
|
||||||
|
deviceName: devices.name,
|
||||||
|
})
|
||||||
|
.from(remoteSessions)
|
||||||
|
.innerJoin(devices, eq(devices.id, remoteSessions.deviceId))
|
||||||
|
.where(eq(remoteSessions.organizationId, user.organizationId))
|
||||||
|
.orderBy(desc(remoteSessions.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
res.json({ success: true, data: all });
|
||||||
|
});
|
||||||
|
|
||||||
|
remoteRouter.get("/sessions/:sessionId", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: remoteSessions.id,
|
||||||
|
deviceId: remoteSessions.deviceId,
|
||||||
|
status: remoteSessions.status,
|
||||||
|
vncPort: remoteSessions.vncPort,
|
||||||
|
vncPassword: remoteSessions.vncPassword,
|
||||||
|
errorMessage: remoteSessions.errorMessage,
|
||||||
|
startedAt: remoteSessions.startedAt,
|
||||||
|
endedAt: remoteSessions.endedAt,
|
||||||
|
createdAt: remoteSessions.createdAt,
|
||||||
|
deviceName: devices.name,
|
||||||
|
})
|
||||||
|
.from(remoteSessions)
|
||||||
|
.innerJoin(devices, eq(devices.id, remoteSessions.deviceId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(remoteSessions.id, sessionId),
|
||||||
|
eq(remoteSessions.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
res.status(404).json(ERROR_MSG("NOT_FOUND", "Session not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: row });
|
||||||
|
});
|
||||||
|
|
||||||
|
remoteRouter.delete("/sessions/:sessionId", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(remoteSessions.id, sessionId),
|
||||||
|
eq(remoteSessions.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json(ERROR_MSG("NOT_FOUND", "Session not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === "active" || session.status === "starting" || session.status === "pending") {
|
||||||
|
const [job] = await db
|
||||||
|
.insert(jobs)
|
||||||
|
.values({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
createdByUserId: user.userId,
|
||||||
|
jobType: "stop_remote_session",
|
||||||
|
parameters: { session_id: session.id },
|
||||||
|
status: "dispatched",
|
||||||
|
dispatchedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(jobTargets).values({
|
||||||
|
jobId: job.id,
|
||||||
|
deviceId: session.deviceId,
|
||||||
|
status: "dispatched",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closing" })
|
||||||
|
.where(eq(remoteSessions.id, session.id));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closed", endedAt: new Date() })
|
||||||
|
.where(eq(remoteSessions.id, session.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "remote.session.delete",
|
||||||
|
targetType: "remote_session",
|
||||||
|
targetId: session.id,
|
||||||
|
deviceIds: [session.deviceId],
|
||||||
|
result: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: { id: session.id, status: "closing" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { remoteSessions };
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { devices, softwarePackages } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { eq, and, sql, desc, ilike, count } from "drizzle-orm";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const SOFTWARE_DIR = path.resolve(
|
||||||
|
process.env.SOFTWARE_DIR || path.join(__dirname, "../../../../infrastructure/software-releases")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(SOFTWARE_DIR)) {
|
||||||
|
fs.mkdirSync(SOFTWARE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_EXTENSIONS: Record<string, string[]> = {
|
||||||
|
windows: [".msi", ".exe", ".msix", ".appx"],
|
||||||
|
linux: [".deb", ".rpm", ".tar.gz", ".tar.xz"],
|
||||||
|
macos: [".dmg", ".pkg", ".tar.gz"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
const orgDir = path.join(SOFTWARE_DIR, (_req as any).user?.organizationId || "default");
|
||||||
|
fs.mkdirSync(orgDir, { recursive: true });
|
||||||
|
cb(null, orgDir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const unique = crypto.randomBytes(8).toString("hex");
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `${Date.now()}-${unique}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const allAllowed = Object.values(ALLOWED_EXTENSIONS).flat();
|
||||||
|
if (allAllowed.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`File type ${ext} not allowed. Supported: ${allAllowed.join(", ")}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const softwareRouter = Router();
|
||||||
|
|
||||||
|
softwareRouter.use(authenticate);
|
||||||
|
|
||||||
|
softwareRouter.get("/inventory", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const packageName = req.query.package as string | undefined;
|
||||||
|
|
||||||
|
const { rows: latestSnapshots } = await db.execute(sql`
|
||||||
|
SELECT DISTINCT ON (device_id) device_id, packages
|
||||||
|
FROM inventory_snapshots
|
||||||
|
WHERE device_id IN (
|
||||||
|
SELECT id FROM devices
|
||||||
|
WHERE organization_id = ${user.organizationId}
|
||||||
|
AND is_disabled = false AND is_archived = false
|
||||||
|
)
|
||||||
|
ORDER BY device_id, reported_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const packageMap = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
manager: string;
|
||||||
|
devices: Array<{ deviceId: string; deviceName: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { rows: deviceNames } = await db.execute(sql`
|
||||||
|
SELECT id, name FROM devices
|
||||||
|
WHERE organization_id = ${user.organizationId}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const nameMap = new Map<string, string>();
|
||||||
|
for (const d of deviceNames as any[]) {
|
||||||
|
nameMap.set(d.id, d.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snapshot of latestSnapshots as any[]) {
|
||||||
|
const pkgs = (snapshot.packages || []) as any[];
|
||||||
|
for (const pkg of pkgs) {
|
||||||
|
if (packageName && pkg.name !== packageName) continue;
|
||||||
|
|
||||||
|
const key = `${pkg.name}@${pkg.version}@${pkg.manager}`;
|
||||||
|
if (packageMap.has(key)) {
|
||||||
|
packageMap.get(key)!.devices.push({
|
||||||
|
deviceId: snapshot.device_id,
|
||||||
|
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
packageMap.set(key, {
|
||||||
|
name: pkg.name,
|
||||||
|
version: pkg.version,
|
||||||
|
manager: pkg.manager,
|
||||||
|
devices: [{
|
||||||
|
deviceId: snapshot.device_id,
|
||||||
|
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packages = Array.from(packageMap.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueNames = new Set(packages.map((p) => p.name));
|
||||||
|
const summary = Array.from(uniqueNames).map((name) => {
|
||||||
|
const versions = packages.filter((p) => p.name === name);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
versions: versions.map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
manager: v.manager,
|
||||||
|
deviceCount: v.devices.length,
|
||||||
|
devices: v.devices,
|
||||||
|
})),
|
||||||
|
totalDevices: versions.reduce((sum, v) => sum + v.devices.length, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: summary });
|
||||||
|
});
|
||||||
|
|
||||||
|
softwareRouter.get("/inventory/devices/:deviceId", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const deviceId = req.params.deviceId as string;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id, name: devices.name })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.id, deviceId),
|
||||||
|
eq(devices.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: snapshots } = await db.execute(sql`
|
||||||
|
SELECT packages, services, reported_at
|
||||||
|
FROM inventory_snapshots
|
||||||
|
WHERE device_id = ${deviceId}
|
||||||
|
ORDER BY reported_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const latest = (snapshots as any[])[0] || null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
deviceName: device.name,
|
||||||
|
packages: latest?.packages || [],
|
||||||
|
services: latest?.services || [],
|
||||||
|
reportedAt: latest?.reported_at || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
softwareRouter.get("/services", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
|
||||||
|
const { rows: latestSnapshots } = await db.execute(sql`
|
||||||
|
SELECT DISTINCT ON (device_id) device_id, services
|
||||||
|
FROM inventory_snapshots
|
||||||
|
WHERE device_id IN (
|
||||||
|
SELECT id FROM devices
|
||||||
|
WHERE organization_id = ${user.organizationId}
|
||||||
|
AND is_disabled = false AND is_archived = false
|
||||||
|
)
|
||||||
|
ORDER BY device_id, reported_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const serviceMap = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
enabled: boolean;
|
||||||
|
devices: Array<{ deviceId: string; deviceName: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { rows: deviceNames } = await db.execute(sql`
|
||||||
|
SELECT id, name FROM devices
|
||||||
|
WHERE organization_id = ${user.organizationId}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const nameMap = new Map<string, string>();
|
||||||
|
for (const d of deviceNames as any[]) {
|
||||||
|
nameMap.set(d.id, d.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snapshot of latestSnapshots as any[]) {
|
||||||
|
const svcs = (snapshot.services || []) as any[];
|
||||||
|
for (const svc of svcs) {
|
||||||
|
const key = `${svc.name}@${svc.status}`;
|
||||||
|
if (serviceMap.has(key)) {
|
||||||
|
serviceMap.get(key)!.devices.push({
|
||||||
|
deviceId: snapshot.device_id,
|
||||||
|
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
serviceMap.set(key, {
|
||||||
|
name: svc.name,
|
||||||
|
status: svc.status,
|
||||||
|
enabled: svc.enabled,
|
||||||
|
devices: [{
|
||||||
|
deviceId: snapshot.device_id,
|
||||||
|
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = Array.from(serviceMap.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: services });
|
||||||
|
});
|
||||||
|
|
||||||
|
softwareRouter.get("/repository", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const platform = req.query.platform as string | undefined;
|
||||||
|
|
||||||
|
let whereClause = eq(softwarePackages.organizationId, user.organizationId);
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause = and(whereClause, ilike(softwarePackages.name, `%${search}%`))!;
|
||||||
|
}
|
||||||
|
if (platform) {
|
||||||
|
whereClause = and(whereClause, eq(softwarePackages.platform, platform))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(softwarePackages)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const pkgs = await db
|
||||||
|
.select()
|
||||||
|
.from(softwarePackages)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(softwarePackages.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pkgs,
|
||||||
|
meta: { page, limit, total: totalResult.total, totalPages: Math.ceil(totalResult.total / limit) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
softwareRouter.post("/repository", authorize("admin"), upload.single("file"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "No file uploaded" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
const name = body.name || path.basename(file.originalname, path.extname(file.originalname));
|
||||||
|
const version = body.version || "1.0.0";
|
||||||
|
const packageType = body.package_type || path.extname(file.originalname).replace(".", "");
|
||||||
|
const platform = body.platform || detectPlatform(file.originalname);
|
||||||
|
const architecture = body.architecture || "amd64";
|
||||||
|
|
||||||
|
const checksum = crypto.createHash("sha256").update(fs.readFileSync(file.path)).digest("hex");
|
||||||
|
|
||||||
|
const [pkg] = await db
|
||||||
|
.insert(softwarePackages)
|
||||||
|
.values({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
name,
|
||||||
|
description: body.description || null,
|
||||||
|
version,
|
||||||
|
packageType,
|
||||||
|
platform,
|
||||||
|
architecture,
|
||||||
|
filename: file.filename,
|
||||||
|
fileSize: file.size,
|
||||||
|
checksum,
|
||||||
|
installCommand: body.install_command || null,
|
||||||
|
tags: body.tags ? JSON.parse(body.tags) : [],
|
||||||
|
uploadedByUserId: user.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: pkg });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[software/upload] Error:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "INTERNAL_ERROR", message: err.message || "Upload failed" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
softwareRouter.get("/repository/:packageId/download", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const packageId = req.params.packageId as string;
|
||||||
|
|
||||||
|
const [pkg] = await db
|
||||||
|
.select()
|
||||||
|
.from(softwarePackages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(softwarePackages.id, packageId),
|
||||||
|
eq(softwarePackages.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Package not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgDir = path.join(SOFTWARE_DIR, user.organizationId);
|
||||||
|
const filePath = path.join(orgDir, pkg.filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "File not found on disk" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${pkg.name}-${pkg.version}${path.extname(pkg.filename)}"`);
|
||||||
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
|
res.setHeader("Content-Length", pkg.fileSize);
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
softwareRouter.delete("/repository/:packageId", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const packageId = req.params.packageId as string;
|
||||||
|
|
||||||
|
const [pkg] = await db
|
||||||
|
.select()
|
||||||
|
.from(softwarePackages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(softwarePackages.id, packageId),
|
||||||
|
eq(softwarePackages.organizationId, user.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Package not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgDir = path.join(SOFTWARE_DIR, user.organizationId);
|
||||||
|
const filePath = path.join(orgDir, pkg.filename);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(softwarePackages)
|
||||||
|
.where(eq(softwarePackages.id, packageId));
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
function detectPlatform(filename: string): string {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
if ([".msi", ".exe", ".msix", ".appx"].includes(ext)) return "windows";
|
||||||
|
if ([".deb", ".rpm"].includes(ext)) return "linux";
|
||||||
|
if ([".dmg", ".pkg"].includes(ext)) return "macos";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { updateRequests, devices, users, deployments, deploymentTargets } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and, desc, count } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const updateRequestsRouter = Router();
|
||||||
|
|
||||||
|
updateRequestsRouter.use(authenticate);
|
||||||
|
|
||||||
|
updateRequestsRouter.get("/", authorize("read_only"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
|
||||||
|
const whereConditions = [eq(updateRequests.organizationId, user.organizationId)];
|
||||||
|
if (status) {
|
||||||
|
whereConditions.push(eq(updateRequests.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(updateRequests)
|
||||||
|
.where(and(...whereConditions));
|
||||||
|
|
||||||
|
const requests = await db
|
||||||
|
.select({
|
||||||
|
id: updateRequests.id,
|
||||||
|
organizationId: updateRequests.organizationId,
|
||||||
|
deviceId: updateRequests.deviceId,
|
||||||
|
packageName: updateRequests.packageName,
|
||||||
|
packageManager: updateRequests.packageManager,
|
||||||
|
currentVersion: updateRequests.currentVersion,
|
||||||
|
targetVersion: updateRequests.targetVersion,
|
||||||
|
requestedByUserId: updateRequests.requestedByUserId,
|
||||||
|
status: updateRequests.status,
|
||||||
|
approvedByUserId: updateRequests.approvedByUserId,
|
||||||
|
approvedAt: updateRequests.approvedAt,
|
||||||
|
rejectionReason: updateRequests.rejectionReason,
|
||||||
|
deploymentId: updateRequests.deploymentId,
|
||||||
|
notes: updateRequests.notes,
|
||||||
|
createdAt: updateRequests.createdAt,
|
||||||
|
updatedAt: updateRequests.updatedAt,
|
||||||
|
deviceName: devices.name,
|
||||||
|
requestedByName: users.displayName,
|
||||||
|
})
|
||||||
|
.from(updateRequests)
|
||||||
|
.leftJoin(devices, eq(updateRequests.deviceId, devices.id))
|
||||||
|
.leftJoin(users, eq(updateRequests.requestedByUserId, users.id))
|
||||||
|
.where(and(...whereConditions))
|
||||||
|
.orderBy(desc(updateRequests.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: requests,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalResult.total,
|
||||||
|
totalPages: Math.ceil(totalResult.total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRequestSchema = z.object({
|
||||||
|
deviceId: z.string().uuid(),
|
||||||
|
packageName: z.string().min(1).max(255),
|
||||||
|
packageManager: z.string().default("apt"),
|
||||||
|
currentVersion: z.string().max(100).optional(),
|
||||||
|
targetVersion: z.string().min(1).max(100),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRequestsRouter.post("/", authorize("operator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createRequestSchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select({ id: devices.id, organizationId: devices.organizationId })
|
||||||
|
.from(devices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(devices.id, body.deviceId),
|
||||||
|
eq(devices.organizationId, authUser.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Device not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [request] = await db
|
||||||
|
.insert(updateRequests)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
deviceId: body.deviceId,
|
||||||
|
packageName: body.packageName,
|
||||||
|
packageManager: body.packageManager,
|
||||||
|
currentVersion: body.currentVersion || null,
|
||||||
|
targetVersion: body.targetVersion,
|
||||||
|
requestedByUserId: authUser.userId,
|
||||||
|
notes: body.notes || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
action: "update_request.create",
|
||||||
|
targetType: "update_request",
|
||||||
|
targetId: request.id,
|
||||||
|
deviceIds: [body.deviceId],
|
||||||
|
result: "success",
|
||||||
|
metadata: {
|
||||||
|
packageName: body.packageName,
|
||||||
|
targetVersion: body.targetVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: request });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveSchema = z.object({
|
||||||
|
deployNow: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRequestsRouter.patch("/:requestId/approve", authorize("operator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = approveSchema.parse(req.body);
|
||||||
|
const user = (req as any).user;
|
||||||
|
const requestId = req.params.requestId as string;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(updateRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(updateRequests.id, requestId),
|
||||||
|
eq(updateRequests.organizationId, user.organizationId),
|
||||||
|
eq(updateRequests.status, "pending")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Request not found or not pending" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(updateRequests)
|
||||||
|
.set({
|
||||||
|
status: "approved",
|
||||||
|
approvedByUserId: user.userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(updateRequests.id, requestId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
let deploymentId = null;
|
||||||
|
if (body.deployNow) {
|
||||||
|
const [deployment] = await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
name: `Update ${existing.packageName} to ${existing.targetVersion}`,
|
||||||
|
description: `Approved update for ${existing.packageName} on device ${existing.deviceId}`,
|
||||||
|
jobType: "update_package",
|
||||||
|
parameters: {
|
||||||
|
package_name: existing.packageName,
|
||||||
|
target_version: existing.targetVersion,
|
||||||
|
manager: existing.packageManager,
|
||||||
|
},
|
||||||
|
status: "approved",
|
||||||
|
createdByUserId: user.userId,
|
||||||
|
approvedByUserId: user.userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
totalTargets: 1,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(deploymentTargets).values({
|
||||||
|
deploymentId: deployment.id,
|
||||||
|
deviceId: existing.deviceId,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(updateRequests)
|
||||||
|
.set({ deploymentId: deployment.id })
|
||||||
|
.where(eq(updateRequests.id, requestId));
|
||||||
|
|
||||||
|
deploymentId = deployment.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "update_request.approve",
|
||||||
|
targetType: "update_request",
|
||||||
|
targetId: requestId,
|
||||||
|
deviceIds: [existing.deviceId],
|
||||||
|
result: "success",
|
||||||
|
metadata: {
|
||||||
|
packageName: existing.packageName,
|
||||||
|
deployNow: body.deployNow,
|
||||||
|
deploymentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: { ...updated, deploymentId } });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectSchema = z.object({
|
||||||
|
reason: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRequestsRouter.patch("/:requestId/reject", authorize("operator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = rejectSchema.parse(req.body);
|
||||||
|
const user = (req as any).user;
|
||||||
|
const requestId = req.params.requestId as string;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(updateRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(updateRequests.id, requestId),
|
||||||
|
eq(updateRequests.organizationId, user.organizationId),
|
||||||
|
eq(updateRequests.status, "pending")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Request not found or not pending" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(updateRequests)
|
||||||
|
.set({
|
||||||
|
status: "rejected",
|
||||||
|
rejectionReason: body.reason,
|
||||||
|
})
|
||||||
|
.where(eq(updateRequests.id, requestId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
action: "update_request.reject",
|
||||||
|
targetType: "update_request",
|
||||||
|
targetId: requestId,
|
||||||
|
deviceIds: [existing.deviceId],
|
||||||
|
result: "success",
|
||||||
|
metadata: {
|
||||||
|
packageName: existing.packageName,
|
||||||
|
reason: body.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRequestsRouter.patch("/:requestId/cancel", authorize("operator"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const requestId = req.params.requestId as string;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(updateRequests)
|
||||||
|
.set({ status: "cancelled" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(updateRequests.id, requestId),
|
||||||
|
eq(updateRequests.organizationId, user.organizationId),
|
||||||
|
eq(updateRequests.status, "pending")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Request not found or not cancellable" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: updated });
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { users } from "../db/schema.js";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth.js";
|
||||||
|
import { auditLog } from "../lib/audit.js";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const usersRouter = Router();
|
||||||
|
|
||||||
|
usersRouter.use(authenticate);
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
displayName: z.string().min(1).max(255),
|
||||||
|
role: z.enum(["admin", "operator", "read_only"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.get("/", authorize("admin"), async (req, res) => {
|
||||||
|
const user = (req as any).user;
|
||||||
|
const allUsers = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
displayName: users.displayName,
|
||||||
|
role: users.role,
|
||||||
|
isActive: users.isActive,
|
||||||
|
lastLoginAt: users.lastLoginAt,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.organizationId, user.organizationId));
|
||||||
|
|
||||||
|
res.json({ success: true, data: allUsers });
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.post("/", authorize("admin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = createUserSchema.parse(req.body);
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(body.password, 12);
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
email: body.email,
|
||||||
|
passwordHash,
|
||||||
|
displayName: body.displayName,
|
||||||
|
role: body.role,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
displayName: users.displayName,
|
||||||
|
role: users.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
action: "user.create",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: newUser.id,
|
||||||
|
result: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: newUser });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.patch("/:userId/deactivate", authorize("admin"), async (req, res) => {
|
||||||
|
const authUser = (req as any).user;
|
||||||
|
const userId = req.params.userId as string;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ isActive: false })
|
||||||
|
.where(and(eq(users.id, userId), eq(users.organizationId, authUser.organizationId)));
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
req,
|
||||||
|
organizationId: authUser.organizationId,
|
||||||
|
action: "user.deactivate",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
|
result: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { Server } from "http";
|
||||||
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { remoteSessions, deviceCredentials, devices } from "../db/schema.js";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { hashCredential } from "../lib/tokens.js";
|
||||||
|
import { verifyToken } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const agents = new Map<string, WebSocket>();
|
||||||
|
const browsers = new Map<string, WebSocket>();
|
||||||
|
const pairs = new Set<string>();
|
||||||
|
|
||||||
|
async function closeSession(sessionId: string) {
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "closed", endedAt: new Date() })
|
||||||
|
.where(eq(remoteSessions.id, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pair(sessionId: string) {
|
||||||
|
const agent = agents.get(sessionId);
|
||||||
|
const browser = browsers.get(sessionId);
|
||||||
|
if (!agent || !browser || pairs.has(sessionId)) return;
|
||||||
|
pairs.add(sessionId);
|
||||||
|
|
||||||
|
const teardown = async (origin: "agent" | "browser") => {
|
||||||
|
pairs.delete(sessionId);
|
||||||
|
agents.delete(sessionId);
|
||||||
|
browsers.delete(sessionId);
|
||||||
|
if (agent.readyState === WebSocket.OPEN) agent.close(1011, `${origin} disconnected`);
|
||||||
|
if (browser.readyState === WebSocket.OPEN) browser.close(1011, `${origin} disconnected`);
|
||||||
|
closeSession(sessionId).catch((err) => console.error("[remote] closeSession error:", err));
|
||||||
|
};
|
||||||
|
|
||||||
|
agent.on("close", () => teardown("agent"));
|
||||||
|
agent.on("error", () => teardown("agent"));
|
||||||
|
browser.on("close", () => teardown("browser"));
|
||||||
|
browser.on("error", () => teardown("browser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardTo(peer: WebSocket | undefined, data: WebSocket.RawData) {
|
||||||
|
if (peer && peer.readyState === WebSocket.OPEN) {
|
||||||
|
peer.send(data, { binary: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachForwarder wires up message forwarding as soon as the socket exists so
|
||||||
|
// that bytes cannot be lost while the handler awaits the ownership/credential
|
||||||
|
// database checks. The forwarding is peer-lookup based and safe to use in any
|
||||||
|
// connection order.
|
||||||
|
function attachForwarder(ws: WebSocket, sessionId: string, kind: "agent" | "browser") {
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
forwardTo(kind === "agent" ? browsers.get(sessionId) : agents.get(sessionId), data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAgentTunnel(url: URL, ws: WebSocket) {
|
||||||
|
const sessionId = url.searchParams.get("session_id") || "";
|
||||||
|
const token = url.searchParams.get("token") || "";
|
||||||
|
if (!sessionId || !token) {
|
||||||
|
ws.close(4001, "Missing session_id or token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attachForwarder(ws, sessionId, "agent");
|
||||||
|
|
||||||
|
const credHash = hashCredential(token);
|
||||||
|
const [deviceCred] = await db
|
||||||
|
.select()
|
||||||
|
.from(deviceCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(deviceCredentials.credentialHash, credHash),
|
||||||
|
eq(deviceCredentials.credentialType, "device_key")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!deviceCred || (deviceCred.expiresAt && new Date(deviceCred.expiresAt) < new Date())) {
|
||||||
|
ws.close(4001, "Invalid device credential");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [device] = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.id, deviceCred.deviceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!device || device.isDisabled) {
|
||||||
|
ws.close(4001, "Device disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteSessions)
|
||||||
|
.where(eq(remoteSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.deviceId !== device.id) {
|
||||||
|
ws.close(4001, "Session not found for device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === "closed" || session.status === "failed") {
|
||||||
|
ws.close(4001, "Session closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(remoteSessions)
|
||||||
|
.set({ status: "active", startedAt: new Date(), errorMessage: null })
|
||||||
|
.where(eq(remoteSessions.id, sessionId));
|
||||||
|
|
||||||
|
agents.set(sessionId, ws);
|
||||||
|
pair(sessionId);
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
if (agents.get(sessionId) === ws) {
|
||||||
|
agents.delete(sessionId);
|
||||||
|
closeSession(sessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBrowser(url: URL, ws: WebSocket) {
|
||||||
|
const token = url.searchParams.get("token") || "";
|
||||||
|
let user: any;
|
||||||
|
try {
|
||||||
|
user = verifyToken(token);
|
||||||
|
} catch {
|
||||||
|
ws.close(4001, "Invalid session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionId = url.pathname.split("/").pop() || "";
|
||||||
|
attachForwarder(ws, sessionId, "browser");
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(remoteSessions)
|
||||||
|
.where(eq(remoteSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.organizationId !== user.organizationId) {
|
||||||
|
ws.close(4001, "Session not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browsers.has(sessionId)) {
|
||||||
|
browsers.get(sessionId)?.close(1013, "Replaced by another viewer");
|
||||||
|
browsers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
browsers.set(sessionId, ws);
|
||||||
|
pair(sessionId);
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
if (browsers.get(sessionId) === ws) browsers.delete(sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachRelay(server: Server) {
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on("upgrade", (req, socket, head) => {
|
||||||
|
const base = `${req.headers["x-forwarded-proto"] === "https" ? "https" : "http"}://${req.headers.host}`;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(req.url || "/", base);
|
||||||
|
} catch {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/v1/remote/agent") {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
handleAgentTunnel(url, ws).catch(() => {
|
||||||
|
ws.close(4000, "Agent tunnel error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/api\/v1\/remote\/browser\/[0-9a-fA-F-]{36}$/.test(url.pathname)) {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
handleBrowser(url, ws).catch(() => {
|
||||||
|
ws.close(4000, "Session error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("close", () => wss.close());
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
testTimeout: 30000,
|
||||||
|
hookTimeout: 30000,
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
pool: "forks",
|
||||||
|
poolOptions: {
|
||||||
|
forks: {
|
||||||
|
maxForks: 1,
|
||||||
|
singleFork: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
transpilePackages: ["@fleetforge/shared"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@fleetforge/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3001",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3001",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fleetforge/shared": "workspace:*",
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.32.0",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: string;
|
||||||
|
alertType: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityVariant: Record<string, "info" | "warning" | "destructive" | "outline"> = {
|
||||||
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
error: "destructive",
|
||||||
|
critical: "destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
const fetchAlerts = () => {
|
||||||
|
api.get<AlertItem[]>("/v1/alerts").then((res) => {
|
||||||
|
if (res.success && res.data) setAlerts(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchAlerts(); }, []);
|
||||||
|
|
||||||
|
const resolveAlert = async (id: string) => {
|
||||||
|
await api.patch(`/v1/alerts/${id}/resolve`);
|
||||||
|
fetchAlerts();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Alerts</h1>
|
||||||
|
<p className="text-muted-foreground">System alerts and notifications</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No alerts. All clear!</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<Card key={alert.id} className={alert.isResolved ? "opacity-60" : ""}>
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant={severityVariant[alert.severity] || "outline"}>
|
||||||
|
{alert.severity.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{alert.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{alert.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(alert.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{!alert.isResolved && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => resolveAlert(alert.id)}>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { ScrollText } from "lucide-react";
|
||||||
|
|
||||||
|
interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId: string | null;
|
||||||
|
result: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
correlationId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPage() {
|
||||||
|
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [actionFilter, setActionFilter] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (actionFilter) params.action = actionFilter;
|
||||||
|
api.get<AuditEvent[]>("/v1/audit-events", params).then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setEvents(res.data);
|
||||||
|
setTotal(res.meta?.total || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [actionFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Audit Log</h1>
|
||||||
|
<p className="text-muted-foreground">{total} event(s) recorded</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by action..."
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
|
className="w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<ScrollText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No audit events recorded yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Result</TableHead>
|
||||||
|
<TableHead>IP</TableHead>
|
||||||
|
<TableHead>Correlation ID</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{events.map((event) => (
|
||||||
|
<TableRow key={event.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(event.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">
|
||||||
|
{event.action}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{event.targetType}
|
||||||
|
{event.targetId && (
|
||||||
|
<span className="ml-1">
|
||||||
|
({event.targetId.substring(0, 8)}...)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={event.result === "success" ? "success" : "destructive"}>
|
||||||
|
{event.result}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{event.ipAddress || "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
|
{event.correlationId.substring(0, 12)}...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Server, Wifi, WifiOff, AlertTriangle, Bell, Zap, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
totalDevices: number;
|
||||||
|
onlineDevices: number;
|
||||||
|
offlineDevices: number;
|
||||||
|
warningDevices: number;
|
||||||
|
openAlerts: number;
|
||||||
|
pendingJobs: number;
|
||||||
|
staleDevices: number;
|
||||||
|
osDistribution: Array<{ os: string; total: number }>;
|
||||||
|
recentDevices: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
osName: string;
|
||||||
|
lastHeartbeatAt: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variant: "success" | "secondary" | "warning" =
|
||||||
|
status === "online" ? "success" : status === "offline" ? "secondary" : "warning";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant}>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<DashboardStats>("/v1/dashboard/stats").then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setStats(res.data);
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || "Failed to load dashboard");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">Fleet overview and status</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-6">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatCard label="Total Devices" value={stats.totalDevices} icon={Server} />
|
||||||
|
<StatCard label="Online" value={stats.onlineDevices} icon={Wifi} />
|
||||||
|
<StatCard label="Offline" value={stats.offlineDevices} icon={WifiOff} />
|
||||||
|
<StatCard label="Warnings" value={stats.warningDevices} icon={AlertTriangle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Devices</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stats.recentDevices.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No devices enrolled yet. Create an enrollment token to get started.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.recentDevices.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{device.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{device.osName}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={device.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>OS Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stats.osDistribution.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No device data available yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.osDistribution.map((item) => (
|
||||||
|
<div key={item.os} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">{item.os}</span>
|
||||||
|
<span className="text-sm font-medium">{item.total}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Open Alerts</CardTitle>
|
||||||
|
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.openAlerts}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Pending Jobs</CardTitle>
|
||||||
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.pendingJobs}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Stale Devices</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.staleDevices}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Rocket, Check, Plus, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Deployment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
jobType: string;
|
||||||
|
status: string;
|
||||||
|
createdByName: string | null;
|
||||||
|
totalTargets: number;
|
||||||
|
completedTargets: number;
|
||||||
|
failedTargets: number;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariant: Record<string, "info" | "warning" | "success" | "destructive" | "secondary" | "outline"> = {
|
||||||
|
draft: "outline",
|
||||||
|
pending_approval: "warning",
|
||||||
|
approved: "info",
|
||||||
|
running: "warning",
|
||||||
|
completed: "success",
|
||||||
|
partially_completed: "warning",
|
||||||
|
failed: "destructive",
|
||||||
|
cancelled: "secondary",
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_TYPES = [
|
||||||
|
{ value: "install_package", label: "Install Package", paramKey: "package_name", paramLabel: "Package Name" },
|
||||||
|
{ value: "update_package", label: "Update Package", paramKey: "package_name", paramLabel: "Package Name" },
|
||||||
|
{ value: "remove_package", label: "Remove Package", paramKey: "package_name", paramLabel: "Package Name" },
|
||||||
|
{ value: "start_service", label: "Start Service", paramKey: "service_name", paramLabel: "Service Name" },
|
||||||
|
{ value: "stop_service", label: "Stop Service", paramKey: "service_name", paramLabel: "Service Name" },
|
||||||
|
{ value: "restart_service", label: "Restart Service", paramKey: "service_name", paramLabel: "Service Name" },
|
||||||
|
{ value: "check_service_status", label: "Check Service Status", paramKey: "service_name", paramLabel: "Service Name" },
|
||||||
|
{ value: "reboot_system", label: "Reboot System", paramKey: null, paramLabel: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DeploymentsPage() {
|
||||||
|
const [deployments, setDeployments] = useState<Deployment[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
jobType: "install_package",
|
||||||
|
packageName: "",
|
||||||
|
serviceName: "",
|
||||||
|
autoApprove: false,
|
||||||
|
});
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const loadDeployments = () => {
|
||||||
|
api.get<Deployment[]>("/v1/deployments").then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setDeployments(res.data);
|
||||||
|
setTotal(res.meta?.total || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDevices = () => {
|
||||||
|
api.get<Device[]>("/v1/devices").then((res) => {
|
||||||
|
if (res.success && res.data) setDevices(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadDeployments(); loadDevices(); }, []);
|
||||||
|
|
||||||
|
const selectedJobType = JOB_TYPES.find((j) => j.value === form.jobType);
|
||||||
|
const needsPackageName = selectedJobType?.paramKey === "package_name";
|
||||||
|
const needsServiceName = selectedJobType?.paramKey === "service_name";
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (selectedDevices.length === 0) {
|
||||||
|
setError("Select at least one target device.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters: Record<string, string> = {};
|
||||||
|
if (needsPackageName) {
|
||||||
|
if (!form.packageName.trim()) {
|
||||||
|
setError("Package name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parameters.package_name = form.packageName.trim();
|
||||||
|
}
|
||||||
|
if (needsServiceName) {
|
||||||
|
if (!form.serviceName.trim()) {
|
||||||
|
setError("Service name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parameters.service_name = form.serviceName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.post<Deployment>("/v1/deployments", {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
jobType: form.jobType,
|
||||||
|
parameters,
|
||||||
|
deviceIds: selectedDevices,
|
||||||
|
autoApprove: form.autoApprove,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
setShowCreate(false);
|
||||||
|
setForm({ name: "", description: "", jobType: "install_package", packageName: "", serviceName: "", autoApprove: false });
|
||||||
|
setSelectedDevices([]);
|
||||||
|
loadDeployments();
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || "Failed to create deployment");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
await api.patch(`/v1/deployments/${id}/approve`);
|
||||||
|
loadDeployments();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string) => {
|
||||||
|
await api.patch(`/v1/deployments/${id}/cancel`);
|
||||||
|
loadDeployments();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Delete this deployment?")) return;
|
||||||
|
await api.delete(`/v1/deployments/${id}`);
|
||||||
|
loadDeployments();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDevice = (id: string) => {
|
||||||
|
setSelectedDevices((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Deployments</h1>
|
||||||
|
<p className="text-muted-foreground">{total} deployment(s)</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Deployment
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Deployment</DialogTitle>
|
||||||
|
<DialogDescription>Deploy packages, services, or commands to selected devices.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="e.g. Install nginx on web servers"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Job Type *</Label>
|
||||||
|
<Select value={form.jobType} onValueChange={(v) => setForm({ ...form, jobType: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{JOB_TYPES.map((jt) => (
|
||||||
|
<SelectItem key={jt.value} value={jt.value}>{jt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsPackageName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Package Name *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.packageName}
|
||||||
|
onChange={(e) => setForm({ ...form, packageName: e.target.value })}
|
||||||
|
placeholder="e.g. nginx"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsServiceName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Service Name *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.serviceName}
|
||||||
|
onChange={(e) => setForm({ ...form, serviceName: e.target.value })}
|
||||||
|
placeholder="e.g. nginx"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Target Devices *</Label>
|
||||||
|
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground p-4 text-center">No devices enrolled yet.</p>
|
||||||
|
) : (
|
||||||
|
devices.map((d) => (
|
||||||
|
<label
|
||||||
|
key={d.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 hover:bg-muted/50 cursor-pointer border-b last:border-b-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDevices.includes(d.id)}
|
||||||
|
onChange={() => toggleDevice(d.id)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{d.name}</span>
|
||||||
|
<Badge variant={d.status === "online" ? "success" : "secondary"} className="ml-auto text-xs">
|
||||||
|
{d.status}
|
||||||
|
</Badge>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedDevices.length} device(s) selected</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoApprove}
|
||||||
|
onChange={(e) => setForm({ ...form, autoApprove: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Auto-approve and deploy immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||||
|
<Button type="submit">Create Deployment</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deployments.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Rocket className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No deployments created yet. Click "New Deployment" to get started.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Job Type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Progress</TableHead>
|
||||||
|
<TableHead>Created By</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{deployments.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-sm">{d.name}</div>
|
||||||
|
{d.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{d.description}</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{d.jobType.replace(/_/g, " ")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant[d.status] || "secondary"}>{d.status.replace(/_/g, " ")}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-green-600">{d.completedTargets}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-muted-foreground">{d.totalTargets}</span>
|
||||||
|
{d.failedTargets > 0 && (
|
||||||
|
<span className="text-destructive">({d.failedTargets} failed)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.createdByName || "—"}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(d.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{d.status === "draft" && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleApprove(d.id)}>
|
||||||
|
<Check className="h-3 w-3 mr-1" /> Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{["draft", "approved"].includes(d.status) && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => handleCancel(d.id)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{d.status === "draft" && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => handleDelete(d.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Wifi, Clock, AlertTriangle, Wrench, Monitor } from "lucide-react";
|
||||||
|
import { DeviceActionsDialog } from "@/components/DeviceActionsDialog";
|
||||||
|
import { RemoteAccessModal } from "@/components/RemoteAccessModal";
|
||||||
|
|
||||||
|
interface DeviceDetail {
|
||||||
|
id: string; name: string; deviceType: string; platform: string;
|
||||||
|
osName: string; osVersion: string; architecture: string; kernelVersion: string | null;
|
||||||
|
cpuModel: string | null; cpuCores: number | null;
|
||||||
|
totalMemoryBytes: number | null; totalDiskBytes: number | null;
|
||||||
|
ipAddress: string | null; agentVersion: string; status: string;
|
||||||
|
lastHeartbeatAt: string | null; enrolledAt: string; tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricSnapshot {
|
||||||
|
cpuUsagePercent: number | null; memoryUsagePercent: number | null;
|
||||||
|
diskUsagePercent: number | null; loadAverage1m: number | null; loadAverage5m: number | null;
|
||||||
|
memoryUsedBytes: number | null; memoryTotalBytes: number | null;
|
||||||
|
diskUsedBytes: number | null; diskTotalBytes: number | null;
|
||||||
|
uptimeSeconds: number | null; reportedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageInfo { name: string; version: string; manager: string; }
|
||||||
|
interface ServiceInfo { name: string; status: string; enabled: boolean; }
|
||||||
|
interface InventoryData { packages: PackageInfo[]; services: ServiceInfo[]; reportedAt: string | null; }
|
||||||
|
|
||||||
|
interface DeviceJob {
|
||||||
|
id: string; jobType: string; status: string; parameters: Record<string, unknown>;
|
||||||
|
targetStatus: string; targetResult: Record<string, unknown> | null;
|
||||||
|
createdAt: string; completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceAlert {
|
||||||
|
id: string; alertType: string; severity: string; title: string; message: string;
|
||||||
|
isResolved: boolean; createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null): string {
|
||||||
|
if (!bytes) return "\u2014";
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
return `${mb.toFixed(0)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number | null): string {
|
||||||
|
if (!seconds) return "\u2014";
|
||||||
|
const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variant: "success" | "secondary" | "warning" = status === "online" ? "success" : status === "offline" ? "secondary" : "warning";
|
||||||
|
return <Badge variant={variant}>{status.charAt(0).toUpperCase() + status.slice(1)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ icon: Icon, label, value, subtext }: { icon: any; label: string; value: string; subtext?: string }) {
|
||||||
|
return (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-muted rounded-lg"><Icon className="h-4 w-4 text-muted-foreground" /></div>
|
||||||
|
<div className="flex-1"><p className="text-xs text-muted-foreground">{label}</p><p className="text-lg font-semibold">{value}</p>{subtext && <p className="text-xs text-muted-foreground">{subtext}</p>}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressCard({ label, percent, used, total }: { label: string; percent: number | null; used?: string; total?: string }) {
|
||||||
|
const p = percent != null ? Math.min(100, Math.max(0, percent)) : 0;
|
||||||
|
const color = p > 90 ? "bg-destructive" : p > 70 ? "bg-yellow-500" : "bg-primary";
|
||||||
|
return (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2"><p className="text-sm font-medium">{label}</p><p className="text-sm text-muted-foreground">{percent != null ? `${percent.toFixed(1)}%` : "\u2014"}</p></div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2"><div className={`h-2 rounded-full ${color}`} style={{ width: `${p}%` }} /></div>
|
||||||
|
{used && total && <p className="text-xs text-muted-foreground mt-1">{used} / {total}</p>}
|
||||||
|
</CardContent></Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const deviceId = params.deviceId as string;
|
||||||
|
const [device, setDevice] = useState<DeviceDetail | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<MetricSnapshot[]>([]);
|
||||||
|
const [inventory, setInventory] = useState<InventoryData | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<DeviceJob[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<DeviceAlert[]>([]);
|
||||||
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
|
const [remoteOpen, setRemoteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<DeviceDetail>(`/v1/devices/${deviceId}`).then((r) => { if (r.success && r.data) setDevice(r.data); });
|
||||||
|
api.get<MetricSnapshot[]>(`/v1/devices/${deviceId}/metrics`).then((r) => { if (r.success && r.data) setMetrics(r.data); });
|
||||||
|
api.get<InventoryData>(`/v1/software/inventory/devices/${deviceId}`).then((r) => { if (r.success && r.data) setInventory(r.data); });
|
||||||
|
api.get<DeviceJob[]>(`/v1/devices/${deviceId}/jobs`).then((r) => { if (r.success && r.data) setJobs(r.data); });
|
||||||
|
api.get<DeviceAlert[]>(`/v1/devices/${deviceId}/alerts`).then((r) => { if (r.success && r.data) setAlerts(r.data); });
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
if (!device) return <DashboardLayout><Skeleton className="h-8 w-48 mb-8" /><div className="grid grid-cols-2 gap-4"><Skeleton className="h-96" /><Skeleton className="h-96" /></div></DashboardLayout>;
|
||||||
|
|
||||||
|
const m = metrics[0];
|
||||||
|
const jobStatusVariant: Record<string, "success" | "destructive" | "warning" | "secondary" | "info"> = {
|
||||||
|
succeeded: "success", failed: "destructive", running: "warning", queued: "secondary", dispatched: "info",
|
||||||
|
};
|
||||||
|
const severityVariant: Record<string, "destructive" | "warning" | "info" | "secondary"> = {
|
||||||
|
critical: "destructive", error: "destructive", warning: "warning", info: "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href="/devices" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"><ArrowLeft className="mr-1 h-4 w-4" />Back to Devices</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{device.name}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{device.osName} {device.osVersion} · {device.architecture} · {device.ipAddress || "No IP"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={() => setRemoteOpen(true)}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />Remote
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setActionsOpen(true)}>
|
||||||
|
<Wrench className="mr-2 h-4 w-4" />Actions
|
||||||
|
</Button>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">{device.agentVersion}</Badge>
|
||||||
|
<StatusBadge status={device.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
|
||||||
|
<MetricCard icon={Cpu} label="CPU" value={m?.cpuUsagePercent != null ? `${m.cpuUsagePercent.toFixed(1)}%` : "\u2014"} subtext={device.cpuCores ? `${device.cpuCores} cores \u00b7 ${device.cpuModel?.split(" ").slice(-2).join(" ") || ""}` : undefined} />
|
||||||
|
<MetricCard icon={MemoryStick} label="Memory" value={m?.memoryUsagePercent != null ? `${m.memoryUsagePercent.toFixed(1)}%` : "\u2014"} subtext={m?.memoryUsedBytes ? `${formatBytes(m.memoryUsedBytes)} / ${formatBytes(m.memoryTotalBytes)}` : formatBytes(device.totalMemoryBytes)} />
|
||||||
|
<MetricCard icon={HardDrive} label="Disk" value={m?.diskUsagePercent != null ? `${m.diskUsagePercent.toFixed(1)}%` : "\u2014"} subtext={m?.diskUsedBytes ? `${formatBytes(m.diskUsedBytes)} / ${formatBytes(m.diskTotalBytes)}` : formatBytes(device.totalDiskBytes)} />
|
||||||
|
<MetricCard icon={Clock} label="Uptime" value={m?.uptimeSeconds != null ? formatUptime(m.uptimeSeconds) : "\u2014"} />
|
||||||
|
<MetricCard icon={Wifi} label="Load" value={m?.loadAverage1m != null ? m.loadAverage1m.toFixed(2) : "\u2014"} subtext={m?.loadAverage5m != null ? `5m: ${m.loadAverage5m.toFixed(2)}` : undefined} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="system" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="system">System</TabsTrigger>
|
||||||
|
<TabsTrigger value="metrics">Metrics History</TabsTrigger>
|
||||||
|
<TabsTrigger value="packages">Packages ({inventory?.packages?.length || 0})</TabsTrigger>
|
||||||
|
<TabsTrigger value="services">Services ({inventory?.services?.length || 0})</TabsTrigger>
|
||||||
|
<TabsTrigger value="jobs">Jobs ({jobs.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="alerts">Alerts ({alerts.filter((a) => !a.isResolved).length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="system">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<Card><CardHeader><CardTitle className="text-base">Hardware</CardTitle></CardHeader><CardContent className="space-y-1">
|
||||||
|
<InfoRow label="CPU" value={device.cpuModel || "\u2014"} />
|
||||||
|
<InfoRow label="Cores" value={device.cpuCores || "\u2014"} />
|
||||||
|
<InfoRow label="Memory" value={formatBytes(device.totalMemoryBytes)} />
|
||||||
|
<InfoRow label="Disk" value={formatBytes(device.totalDiskBytes)} />
|
||||||
|
<InfoRow label="Architecture" value={device.architecture} />
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardHeader><CardTitle className="text-base">Software</CardTitle></CardHeader><CardContent className="space-y-1">
|
||||||
|
<InfoRow label="OS" value={`${device.osName} ${device.osVersion}`} />
|
||||||
|
<InfoRow label="Kernel" value={device.kernelVersion} />
|
||||||
|
<InfoRow label="Platform" value={device.platform} />
|
||||||
|
<InfoRow label="Device Type" value={device.deviceType.replace("_", " ")} />
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardHeader><CardTitle className="text-base">Network & Agent</CardTitle></CardHeader><CardContent className="space-y-1">
|
||||||
|
<InfoRow label="IP Address" value={device.ipAddress} />
|
||||||
|
<InfoRow label="Agent Version" value={device.agentVersion} />
|
||||||
|
<InfoRow label="Enrolled" value={new Date(device.enrolledAt).toLocaleString()} />
|
||||||
|
<InfoRow label="Last Heartbeat" value={device.lastHeartbeatAt ? new Date(device.lastHeartbeatAt).toLocaleString() : "Never"} />
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardHeader><CardTitle className="text-base">Live Metrics</CardTitle></CardHeader><CardContent className="space-y-3">
|
||||||
|
{m ? (<>
|
||||||
|
<ProgressCard label="CPU" percent={m.cpuUsagePercent} />
|
||||||
|
<ProgressCard label="Memory" percent={m.memoryUsagePercent} used={formatBytes(m.memoryUsedBytes)} total={formatBytes(m.memoryTotalBytes)} />
|
||||||
|
<ProgressCard label="Disk" percent={m.diskUsagePercent} used={formatBytes(m.diskUsedBytes)} total={formatBytes(m.diskTotalBytes)} />
|
||||||
|
</>) : <p className="text-sm text-muted-foreground">No live metrics available.</p>}
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="metrics">
|
||||||
|
<Card><CardContent className="p-0">
|
||||||
|
{metrics.length === 0 ? <p className="p-6 text-muted-foreground text-sm text-center">No metrics recorded yet.</p> : (
|
||||||
|
<Table><TableHeader><TableRow><TableHead>Time</TableHead><TableHead>CPU</TableHead><TableHead>Memory</TableHead><TableHead>Disk</TableHead><TableHead>Load (1m)</TableHead><TableHead>Uptime</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>{metrics.slice(0, 50).map((m, i) => (
|
||||||
|
<TableRow key={i}><TableCell className="text-sm">{new Date(m.reportedAt).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-sm">{m.cpuUsagePercent != null ? `${m.cpuUsagePercent.toFixed(1)}%` : "\u2014"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{m.memoryUsagePercent != null ? `${m.memoryUsagePercent.toFixed(1)}%` : "\u2014"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{m.diskUsagePercent != null ? `${m.diskUsagePercent.toFixed(1)}%` : "\u2014"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{m.loadAverage1m != null ? m.loadAverage1m.toFixed(2) : "\u2014"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{formatUptime(m.uptimeSeconds)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}</TableBody></Table>
|
||||||
|
)}
|
||||||
|
</CardContent></Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="packages">
|
||||||
|
<Card><CardContent>
|
||||||
|
{!inventory?.packages?.length ? <p className="text-sm text-muted-foreground text-center py-6">No packages reported. Waiting for agent heartbeat.</p> : (
|
||||||
|
<Table><TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Version</TableHead><TableHead>Manager</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>{inventory.packages.sort((a, b) => a.name.localeCompare(b.name)).map((p, i) => (
|
||||||
|
<TableRow key={i}><TableCell className="font-mono text-sm">{p.name}</TableCell><TableCell className="text-sm">{p.version}</TableCell><TableCell><Badge variant="outline">{p.manager}</Badge></TableCell></TableRow>
|
||||||
|
))}</TableBody></Table>
|
||||||
|
)}
|
||||||
|
</CardContent></Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="services">
|
||||||
|
<Card><CardContent>
|
||||||
|
{!inventory?.services?.length ? <p className="text-sm text-muted-foreground text-center py-6">No services reported.</p> : (
|
||||||
|
<Table><TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Enabled</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>{inventory.services.sort((a, b) => a.name.localeCompare(b.name)).map((s, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="font-mono text-sm">{s.name}</TableCell>
|
||||||
|
<TableCell><Badge variant={s.status === "running" ? "success" : s.status === "active" ? "success" : "secondary"}>{s.status}</Badge></TableCell>
|
||||||
|
<TableCell className="text-sm">{s.enabled ? "Yes" : "No"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}</TableBody></Table>
|
||||||
|
)}
|
||||||
|
</CardContent></Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="jobs">
|
||||||
|
<Card><CardContent>
|
||||||
|
{jobs.length === 0 ? <p className="text-sm text-muted-foreground text-center py-6">No jobs executed on this device yet.</p> : (
|
||||||
|
<Table><TableHeader><TableRow><TableHead>Type</TableHead><TableHead>Status</TableHead><TableHead>Created</TableHead><TableHead>Duration</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>{jobs.map((j) => (
|
||||||
|
<TableRow key={j.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{j.jobType.replace(/_/g, " ")}</TableCell>
|
||||||
|
<TableCell><Badge variant={jobStatusVariant[j.targetStatus] || "secondary"}>{j.targetStatus}</Badge></TableCell>
|
||||||
|
<TableCell className="text-sm">{new Date(j.createdAt).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-sm">{j.completedAt ? `${((new Date(j.completedAt).getTime() - new Date(j.createdAt).getTime()) / 1000).toFixed(1)}s` : "\u2014"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}</TableBody></Table>
|
||||||
|
)}
|
||||||
|
</CardContent></Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="alerts">
|
||||||
|
<Card><CardContent>
|
||||||
|
{alerts.length === 0 ? <p className="text-sm text-muted-foreground text-center py-6">No alerts for this device.</p> : (
|
||||||
|
<div className="space-y-2">{alerts.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-start gap-3 p-3 rounded-lg border">
|
||||||
|
<AlertTriangle className={`h-4 w-4 mt-0.5 ${a.severity === "critical" || a.severity === "error" ? "text-destructive" : a.severity === "warning" ? "text-yellow-500" : "text-muted-foreground"}`} />
|
||||||
|
<div className="flex-1"><div className="flex items-center gap-2"><p className="text-sm font-medium">{a.title}</p><Badge variant={severityVariant[a.severity] || "secondary"} className="text-xs">{a.severity}</Badge>{a.isResolved && <Badge variant="outline" className="text-xs">Resolved</Badge>}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{a.message}</p><p className="text-xs text-muted-foreground mt-1">{new Date(a.createdAt).toLocaleString()}</p></div>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
)}
|
||||||
|
</CardContent></Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DeviceActionsDialog
|
||||||
|
device={{ id: device.id, name: device.name, status: device.status }}
|
||||||
|
open={actionsOpen}
|
||||||
|
onOpenChange={setActionsOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RemoteAccessModal
|
||||||
|
deviceId={device.id}
|
||||||
|
deviceName={device.name}
|
||||||
|
open={remoteOpen}
|
||||||
|
onOpenChange={setRemoteOpen}
|
||||||
|
/>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string | number | null }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between py-2 border-b border-border last:border-0">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm font-medium">{value || "\u2014"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Server, Trash2, Loader2, Wrench, Monitor } from "lucide-react";
|
||||||
|
import { DeviceActionsDialog } from "@/components/DeviceActionsDialog";
|
||||||
|
import { RemoteAccessModal } from "@/components/RemoteAccessModal";
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deviceType: string;
|
||||||
|
osName: string;
|
||||||
|
osVersion: string;
|
||||||
|
architecture: string;
|
||||||
|
status: string;
|
||||||
|
agentVersion: string;
|
||||||
|
lastHeartbeatAt: string | null;
|
||||||
|
enrolledAt: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variant: "success" | "secondary" | "warning" =
|
||||||
|
status === "online" ? "success" : status === "offline" ? "secondary" : "warning";
|
||||||
|
return <Badge variant={variant}>{status.charAt(0).toUpperCase() + status.slice(1)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Device | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [actionsTarget, setActionsTarget] = useState<Device | null>(null);
|
||||||
|
const [remoteTarget, setRemoteTarget] = useState<Device | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (search) params.search = search;
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
|
||||||
|
api.get<Device[]>("/v1/devices", params).then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setDevices(res.data);
|
||||||
|
setTotal(res.meta?.total || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [search, statusFilter]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/v1/devices/${deleteTarget.id}`);
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== deleteTarget.id));
|
||||||
|
setTotal((t) => Math.max(0, t - 1));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Devices</h1>
|
||||||
|
<p className="text-muted-foreground">{total} device(s) enrolled</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mb-6">
|
||||||
|
<Input
|
||||||
|
placeholder="Search devices..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-64"
|
||||||
|
/>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="online">Online</SelectItem>
|
||||||
|
<SelectItem value="offline">Offline</SelectItem>
|
||||||
|
<SelectItem value="warning">Warning</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground text-lg mb-2">No devices found</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Enroll a device using the enrollment token system in Settings.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>OS</TableHead>
|
||||||
|
<TableHead>Architecture</TableHead>
|
||||||
|
<TableHead>Agent</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Last Heartbeat</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<TableRow key={device.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/devices/${device.id}`}
|
||||||
|
className="text-primary hover:underline font-medium text-sm"
|
||||||
|
>
|
||||||
|
{device.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{device.deviceType.replace("_", " ")}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{device.osName} {device.osVersion}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{device.architecture}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{device.agentVersion}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={device.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{device.lastHeartbeatAt
|
||||||
|
? new Date(device.lastHeartbeatAt).toLocaleString()
|
||||||
|
: "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-primary"
|
||||||
|
title="Remote desktop"
|
||||||
|
onClick={() => setRemoteTarget(device)}
|
||||||
|
>
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-primary"
|
||||||
|
title="Device actions"
|
||||||
|
onClick={() => setActionsTarget(device)}
|
||||||
|
>
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
title="Delete device"
|
||||||
|
onClick={() => setDeleteTarget(device)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleting && setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete device?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently remove <strong>{deleteTarget?.name}</strong> and all of its
|
||||||
|
historical data (metrics, inventory, jobs, alerts, compliance results). This action
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
|
||||||
|
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
|
Delete device
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<DeviceActionsDialog
|
||||||
|
device={
|
||||||
|
actionsTarget
|
||||||
|
? { id: actionsTarget.id, name: actionsTarget.name, status: actionsTarget.status }
|
||||||
|
: { id: "", name: "", status: "" }
|
||||||
|
}
|
||||||
|
open={!!actionsTarget}
|
||||||
|
onOpenChange={(open) => !open && setActionsTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RemoteAccessModal
|
||||||
|
deviceId={remoteTarget ? remoteTarget.id : ""}
|
||||||
|
deviceName={remoteTarget ? remoteTarget.name : ""}
|
||||||
|
open={!!remoteTarget}
|
||||||
|
onOpenChange={(open) => !open && setRemoteTarget(null)}
|
||||||
|
/>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Terminal,
|
||||||
|
MonitorSmartphone,
|
||||||
|
Apple,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface PlatformInfo {
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
label: string;
|
||||||
|
filename: string;
|
||||||
|
available: boolean;
|
||||||
|
size: number;
|
||||||
|
sizeFormatted: string;
|
||||||
|
installHint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentInfo {
|
||||||
|
version: string;
|
||||||
|
platforms: PlatformInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const osIcons: Record<string, typeof Terminal> = {
|
||||||
|
linux: Terminal,
|
||||||
|
windows: MonitorSmartphone,
|
||||||
|
darwin: Apple,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getApiBase() {
|
||||||
|
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
function WindowsInstallerCard() {
|
||||||
|
const [serverUrl, setServerUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServerUrl(getApiBase().replace(/\/api$/, ""));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
try {
|
||||||
|
const token = api.getToken();
|
||||||
|
const res = await fetch(`${getApiBase()}/v1/installers/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ serverUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `Failed to generate installer (${res.status})`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.error?.message) message = data.error.message;
|
||||||
|
} catch {}
|
||||||
|
setError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = blobUrl;
|
||||||
|
a.download = "forge-agent.msi";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to generate installer"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MonitorSmartphone className="h-5 w-5" />
|
||||||
|
Windows MSI Installer
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-url">Server URL</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => setServerUrl(e.target.value)}
|
||||||
|
placeholder="http://your-server:3002"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The installer will be pre-configured to point the agent at this
|
||||||
|
Fleet Forge server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleGenerate} disabled={loading || !serverUrl.trim()}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Generate Installer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && !error && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Installer generated and downloaded as forge-agent.msi.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
The installer embeds a single-use enrollment token. Each generated
|
||||||
|
MSI can only enroll one device; generate a new installer for every
|
||||||
|
device you deploy.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadsPage() {
|
||||||
|
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<AgentInfo>("/v1/downloads/agent").then((res) => {
|
||||||
|
if (res.success && res.data) setAgentInfo(res.data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownload = async (filename: string) => {
|
||||||
|
const token = api.getToken();
|
||||||
|
const url = `${getApiBase()}/v1/downloads/agent/${filename}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const blob = await res.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = blobUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, key: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(key);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gpoTemplate = `msiexec /i forge-agent.msi /qn /l*v install.log`;
|
||||||
|
|
||||||
|
const uninstallTemplate = `sc stop FleetForgeAgent
|
||||||
|
msiexec /x forge-agent.msi /qn`;
|
||||||
|
|
||||||
|
const linuxTemplate = `chmod +x forge-agent-linux-amd64
|
||||||
|
sudo ./forge-agent enroll -token <TOKEN> -server http://SERVER:3002
|
||||||
|
sudo ./forge-agent run`;
|
||||||
|
|
||||||
|
const systemdTemplate = `[Unit]
|
||||||
|
Description=Fleet Forge Agent
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/forge-agent run
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Agent Downloads</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Download the Fleet Forge agent for your platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="windows-msi" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="windows-msi">Windows MSI Installer</TabsTrigger>
|
||||||
|
<TabsTrigger value="binaries">All Binaries</TabsTrigger>
|
||||||
|
<TabsTrigger value="deployment-guide">Deployment Guide</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="windows-msi">
|
||||||
|
<WindowsInstallerCard />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="binaries">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
Forge Agent {agentInfo?.version}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{agentInfo?.platforms.map((platform) => {
|
||||||
|
const Icon = osIcons[platform.os] || Download;
|
||||||
|
return (
|
||||||
|
<Card key={`${platform.os}-${platform.arch}`}>
|
||||||
|
<CardContent className="p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{platform.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{platform.sizeFormatted}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!platform.available}
|
||||||
|
onClick={() => handleDownload(platform.filename)}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3 mr-2" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!agentInfo && (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
Loading agent binaries...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="deployment-guide">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Windows GPO Deployment
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Deploy the MSI silently across your fleet via Group Policy:
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||||
|
{gpoTemplate}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(gpoTemplate, "gpo")}
|
||||||
|
>
|
||||||
|
{copied === "gpo" ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Uninstall</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Stop the service and remove the agent from a Windows device:
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||||
|
{uninstallTemplate}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(uninstallTemplate, "uninstall")}
|
||||||
|
>
|
||||||
|
{copied === "uninstall" ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Linux Quick Start</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Download the Linux binary, then enroll and run it:
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||||
|
{linuxTemplate}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(linuxTemplate, "linux")}
|
||||||
|
>
|
||||||
|
{copied === "linux" ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Linux Systemd Service
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
For permanent installation on Linux, create a systemd unit
|
||||||
|
file:
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||||
|
{systemdTemplate}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(systemdTemplate, "systemd")}
|
||||||
|
>
|
||||||
|
{copied === "systemd" ? (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 210 40% 98%;
|
||||||
|
--foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
--primary: 222 47% 11%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215 16% 47%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214 32% 91%;
|
||||||
|
--input: 214 32% 91%;
|
||||||
|
--ring: 222 47% 11%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--sidebar: 222 47% 11%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-accent: 217 33% 17%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217 33% 17%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222 47% 6%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222 47% 8%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222 47% 8%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
--secondary: 217 33% 14%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217 33% 14%;
|
||||||
|
--muted-foreground: 215 20% 55%;
|
||||||
|
|
||||||
|
--accent: 217 33% 14%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 63% 31%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217 33% 14%;
|
||||||
|
--input: 217 33% 14%;
|
||||||
|
--ring: 212 27% 84%;
|
||||||
|
|
||||||
|
--sidebar: 222 47% 4%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-accent: 217 33% 12%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217 33% 12%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, Users } from "lucide-react";
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupsPage() {
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newDesc, setNewDesc] = useState("");
|
||||||
|
|
||||||
|
const fetchGroups = () => {
|
||||||
|
api.get<Group[]>("/v1/groups").then((res) => {
|
||||||
|
if (res.success && res.data) setGroups(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchGroups(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await api.post("/v1/groups", { name: newName, description: newDesc });
|
||||||
|
setNewName("");
|
||||||
|
setNewDesc("");
|
||||||
|
setShowCreate(false);
|
||||||
|
fetchGroups();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Groups</h1>
|
||||||
|
<p className="text-muted-foreground">Organize devices into groups</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Group
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Group</DialogTitle>
|
||||||
|
<DialogDescription>Add a new device group to organize your fleet.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={newName} onChange={(e) => setNewName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Users className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No groups created yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<TableRow key={g.id}>
|
||||||
|
<TableCell className="font-medium">{g.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{g.description || "\u2014"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Zap, Ban, Trash2, Eye, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
id: string;
|
||||||
|
jobId: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string | null;
|
||||||
|
status: string;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: string;
|
||||||
|
jobType: string;
|
||||||
|
status: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
targets?: Target[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariant: Record<string, "info" | "warning" | "success" | "destructive" | "secondary" | "outline"> = {
|
||||||
|
queued: "info",
|
||||||
|
dispatched: "info",
|
||||||
|
running: "warning",
|
||||||
|
succeeded: "success",
|
||||||
|
failed: "destructive",
|
||||||
|
cancelled: "secondary",
|
||||||
|
expired: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set(["queued", "dispatched", "running"]);
|
||||||
|
const POLL_INTERVAL = 4000;
|
||||||
|
|
||||||
|
async function fetchJobs(): Promise<Job[]> {
|
||||||
|
const res = await api.get<Job[]>("/v1/jobs");
|
||||||
|
return res.success && res.data ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJobDetail(id: string): Promise<Job> {
|
||||||
|
const res = await api.get<Job>(`/v1/jobs/${id}`);
|
||||||
|
if (res.success && res.data) return res.data;
|
||||||
|
throw new Error(res.error?.message || "Failed to load job");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobsPage() {
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const list = await fetchJobs();
|
||||||
|
setJobs(list);
|
||||||
|
setTotal(list.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, POLL_INTERVAL);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailOpen || !selectedJob) return;
|
||||||
|
const id = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const detail = await fetchJobDetail(selectedJob.id);
|
||||||
|
setSelectedJob(detail);
|
||||||
|
setJobs((prev) => prev.map((j) => (j.id === detail.id ? detail : j)));
|
||||||
|
} catch {
|
||||||
|
/* ignore transient polling errors */
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [detailOpen, selectedJob]);
|
||||||
|
|
||||||
|
const openDetail = async (job: Job) => {
|
||||||
|
setSelectedJob(job);
|
||||||
|
setDetailOpen(true);
|
||||||
|
try {
|
||||||
|
const detail = await fetchJobDetail(job.id);
|
||||||
|
setSelectedJob(detail);
|
||||||
|
} catch {
|
||||||
|
/* keep cached summary */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelJob = async (id: string) => {
|
||||||
|
setBusyId(id);
|
||||||
|
await api.patch(`/v1/jobs/${id}/cancel`);
|
||||||
|
setBusyId(null);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteJob = async (id: string) => {
|
||||||
|
setBusyId(id);
|
||||||
|
await api.delete(`/v1/jobs/${id}`);
|
||||||
|
setBusyId(null);
|
||||||
|
if (selectedJob?.id === id) setDetailOpen(false);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
|
||||||
|
<p className="text-muted-foreground">{total} job(s) total</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={load}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No jobs have been created yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Completed</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<TableRow key={job.id}>
|
||||||
|
<TableCell className="font-medium text-sm">
|
||||||
|
{job.jobType.replace(/_/g, " ")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant[job.status] || "secondary"}>
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(job.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{job.completedAt ? new Date(job.completedAt).toLocaleString() : "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openDetail(job)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{ACTIVE_STATUSES.has(job.status) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId === job.id}
|
||||||
|
onClick={() => cancelJob(job.id)}
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4 text-amber-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId === job.id}
|
||||||
|
onClick={() => deleteJob(job.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<span>{selectedJob?.jobType.replace(/_/g, " ")}</span>
|
||||||
|
{selectedJob && (
|
||||||
|
<Badge variant={statusVariant[selectedJob.status] || "secondary"}>
|
||||||
|
{selectedJob.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{selectedJob && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{ACTIVE_STATUSES.has(selectedJob.status) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId === selectedJob.id}
|
||||||
|
onClick={() => {
|
||||||
|
cancelJob(selectedJob.id);
|
||||||
|
setSelectedJob({ ...selectedJob, status: "cancelled" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4 mr-2 text-amber-500" /> Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId === selectedJob.id}
|
||||||
|
onClick={() => deleteJob(selectedJob.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">Job ID</div>
|
||||||
|
<div className="font-mono">{selectedJob.id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">Created</div>
|
||||||
|
<div>{new Date(selectedJob.createdAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
{selectedJob.completedAt && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">Completed</div>
|
||||||
|
<div>{new Date(selectedJob.completedAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs mb-2">Parameters</div>
|
||||||
|
<pre className="bg-muted rounded p-3 text-xs max-h-40 overflow-auto font-mono">
|
||||||
|
{JSON.stringify(selectedJob.parameters, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs mb-2">
|
||||||
|
Targets ({selectedJob.targets?.length || 0})
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Started</TableHead>
|
||||||
|
<TableHead>Result</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(selectedJob.targets || []).map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="text-sm">{t.deviceName || t.deviceId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant[t.status] || "secondary"}>
|
||||||
|
{t.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{t.startedAt ? new Date(t.startedAt).toLocaleTimeString() : "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{t.status === "failed" &&
|
||||||
|
(t.result as { error?: string } | null)?.error ? (
|
||||||
|
<span className="text-destructive break-all">
|
||||||
|
{(t.result as { error?: string }).error}
|
||||||
|
</span>
|
||||||
|
) : t.status === "succeeded" ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{(t.result as { output?: string } | null)?.output
|
||||||
|
?.slice(0, 120) || "OK"}
|
||||||
|
</span>
|
||||||
|
) : t.status === "running" ? (
|
||||||
|
<span className="text-warning">Installing\u2026</span>
|
||||||
|
) : (
|
||||||
|
"\u2014"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Fleet Forge",
|
||||||
|
description: "Unified Endpoint & Infrastructure Management Platform",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className="min-h-screen bg-background antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Hexagon, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState("admin@fleetforge.local");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ token: string; user: { id: string; email: string; displayName: string; role: string } }>("/v1/auth/login", { email, password });
|
||||||
|
if (res.success && res.data) {
|
||||||
|
api.setToken(res.data.token);
|
||||||
|
router.push("/dashboard");
|
||||||
|
} else {
|
||||||
|
setError(res.error?.message || "Login failed");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Connection failed. Is the API running?");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-sidebar">
|
||||||
|
<div className="w-full max-w-md px-4">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
|
<Hexagon className="h-10 w-10 text-primary-foreground" />
|
||||||
|
<h1 className="text-3xl font-bold text-primary-foreground tracking-tight">
|
||||||
|
Fleet Forge
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-primary-foreground/50 text-sm">
|
||||||
|
Endpoint & Infrastructure Management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sign in to your account</CardTitle>
|
||||||
|
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="admin@fleetforge.local"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-primary-foreground/30 text-xs mt-6">
|
||||||
|
Fleet Forge v0.1.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Shield } from "lucide-react";
|
||||||
|
|
||||||
|
interface Policy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rules: Array<{ type: string; condition: string; value: string | number | boolean }>;
|
||||||
|
isEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoliciesPage() {
|
||||||
|
const [policies, setPolicies] = useState<Policy[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Policy[]>("/v1/policies").then((res) => {
|
||||||
|
if (res.success && res.data) setPolicies(res.data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Policies</h1>
|
||||||
|
<p className="text-muted-foreground">Compliance and configuration policies</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{policies.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No policies configured yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{policies.map((policy) => (
|
||||||
|
<Card key={policy.id}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-lg">{policy.name}</CardTitle>
|
||||||
|
<Badge variant={policy.isEnabled ? "success" : "secondary"}>
|
||||||
|
{policy.isEnabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{policy.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">{policy.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{policy.rules.length} rule(s) · Created{" "}
|
||||||
|
{new Date(policy.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, KeyRound, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface EnrollToken {
|
||||||
|
id: string;
|
||||||
|
expiresAt: string;
|
||||||
|
maxUses: number | null;
|
||||||
|
useCount: number;
|
||||||
|
isRevoked: boolean;
|
||||||
|
label: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnrollmentPage() {
|
||||||
|
const [tokens, setTokens] = useState<EnrollToken[]>([]);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
label: "",
|
||||||
|
expiresInHours: 24,
|
||||||
|
maxUses: null as number | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchTokens = () => {
|
||||||
|
api.get<EnrollToken[]>("/v1/enrollment-tokens").then((res) => {
|
||||||
|
if (res.success && res.data) setTokens(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchTokens(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const res = await api.post<{ id: string; token: string; expiresAt: string; label: string | null }>(
|
||||||
|
"/v1/enrollment-tokens",
|
||||||
|
form
|
||||||
|
);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setNewToken(res.data.token);
|
||||||
|
}
|
||||||
|
setShowCreate(false);
|
||||||
|
fetchTokens();
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeToken = async (id: string) => {
|
||||||
|
await api.delete(`/v1/enrollment-tokens/${id}`);
|
||||||
|
fetchTokens();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToken = async () => {
|
||||||
|
if (newToken) {
|
||||||
|
await navigator.clipboard.writeText(newToken);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Enrollment Tokens</h1>
|
||||||
|
<p className="text-muted-foreground">Create tokens for device enrollment</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Token
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Enrollment Token</DialogTitle>
|
||||||
|
<DialogDescription>Generate a new token for device enrollment.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Label</Label>
|
||||||
|
<Input value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Expires in hours</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
value={form.expiresInHours}
|
||||||
|
onChange={(e) => setForm({ ...form, expiresInHours: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max uses (empty = unlimited)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxUses ?? ""}
|
||||||
|
onChange={(e) => setForm({ ...form, maxUses: e.target.value ? parseInt(e.target.value) : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||||
|
<Button type="submit">Generate Token</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newToken && (
|
||||||
|
<Alert className="mb-6 border-green-200 bg-green-50">
|
||||||
|
<KeyRound className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="text-sm font-medium text-green-800 mb-2">
|
||||||
|
Enrollment token created. Copy it now — it won't be shown again.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-green-100 p-3 rounded text-sm text-green-900 break-all font-mono">
|
||||||
|
{newToken}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="icon" onClick={copyToken}>
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setNewToken(null)}
|
||||||
|
className="mt-2 text-xs text-green-700 hover:text-green-900"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead>Uses</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<TableRow key={token.id}>
|
||||||
|
<TableCell className="text-sm">{token.label || "\u2014"}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{token.useCount}
|
||||||
|
{token.maxUses ? ` / ${token.maxUses}` : ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(token.expiresAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
token.isRevoked
|
||||||
|
? "destructive"
|
||||||
|
: new Date(token.expiresAt) < new Date()
|
||||||
|
? "outline"
|
||||||
|
: "success"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{token.isRevoked ? "Revoked" : new Date(token.expiresAt) < new Date() ? "Expired" : "Active"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(token.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!token.isRevoked && (
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => revokeToken(token.id)}>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{tokens.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-12">
|
||||||
|
No enrollment tokens created yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
displayName: "",
|
||||||
|
role: "operator",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchUsers = () => {
|
||||||
|
api.get<User[]>("/v1/users").then((res) => {
|
||||||
|
if (res.success && res.data) setUsers(res.data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchUsers(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await api.post("/v1/users", form);
|
||||||
|
setForm({ email: "", password: "", displayName: "", role: "operator" });
|
||||||
|
setShowCreate(false);
|
||||||
|
fetchUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
||||||
|
<p className="text-muted-foreground">Manage platform users</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create User</DialogTitle>
|
||||||
|
<DialogDescription>Add a new user to the platform.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Display Name</Label>
|
||||||
|
<Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={8} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="operator">Operator</SelectItem>
|
||||||
|
<SelectItem value="read_only">Read Only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||||
|
<Button type="submit">Create User</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Last Login</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="text-sm font-medium">{user.displayName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
<Badge variant="outline">{user.role}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.isActive ? "success" : "destructive"}>
|
||||||
|
{user.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "Never"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Package, Search, Loader2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface CatalogPackage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
publisher?: string;
|
||||||
|
latestVersion: string;
|
||||||
|
versions?: string[];
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
iconUrl?: string;
|
||||||
|
homepage?: string;
|
||||||
|
license?: string;
|
||||||
|
downloadCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogResponse {
|
||||||
|
packages: CatalogPackage[];
|
||||||
|
total?: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
winget: "Windows Package Manager (winget)",
|
||||||
|
chocolatey: "Chocolatey",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppCatalogTab({ source }: { source: "winget" | "chocolatey" }) {
|
||||||
|
const [packages, setPackages] = useState<CatalogPackage[]>([]);
|
||||||
|
const [total, setTotal] = useState<number | undefined>(undefined);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit] = useState(25);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [installPkg, setInstallPkg] = useState<CatalogPackage | null>(null);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState("");
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [installError, setInstallError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setDebouncedSearch(search);
|
||||||
|
setPage(1);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const params: Record<string, string> = { page: String(page), limit: String(limit) };
|
||||||
|
if (debouncedSearch.trim()) params.search = debouncedSearch.trim();
|
||||||
|
api
|
||||||
|
.get<CatalogResponse>(`/v1/appcatalog/${source}`, params)
|
||||||
|
.then((r) => {
|
||||||
|
if (r.success && r.data) {
|
||||||
|
setPackages(r.data.packages || []);
|
||||||
|
setTotal(r.data.total);
|
||||||
|
} else {
|
||||||
|
setError(r.error?.message || "Failed to load catalog");
|
||||||
|
setPackages([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("Failed to load catalog");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [source, page, limit, debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Device[]>("/v1/devices?limit=100").then((r) => setDevices(r.data || []));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasMore = packages.length >= limit;
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!installPkg || !selectedDevice) return;
|
||||||
|
setInstalling(true);
|
||||||
|
setInstallError("");
|
||||||
|
const parameters: Record<string, unknown> = {
|
||||||
|
source,
|
||||||
|
package_id: installPkg.id,
|
||||||
|
package_version: installPkg.latestVersion,
|
||||||
|
};
|
||||||
|
const r = await api.post("/v1/jobs", {
|
||||||
|
jobType: "install_registry_package",
|
||||||
|
deviceIds: [selectedDevice],
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
setInstalling(false);
|
||||||
|
if (!r.success) {
|
||||||
|
setInstallError(r.error?.message || "Failed to create installation job");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInstallPkg(null);
|
||||||
|
setSelectedDevice("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={`Search ${source} packages...`}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{total !== undefined ? `${total} packages` : `${packages.length} packages`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card><CardContent className="p-12 text-center"><Loader2 className="h-8 w-8 animate-spin mx-auto" /></CardContent></Card>
|
||||||
|
) : packages.length === 0 ? (
|
||||||
|
<Card><CardContent className="p-12 text-center"><Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" /><p className="text-muted-foreground">No packages found{debouncedSearch ? ` for "${debouncedSearch}"` : ""}.</p></CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Package</TableHead>
|
||||||
|
<TableHead>Version</TableHead>
|
||||||
|
<TableHead>Publisher</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
{source === "chocolatey" && <TableHead>Downloads</TableHead>}
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{packages.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{p.iconUrl ? (
|
||||||
|
<img src={p.iconUrl} alt="" className="h-6 w-6 rounded object-contain" />
|
||||||
|
) : (
|
||||||
|
<Package className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{p.name}</div>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground">{p.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{p.latestVersion}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{p.publisher || "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-md">
|
||||||
|
<span className="line-clamp-2">{p.description || "—"}</span>
|
||||||
|
{p.tags && p.tags.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{p.tags.slice(0, 4).map((t) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-[10px]">{t}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{source === "chocolatey" && (
|
||||||
|
<TableCell className="text-sm">{p.downloadCount?.toLocaleString() ?? "—"}</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setInstallPkg(p)}>Install</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{total !== undefined ? `Page ${page} of ${Math.max(1, Math.ceil((total || 0) / limit))}` : `Page ${page}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!hasMore || loading} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
Next<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!installPkg} onOpenChange={() => setInstallPkg(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Install {installPkg?.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Deploy {installPkg?.name} ({installPkg?.id}) v{installPkg?.latestVersion} to a device via {SOURCE_LABELS[source]}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
{installError && <Alert variant="destructive"><AlertDescription>{installError}</AlertDescription></Alert>}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Target Device</Label>
|
||||||
|
<Select value={selectedDevice} onValueChange={setSelectedDevice}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a device" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<SelectItem key={d.id} value={d.id}>{d.name} ({d.status})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="outline" onClick={() => setInstallPkg(null)}>Cancel</Button>
|
||||||
|
<Button disabled={!selectedDevice || installing} onClick={handleInstall}>
|
||||||
|
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}Install
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Package, Upload, Trash2, Search, Loader2 } from "lucide-react";
|
||||||
|
import { AppCatalogTab } from "./AppCatalogTab";
|
||||||
|
|
||||||
|
interface SoftwareEntry { name: string; versions: PackageVersion[]; totalDevices: number; }
|
||||||
|
interface PackageVersion { version: string; manager: string; deviceCount: number; devices: Array<{ deviceId: string; deviceName: string }>; }
|
||||||
|
interface RepoPackage { id: string; name: string; description: string | null; version: string; packageType: string; platform: string; architecture: string; filename: string; fileSize: number; tags: string[]; createdAt: string; }
|
||||||
|
interface Device { id: string; name: string; status: string; platform: string; }
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return "0 B";
|
||||||
|
const k = 1024; const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SoftwarePage() {
|
||||||
|
const [tab, setTab] = useState("inventory");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Software</h1>
|
||||||
|
<p className="text-muted-foreground">Manage software across your fleet</p>
|
||||||
|
</div>
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="inventory">Fleet Inventory</TabsTrigger>
|
||||||
|
<TabsTrigger value="repository">Package Repository</TabsTrigger>
|
||||||
|
<TabsTrigger value="winget">winget</TabsTrigger>
|
||||||
|
<TabsTrigger value="chocolatey">Chocolatey</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="inventory"><InventoryTab search={search} setSearch={setSearch} /></TabsContent>
|
||||||
|
<TabsContent value="repository"><RepositoryTab /></TabsContent>
|
||||||
|
<TabsContent value="winget"><AppCatalogTab source="winget" /></TabsContent>
|
||||||
|
<TabsContent value="chocolatey"><AppCatalogTab source="chocolatey" /></TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryTab({ search, setSearch }: { search: string; setSearch: (v: string) => void }) {
|
||||||
|
const [packages, setPackages] = useState<SoftwareEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => { setLoading(true); api.get<SoftwareEntry[]>("/v1/software/inventory").then((r) => { setPackages(r.data || []); setLoading(false); }); }, []);
|
||||||
|
const filtered = packages.filter((p) => !search || p.name.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search packages..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" /></div>
|
||||||
|
<Badge variant="secondary">{packages.length} packages</Badge>
|
||||||
|
</div>
|
||||||
|
{loading ? <Card><CardContent className="p-12 text-center"><Loader2 className="h-8 w-8 animate-spin mx-auto" /></CardContent></Card>
|
||||||
|
: filtered.length === 0 ? <Card><CardContent className="p-12 text-center"><Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" /><p className="text-muted-foreground">No packages found.</p></CardContent></Card>
|
||||||
|
: <div className="space-y-3">{filtered.map((pkg) => (
|
||||||
|
<Card key={pkg.name}><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-2"><Package className="h-5 w-5 text-muted-foreground" /><span className="font-mono font-medium text-sm">{pkg.name}</span><Badge variant="outline">{pkg.totalDevices} device{pkg.totalDevices !== 1 ? "s" : ""}</Badge></div>
|
||||||
|
<div className="space-y-1 ml-8">{pkg.versions.map((v, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs"><Badge variant="secondary" className="font-mono">{v.version}</Badge><Badge variant="outline" className="text-xs">{v.manager}</Badge><span className="text-muted-foreground">on {v.devices.map((d) => d.deviceName).join(", ")}</span></div>
|
||||||
|
))}</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
))}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepositoryTab() {
|
||||||
|
const [packages, setPackages] = useState<RepoPackage[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [installPkg, setInstallPkg] = useState<RepoPackage | null>(null);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState("");
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [form, setForm] = useState({ name: "", version: "1.0.0", description: "", platform: "windows", architecture: "amd64", tags: "" });
|
||||||
|
|
||||||
|
const load = () => { setLoading(true); api.get<RepoPackage[]>("/v1/software/repository").then((r) => { setPackages(r.data || []); setTotal(r.meta?.total || 0); setLoading(false); }); };
|
||||||
|
const loadDevices = () => { api.get<Device[]>("/v1/devices?limit=100").then((r) => { setDevices(r.data || []); }); };
|
||||||
|
useEffect(() => { load(); loadDevices(); }, []);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault(); setUploadError("");
|
||||||
|
const file = fileRef.current?.files?.[0];
|
||||||
|
if (!file) { setUploadError("Please select a file."); return; }
|
||||||
|
setUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("name", form.name || file.name);
|
||||||
|
fd.append("version", form.version);
|
||||||
|
fd.append("description", form.description);
|
||||||
|
fd.append("platform", form.platform);
|
||||||
|
fd.append("architecture", form.architecture);
|
||||||
|
fd.append("tags", JSON.stringify(form.tags.split(",").map((t) => t.trim()).filter(Boolean)));
|
||||||
|
try {
|
||||||
|
const token = api.getToken();
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"}/v1/software/repository`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) { setShowUpload(false); setForm({ name: "", version: "1.0.0", description: "", platform: "windows", architecture: "amd64", tags: "" }); if (fileRef.current) fileRef.current.value = ""; load(); }
|
||||||
|
else { setUploadError(data.error?.message || "Upload failed"); }
|
||||||
|
} catch (err) { setUploadError("Upload failed"); }
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!installPkg || !selectedDevice) return;
|
||||||
|
setInstalling(true);
|
||||||
|
await api.post("/v1/jobs", { jobType: "download_and_install", deviceIds: [selectedDevice], parameters: { package_id: installPkg.id } });
|
||||||
|
setInstalling(false); setInstallPkg(null); setSelectedDevice("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Delete this package?")) return;
|
||||||
|
await api.delete(`/v1/software/repository/${id}`);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary">{total} packages</Badge>
|
||||||
|
<Dialog open={showUpload} onOpenChange={setShowUpload}>
|
||||||
|
<DialogTrigger asChild><Button><Upload className="mr-2 h-4 w-4" />Upload Package</Button></DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<form onSubmit={handleUpload}>
|
||||||
|
<DialogHeader><DialogTitle>Upload Software Package</DialogTitle><DialogDescription>Upload MSI, EXE, DEB, RPM, DMG, or PKG files.</DialogDescription></DialogHeader>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
{uploadError && <Alert variant="destructive"><AlertDescription>{uploadError}</AlertDescription></Alert>}
|
||||||
|
<div className="space-y-2"><Label>File *</Label><input ref={fileRef} type="file" accept=".msi,.exe,.msix,.deb,.rpm,.dmg,.pkg,.appx,.tar.gz" className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:bg-primary file:text-primary-foreground" required /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Auto-detect from filename" /></div>
|
||||||
|
<div className="space-y-2"><Label>Version</Label><Input value={form.version} onChange={(e) => setForm({ ...form, version: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label>Description</Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2"><Label>Platform</Label><Select value={form.platform} onValueChange={(v) => setForm({ ...form, platform: v })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="windows">Windows</SelectItem><SelectItem value="linux">Linux</SelectItem><SelectItem value="macos">macOS</SelectItem></SelectContent></Select></div>
|
||||||
|
<div className="space-y-2"><Label>Architecture</Label><Select value={form.architecture} onValueChange={(v) => setForm({ ...form, architecture: v })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="amd64">amd64</SelectItem><SelectItem value="arm64">arm64</SelectItem><SelectItem value="all">all</SelectItem></SelectContent></Select></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label>Tags (comma-separated)</Label><Input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="e.g. utility, development" /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6"><Button type="button" variant="outline" onClick={() => setShowUpload(false)}>Cancel</Button><Button type="submit" disabled={uploading}>{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}Upload</Button></DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!installPkg} onOpenChange={() => setInstallPkg(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Install Package</DialogTitle><DialogDescription>Deploy {installPkg?.name} v{installPkg?.version} to a device</DialogDescription></DialogHeader>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2"><Label>Target Device</Label><Select value={selectedDevice} onValueChange={setSelectedDevice}><SelectTrigger><SelectValue placeholder="Select a device" /></SelectTrigger><SelectContent>{devices.map((d) => <SelectItem key={d.id} value={d.id}>{d.name} ({d.status})</SelectItem>)}</SelectContent></Select></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-4"><Button variant="outline" onClick={() => setInstallPkg(null)}>Cancel</Button><Button disabled={!selectedDevice || installing} onClick={handleInstall}>{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}Install</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{loading ? <Card><CardContent className="p-12 text-center"><Loader2 className="h-8 w-8 animate-spin mx-auto" /></CardContent></Card>
|
||||||
|
: packages.length === 0 ? <Card><CardContent className="p-12 text-center"><Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" /><p className="text-muted-foreground">No packages uploaded yet.</p></CardContent></Card>
|
||||||
|
: <Card><Table><TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Version</TableHead><TableHead>Platform</TableHead><TableHead>Type</TableHead><TableHead>Size</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>{packages.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell><div className="font-medium text-sm">{p.name}</div>{p.description && <p className="text-xs text-muted-foreground">{p.description}</p>}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{p.version}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline" className="capitalize">{p.platform}</Badge></TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{p.packageType}</TableCell>
|
||||||
|
<TableCell className="text-sm">{formatBytes(p.fileSize)}</TableCell>
|
||||||
|
<TableCell><div className="flex gap-1"><Button size="sm" variant="outline" onClick={() => setInstallPkg(p)}>Install</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => handleDelete(p.id)}><Trash2 className="h-3 w-3" /></Button></div></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}</TableBody></Table></Card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { ClipboardCheck, Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface UpdateRequest {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string | null;
|
||||||
|
packageName: string;
|
||||||
|
packageManager: string;
|
||||||
|
currentVersion: string | null;
|
||||||
|
targetVersion: string;
|
||||||
|
requestedByName: string | null;
|
||||||
|
status: string;
|
||||||
|
rejectionReason: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariant: Record<string, "info" | "warning" | "success" | "destructive" | "secondary" | "outline"> = {
|
||||||
|
pending: "warning",
|
||||||
|
approved: "success",
|
||||||
|
rejected: "destructive",
|
||||||
|
deployed: "success",
|
||||||
|
cancelled: "secondary",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UpdateApprovalsPage() {
|
||||||
|
const [requests, setRequests] = useState<UpdateRequest[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const loadRequests = () => {
|
||||||
|
api.get<UpdateRequest[]>("/v1/update-requests").then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setRequests(res.data);
|
||||||
|
setTotal(res.meta?.total || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadRequests(); }, []);
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
await api.patch(`/v1/update-requests/${id}/approve`, { deployNow: true });
|
||||||
|
loadRequests();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
const reason = prompt("Rejection reason:");
|
||||||
|
if (!reason) return;
|
||||||
|
await api.patch(`/v1/update-requests/${id}/reject`, { reason });
|
||||||
|
loadRequests();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string) => {
|
||||||
|
await api.patch(`/v1/update-requests/${id}/cancel`);
|
||||||
|
loadRequests();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Update Approvals</h1>
|
||||||
|
<p className="text-muted-foreground">{total} update request(s)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<ClipboardCheck className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">No update requests pending.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Package</TableHead>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>Versions</TableHead>
|
||||||
|
<TableHead>Requested By</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{requests.map((req) => (
|
||||||
|
<TableRow key={req.id}>
|
||||||
|
<TableCell className="font-mono text-sm font-medium">{req.packageName}</TableCell>
|
||||||
|
<TableCell className="text-sm">{req.deviceName || req.deviceId.slice(0, 8)}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{req.currentVersion || "?"} → <span className="font-mono">{req.targetVersion}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{req.requestedByName || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant[req.status] || "secondary"}>{req.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(req.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{req.status === "pending" && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleApprove(req.id)}>
|
||||||
|
<Check className="h-3 w-3 mr-1" /> Approve
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleReject(req.id)}>
|
||||||
|
<X className="h-3 w-3 mr-1" /> Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{req.status === "pending" && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-xs" onClick={() => handleCancel(req.id)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{req.rejectionReason && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{req.rejectionReason}</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { user, loading, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [user, loading, router]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<aside className="w-64 bg-sidebar border-r border-sidebar-border p-6 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
<div className="space-y-3 mt-8">
|
||||||
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-9 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 p-8 space-y-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<Sidebar onLogout={logout} />
|
||||||
|
<main className="flex-1 p-8 overflow-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Package, Search, Loader2, ArrowLeft, Power, RotateCw, Trash2, RefreshCw, Wrench, Download,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface DeviceRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogPackage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
latestVersion: string;
|
||||||
|
publisher?: string;
|
||||||
|
description?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogResponse {
|
||||||
|
packages: CatalogPackage[];
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = "actions" | "install" | "remove";
|
||||||
|
type ConfirmKind = "reboot" | "shutdown" | null;
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
winget: "winget",
|
||||||
|
chocolatey: "Chocolatey",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeviceActionsDialog({
|
||||||
|
device,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
device: DeviceRef;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [view, setView] = useState<View>("actions");
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmKind>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
// install subview
|
||||||
|
const [source, setSource] = useState<"winget" | "chocolatey">("winget");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [packages, setPackages] = useState<CatalogPackage[]>([]);
|
||||||
|
const [loadingCatalog, setLoadingCatalog] = useState(false);
|
||||||
|
const [installError, setInstallError] = useState("");
|
||||||
|
|
||||||
|
// remove subview
|
||||||
|
const [removePackageId, setRemovePackageId] = useState("");
|
||||||
|
const [removeSource, setRemoveSource] = useState<"winget" | "chocolatey">("winget");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setView("actions");
|
||||||
|
setConfirm(null);
|
||||||
|
setFeedback(null);
|
||||||
|
setSearch("");
|
||||||
|
setDebouncedSearch("");
|
||||||
|
setPackages([]);
|
||||||
|
setRemovePackageId("");
|
||||||
|
setInstallError("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(() => {
|
||||||
|
if (!debouncedSearch.trim()) {
|
||||||
|
setPackages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingCatalog(true);
|
||||||
|
setInstallError("");
|
||||||
|
api
|
||||||
|
.get<CatalogResponse>(`/v1/appcatalog/${source}`, {
|
||||||
|
search: debouncedSearch.trim(),
|
||||||
|
limit: "10",
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (r.success && r.data) {
|
||||||
|
setPackages(r.data.packages || []);
|
||||||
|
} else {
|
||||||
|
setPackages([]);
|
||||||
|
setInstallError(r.error?.message || "Search failed");
|
||||||
|
}
|
||||||
|
setLoadingCatalog(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setPackages([]);
|
||||||
|
setInstallError("Search failed");
|
||||||
|
setLoadingCatalog(false);
|
||||||
|
});
|
||||||
|
}, [source, debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCatalog();
|
||||||
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setBusy(false);
|
||||||
|
setFeedback(null);
|
||||||
|
setConfirm(null);
|
||||||
|
setView("actions");
|
||||||
|
};
|
||||||
|
|
||||||
|
const runJob = async (
|
||||||
|
jobType: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
successMessage: string
|
||||||
|
) => {
|
||||||
|
setBusy(true);
|
||||||
|
setFeedback(null);
|
||||||
|
const r = await api.post("/v1/jobs", {
|
||||||
|
jobType,
|
||||||
|
deviceIds: [device.id],
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!r.success) {
|
||||||
|
setFeedback({ type: "error", text: r.error?.message || "Failed to create job" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setFeedback({ type: "success", text: successMessage });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (confirm === "reboot") {
|
||||||
|
await runJob("reboot_system", {}, "Reboot job created. Device will restart shortly.");
|
||||||
|
setConfirm(null);
|
||||||
|
} else if (confirm === "shutdown") {
|
||||||
|
await runJob("shutdown_system", {}, "Shutdown job created. Device will power off shortly.");
|
||||||
|
setConfirm(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async (pkg: CatalogPackage) => {
|
||||||
|
const ok = await runJob(
|
||||||
|
"install_registry_package",
|
||||||
|
{ source, package_id: pkg.id, package_version: pkg.latestVersion },
|
||||||
|
`Installation job created for ${pkg.name}.`
|
||||||
|
);
|
||||||
|
if (ok) setDebouncedSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
if (!removePackageId.trim()) return;
|
||||||
|
const ok = await runJob(
|
||||||
|
"remove_package",
|
||||||
|
{ source: removeSource, package_name: removePackageId.trim() },
|
||||||
|
`Removal job created for ${removePackageId.trim()}.`
|
||||||
|
);
|
||||||
|
if (ok) setRemovePackageId("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const online = device.status === "online";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) reset(); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
<span>Actions</span>
|
||||||
|
<Badge variant={online ? "success" : "secondary"}>{device.status}</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm font-normal">{device.name}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{view === "install"
|
||||||
|
? "Search a software catalog and deploy it to this device."
|
||||||
|
: view === "remove"
|
||||||
|
? "Remove a software package from this device."
|
||||||
|
: "Choose an action to run on this device."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<Alert variant={feedback.type === "error" ? "destructive" : "default"}>
|
||||||
|
<AlertDescription>{feedback.text}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "actions" && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Button variant="outline" className="justify-start" onClick={() => setView("install")}>
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Software installieren
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start" onClick={() => setView("remove")}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Software entfernen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setConfirm(confirm === "reboot" ? null : "reboot")}
|
||||||
|
>
|
||||||
|
<RotateCw className="mr-2 h-4 w-4" /> Neu starten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setConfirm(confirm === "shutdown" ? null : "shutdown")}
|
||||||
|
>
|
||||||
|
<Power className="mr-2 h-4 w-4" /> Herunterfahren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => runJob("update_inventory", {}, "Inventory refresh job created.")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Inventar aktualisieren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => runJob("update_system_info", {}, "System info refresh job created.")}
|
||||||
|
>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4" /> System-Infos aktualisieren
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{confirm && (
|
||||||
|
<div className="rounded-md border border-amber-300/50 bg-amber-50 p-3 text-sm">
|
||||||
|
<p className="mb-3">
|
||||||
|
{confirm === "reboot"
|
||||||
|
? `${device.name} wird jetzt neu gestartet. Fortfahren?`
|
||||||
|
: `${device.name} wird jetzt heruntergefahren. Fortfahren?`}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirm === "shutdown" ? "destructive" : "default"}
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Bestätigen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "install" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={source} onValueChange={(v) => setSource(v as "winget" | "chocolatey")}>
|
||||||
|
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="winget">winget</SelectItem>
|
||||||
|
<SelectItem value="chocolatey">Chocolatey</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={`Search ${SOURCE_LABELS[source]}...`}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{installError && <Alert variant="destructive"><AlertDescription>{installError}</AlertDescription></Alert>}
|
||||||
|
|
||||||
|
{loadingCatalog ? (
|
||||||
|
<div className="py-8 text-center"><Loader2 className="mx-auto h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : debouncedSearch.trim() && packages.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">No packages found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-72 overflow-y-auto space-y-1">
|
||||||
|
{packages.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 rounded-md border p-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{p.iconUrl ? (
|
||||||
|
<img src={p.iconUrl} alt="" className="h-5 w-5 rounded object-contain" />
|
||||||
|
) : (
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground truncate mt-0.5">
|
||||||
|
{p.id} · v{p.latestVersion}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => handleInstall(p)}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Install"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => setView("actions")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1 h-4 w-4" /> Back to actions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "remove" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Package (Name oder ID)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="z.B. Obsidian / Notepad++.Notepad++"
|
||||||
|
value={removePackageId}
|
||||||
|
onChange={(e) => setRemovePackageId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Quelle</Label>
|
||||||
|
<Select value={removeSource} onValueChange={(v) => setRemoveSource(v as "winget" | "chocolatey")}>
|
||||||
|
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="winget">winget</SelectItem>
|
||||||
|
<SelectItem value="chocolatey">Chocolatey</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setView("actions")}>
|
||||||
|
<ArrowLeft className="mr-1 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={!removePackageId.trim() || busy} onClick={handleRemove}>
|
||||||
|
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Loader2, MonitorX, Minimize2, Maximize2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
status: string;
|
||||||
|
vncPort: number;
|
||||||
|
vncPassword?: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
deviceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsBaseUrl(): string {
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002/api";
|
||||||
|
return apiBase.replace(/^http/, "ws").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteAccessModal({
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const rfbRef = useRef<any>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rfbCreatedRef = useRef(false);
|
||||||
|
|
||||||
|
const createSession = useCallback(async () => {
|
||||||
|
setSession(null);
|
||||||
|
setError("");
|
||||||
|
setConnected(false);
|
||||||
|
setConnecting(true);
|
||||||
|
rfbCreatedRef.current = false;
|
||||||
|
|
||||||
|
const created = await api.post<SessionInfo>("/v1/remote/sessions", {
|
||||||
|
deviceId,
|
||||||
|
vncPort: 5900,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created.success || !created.data) {
|
||||||
|
setError(created.error?.message || "Failed to create remote session");
|
||||||
|
setConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(created.data);
|
||||||
|
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
const res = await api.get<SessionInfo>(`/v1/remote/sessions/${created.data!.id}`);
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setError(res.error?.message || "Remote session unavailable");
|
||||||
|
setConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSession(res.data);
|
||||||
|
if (res.data.status === "active") {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setConnecting(false);
|
||||||
|
connectVnc(res.data);
|
||||||
|
} else if (res.data.status === "failed" || res.data.status === "closed") {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setConnecting(false);
|
||||||
|
setError(res.data.errorMessage || `Session ended (${res.data.status})`);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
const connectVnc = useCallback((s: SessionInfo) => {
|
||||||
|
if (rfbCreatedRef.current) return;
|
||||||
|
const token = api.getToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wsUrl = `${wsBaseUrl()}/v1/remote/browser/${s.id}?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
import("@novnc/novnc").then((mod) => {
|
||||||
|
const RFB = mod.default;
|
||||||
|
const target =
|
||||||
|
containerRef.current || document.createElement("div");
|
||||||
|
const rfb = new RFB(target, wsUrl, {
|
||||||
|
credentials: { password: s.vncPassword || "" },
|
||||||
|
share: true,
|
||||||
|
});
|
||||||
|
rfbRef.current = rfb;
|
||||||
|
rfbCreatedRef.current = true;
|
||||||
|
rfb.addEventListener("connect", () => {
|
||||||
|
setConnected(true);
|
||||||
|
});
|
||||||
|
rfb.addEventListener("disconnect", (ev: any) => {
|
||||||
|
setConnected(false);
|
||||||
|
setError(ev?.detail?.clean ? "Disconnected" : `Disconnected: ${ev?.detail?.reason || "connection lost"}`);
|
||||||
|
});
|
||||||
|
rfb.addEventListener("credentialsrequired", () => {
|
||||||
|
setConnected(false);
|
||||||
|
setError("VNC password required but not provided");
|
||||||
|
});
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = true;
|
||||||
|
}).catch(() => {
|
||||||
|
setError("Failed to load VNC client");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
rfbCreatedRef.current = false;
|
||||||
|
createSession();
|
||||||
|
}, [open, createSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && rfbRef.current) {
|
||||||
|
try {
|
||||||
|
rfbRef.current.disconnect();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
rfbRef.current = null;
|
||||||
|
rfbCreatedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const stopSession = async () => {
|
||||||
|
if (session) {
|
||||||
|
await api.delete(`/v1/remote/sessions/${session.id}`);
|
||||||
|
}
|
||||||
|
if (rfbRef.current) {
|
||||||
|
try {
|
||||||
|
rfbRef.current.disconnect();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
rfbRef.current = null;
|
||||||
|
}
|
||||||
|
rfbCreatedRef.current = false;
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const vncElement = (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative w-full ${fullscreen ? "h-[calc(100vh-140px)]" : "h-[62vh]"} border rounded-md overflow-hidden bg-black`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) stopSession(); }}>
|
||||||
|
<DialogContent className={`${fullscreen ? "max-w-[96vw]" : "max-w-5xl"}`}>
|
||||||
|
{!fullscreen && (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<MonitorX className="h-4 w-4" />
|
||||||
|
<span>Remote Desktop</span>
|
||||||
|
<span className="text-muted-foreground text-sm font-normal">{deviceName}</span>
|
||||||
|
{connected ? (
|
||||||
|
<Badge variant="success">Connected</Badge>
|
||||||
|
) : connecting ? (
|
||||||
|
<Badge variant="warning">Starting VNC…</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Disconnected</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{session?.vncPassword
|
||||||
|
? `VNC via encrypted tunnel · Password: ${session.vncPassword}`
|
||||||
|
: "Establishing a secure VNC tunnel to this device…"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(connecting || (session && session.status !== "active")) && !error && (
|
||||||
|
<div className="flex items-center justify-center py-10 space-x-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Waiting for the agent to start VNC…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!connecting && !error && session && session.status === "active" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFullscreen((f) => !f)}
|
||||||
|
>
|
||||||
|
{fullscreen ? (
|
||||||
|
<>
|
||||||
|
<Minimize2 className="mr-1 h-4 w-4" /> Exit fullscreen
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Maximize2 className="mr-1 h-4 w-4" /> Fullscreen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => stopSession()}>
|
||||||
|
End session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{vncElement}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connected && !fullscreen && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Hint: use the toolbar area above the screen for clipboard and keys.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Server,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
Bell,
|
||||||
|
ScrollText,
|
||||||
|
UserCog,
|
||||||
|
KeyRound,
|
||||||
|
LogOut,
|
||||||
|
Hexagon,
|
||||||
|
Package,
|
||||||
|
ClipboardCheck,
|
||||||
|
Rocket,
|
||||||
|
Download,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/devices", label: "Devices", icon: Server },
|
||||||
|
{ href: "/groups", label: "Groups", icon: Users },
|
||||||
|
{ href: "/software", label: "Software", icon: Package },
|
||||||
|
{ href: "/deployments", label: "Deployments", icon: Rocket },
|
||||||
|
{ href: "/update-approvals", label: "Update Approvals", icon: ClipboardCheck },
|
||||||
|
{ href: "/policies", label: "Policies", icon: Shield },
|
||||||
|
{ href: "/jobs", label: "Jobs", icon: Zap },
|
||||||
|
{ href: "/alerts", label: "Alerts", icon: Bell },
|
||||||
|
{ href: "/downloads", label: "Downloads", icon: Download },
|
||||||
|
{ href: "/audit-log", label: "Audit Log", icon: ScrollText },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bottomItems = [
|
||||||
|
{ href: "/settings/users", label: "Users", icon: UserCog },
|
||||||
|
{ href: "/settings/enrollment", label: "Enrollment", icon: KeyRound },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({ onLogout }: { onLogout: () => void }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string) => pathname.startsWith(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-sidebar border-r border-sidebar-border flex flex-col min-h-screen">
|
||||||
|
<div className="p-6 border-b border-sidebar-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hexagon className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-sidebar-foreground tracking-tight">
|
||||||
|
Fleet Forge
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-sidebar-foreground/50">
|
||||||
|
Management Platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 px-3 py-4">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive(item.href)
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-sidebar-border">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{bottomItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive(item.href)
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start gap-3 text-sidebar-foreground/60 hover:text-red-400 hover:bg-sidebar-accent/50"
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "destructive" }>(
|
||||||
|
({ className, variant = "default", ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
variant === "destructive" && "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
success: "border-transparent bg-green-100 text-green-800",
|
||||||
|
warning: "border-transparent bg-yellow-100 text-yellow-800",
|
||||||
|
info: "border-transparent bg-blue-100 text-blue-800",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user