diff --git a/internal/web/assets.go b/internal/web/assets.go
new file mode 100644
index 0000000..08b0760
--- /dev/null
+++ b/internal/web/assets.go
@@ -0,0 +1,32 @@
+package web
+
+import (
+ "embed"
+ "html/template"
+ "io/fs"
+)
+
+// Embedded webroot assets - included at build time
+var (
+ // HTML Templates
+ //go:embed templates/*.html
+ templatesFS embed.FS
+
+ // Static assets (CSS, JS, images)
+ //go:embed static/*
+ staticFS embed.FS
+)
+
+// GetTemplates loads and parses embedded HTML templates
+func GetTemplates() (*template.Template, error) {
+ return template.ParseFS(templatesFS, "templates/*.html")
+}
+
+// GetStaticFS returns the embedded static file system
+func GetStaticFS() fs.FS {
+ staticSubFS, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ panic("failed to create static subdirectory: " + err.Error())
+ }
+ return staticSubFS
+}
diff --git a/internal/web/data.go b/internal/web/data.go
new file mode 100644
index 0000000..051f2cd
--- /dev/null
+++ b/internal/web/data.go
@@ -0,0 +1,73 @@
+package web
+
+import (
+ "time"
+)
+
+// DashboardData represents data for the dashboard template
+type DashboardData struct {
+ Title string `json:"title"`
+ Version string `json:"version"`
+ Uptime string `json:"uptime"`
+ SystemStatus string `json:"system_status"`
+ MinionCount int `json:"minion_count"`
+ MinionPort int `json:"minion_port"`
+ ConsolePort int `json:"console_port"`
+ WebPort int `json:"web_port"`
+ Minions []MinionInfo `json:"minions"`
+}
+
+// MinionInfo represents information about a connected minion
+type MinionInfo struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ ConnectedAt time.Time `json:"connected_at"`
+ LastSeen time.Time `json:"last_seen"`
+}
+
+// StatusResponse represents the API status response
+type StatusResponse struct {
+ Version string `json:"version"`
+ Uptime string `json:"uptime"`
+ Timestamp string `json:"timestamp"`
+ Servers ServerStatusInfo `json:"servers"`
+ Database DatabaseStatus `json:"database"`
+}
+
+// ServerStatusInfo represents server status information
+type ServerStatusInfo struct {
+ Minion ServerInfo `json:"minion"`
+ Console ServerInfo `json:"console"`
+ Web ServerInfo `json:"web"`
+}
+
+// ServerInfo represents individual server information
+type ServerInfo struct {
+ Port int `json:"port"`
+ Status string `json:"status"`
+ Connections int `json:"connections,omitempty"`
+}
+
+// DatabaseStatus represents database connection status
+type DatabaseStatus struct {
+ Status string `json:"status"`
+ Host string `json:"host"`
+}
+
+// MinionsResponse represents the API minions response
+type MinionsResponse struct {
+ Count int `json:"count"`
+ Minions []MinionInfo `json:"minions"`
+}
+
+// HealthResponse represents the API health response
+type HealthResponse struct {
+ Status string `json:"status"`
+ Timestamp string `json:"timestamp"`
+}
+
+// ErrorResponse represents an API error response
+type ErrorResponse struct {
+ Error string `json:"error"`
+ Message string `json:"message"`
+}
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
new file mode 100644
index 0000000..16cbc7d
--- /dev/null
+++ b/internal/web/handlers.go
@@ -0,0 +1,422 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/arhuman/minexus/internal/config"
+ "github.com/arhuman/minexus/internal/nexus"
+ "github.com/arhuman/minexus/internal/version"
+ pb "github.com/arhuman/minexus/protogen"
+ "go.uber.org/zap"
+)
+
+// WebServer represents the HTTP web server
+type WebServer struct {
+ config *config.NexusConfig
+ nexus *nexus.Server
+ templates *template.Template
+ logger *zap.Logger
+ startTime time.Time
+}
+
+// NewWebServer creates a new web server instance
+func NewWebServer(cfg *config.NexusConfig, nexusServer *nexus.Server, logger *zap.Logger) (*WebServer, error) {
+ // Load templates from file system
+ templatesPath := fmt.Sprintf("%s/templates/*.html", cfg.WebRoot)
+ templates, err := template.ParseGlob(templatesPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load web templates from %s: %w", templatesPath, err)
+ }
+
+ return &WebServer{
+ config: cfg,
+ nexus: nexusServer,
+ templates: templates,
+ logger: logger,
+ startTime: time.Now(),
+ }, nil
+}
+
+// handleDashboard serves the main dashboard page
+func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
+ ws.setSecurityHeaders(w)
+
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ data := ws.buildDashboardData()
+
+ if err := ws.templates.ExecuteTemplate(w, "base.html", data); err != nil {
+ ws.logger.Error("Failed to execute dashboard template", zap.Error(err))
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+}
+
+// handleDownload serves binary downloads
+func (ws *WebServer) handleDownload(w http.ResponseWriter, r *http.Request) {
+ ws.setSecurityHeaders(w)
+
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Extract path after /download/
+ path := strings.TrimPrefix(r.URL.Path, "/download/")
+ if path == "" || path == "/" {
+ ws.serveDownloadIndex(w, r)
+ return
+ }
+
+ // Serve binary file
+ ws.serveBinaryFile(w, r, path)
+}
+
+// serveDownloadIndex serves the download index page
+func (ws *WebServer) serveDownloadIndex(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ html := `
+
+
+
+ Downloads - Minexus
+
+
+
+ Binary Downloads
+
+
+ ← Back to Dashboard
+
+`
+ w.Write([]byte(html))
+}
+
+// handleAPIStatus serves the /api/status endpoint
+func (ws *WebServer) handleAPIStatus(w http.ResponseWriter, r *http.Request) {
+ ws.setJSONHeaders(w)
+
+ if r.Method != http.MethodGet {
+ ws.writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only GET requests are supported")
+ return
+ }
+
+ response := StatusResponse{
+ Version: version.Component("Nexus"),
+ Uptime: ws.getUptime(),
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ Servers: ServerStatusInfo{
+ Minion: ServerInfo{
+ Port: ws.config.MinionPort,
+ Status: "running",
+ Connections: ws.getMinionConnectionCount(),
+ },
+ Console: ServerInfo{
+ Port: ws.config.ConsolePort,
+ Status: "running",
+ Connections: ws.getConsoleConnectionCount(),
+ },
+ Web: ServerInfo{
+ Port: ws.config.WebPort,
+ Status: "running",
+ },
+ },
+ Database: DatabaseStatus{
+ Status: ws.getDatabaseStatus(),
+ Host: fmt.Sprintf("%s:%d", ws.config.DBHost, ws.config.DBPort),
+ },
+ }
+
+ if err := json.NewEncoder(w).Encode(response); err != nil {
+ ws.logger.Error("Failed to encode status response", zap.Error(err))
+ ws.writeJSONError(w, http.StatusInternalServerError, "Internal Server Error", "Failed to encode response")
+ }
+}
+
+// handleAPIMinions serves the /api/minions endpoint
+func (ws *WebServer) handleAPIMinions(w http.ResponseWriter, r *http.Request) {
+ ws.setJSONHeaders(w)
+
+ if r.Method != http.MethodGet {
+ ws.writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only GET requests are supported")
+ return
+ }
+
+ minions := ws.getConnectedMinions()
+ response := MinionsResponse{
+ Count: len(minions),
+ Minions: minions,
+ }
+
+ if err := json.NewEncoder(w).Encode(response); err != nil {
+ ws.logger.Error("Failed to encode minions response", zap.Error(err))
+ ws.writeJSONError(w, http.StatusInternalServerError, "Internal Server Error", "Failed to encode response")
+ }
+}
+
+// handleAPIHealth serves the /api/health endpoint
+func (ws *WebServer) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
+ ws.setJSONHeaders(w)
+
+ if r.Method != http.MethodGet {
+ ws.writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only GET requests are supported")
+ return
+ }
+
+ response := HealthResponse{
+ Status: "healthy",
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ if err := json.NewEncoder(w).Encode(response); err != nil {
+ ws.logger.Error("Failed to encode health response", zap.Error(err))
+ ws.writeJSONError(w, http.StatusInternalServerError, "Internal Server Error", "Failed to encode response")
+ }
+}
+
+// buildDashboardData constructs data for the dashboard template
+func (ws *WebServer) buildDashboardData() DashboardData {
+ minions := ws.getConnectedMinions()
+ systemStatus := "healthy"
+
+ // Determine system status based on various factors
+ if len(minions) == 0 {
+ systemStatus = "warning"
+ }
+
+ return DashboardData{
+ Title: "Dashboard",
+ Version: version.Component("Nexus"),
+ Uptime: ws.getUptime(),
+ SystemStatus: systemStatus,
+ MinionCount: len(minions),
+ MinionPort: ws.config.MinionPort,
+ ConsolePort: ws.config.ConsolePort,
+ WebPort: ws.config.WebPort,
+ Minions: minions,
+ }
+}
+
+// getUptime returns formatted uptime string
+func (ws *WebServer) getUptime() string {
+ duration := time.Since(ws.startTime)
+ days := int(duration.Hours()) / 24
+ hours := int(duration.Hours()) % 24
+ minutes := int(duration.Minutes()) % 60
+ seconds := int(duration.Seconds()) % 60
+
+ if days > 0 {
+ return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds)
+ } else if hours > 0 {
+ return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
+ } else if minutes > 0 {
+ return fmt.Sprintf("%dm %ds", minutes, seconds)
+ } else {
+ return fmt.Sprintf("%ds", seconds)
+ }
+}
+
+// getConnectedMinions returns information about connected minions
+func (ws *WebServer) getConnectedMinions() []MinionInfo {
+ if ws.nexus == nil {
+ return []MinionInfo{}
+ }
+
+ ctx := context.Background()
+ minionList, err := ws.nexus.ListMinions(ctx, &pb.Empty{})
+ if err != nil {
+ ws.logger.Error("Failed to get minion list", zap.Error(err))
+ return []MinionInfo{}
+ }
+
+ var minions []MinionInfo
+ for _, hostInfo := range minionList.Minions {
+ lastSeen := time.Unix(hostInfo.LastSeen, 0)
+ minion := MinionInfo{
+ ID: hostInfo.Id,
+ Status: "active", // All listed minions are considered active
+ ConnectedAt: lastSeen, // Use last seen as a proxy for connected time
+ LastSeen: lastSeen,
+ }
+ minions = append(minions, minion)
+ }
+
+ return minions
+}
+
+// getMinionConnectionCount returns the number of connected minions
+func (ws *WebServer) getMinionConnectionCount() int {
+ return len(ws.getConnectedMinions())
+}
+
+// getConsoleConnectionCount returns the number of console connections
+func (ws *WebServer) getConsoleConnectionCount() int {
+ // For now, assume 0-1 console connections since we don't track them separately
+ return 0 // Could be enhanced to track actual console connections
+}
+
+// getDatabaseStatus returns database connection status
+func (ws *WebServer) getDatabaseStatus() string {
+ if ws.nexus == nil {
+ return "disconnected"
+ }
+
+ // Try a simple health check by calling ListMinions
+ ctx := context.Background()
+ _, err := ws.nexus.ListMinions(ctx, &pb.Empty{})
+ if err != nil {
+ ws.logger.Error("Database health check failed", zap.Error(err))
+ return "disconnected"
+ }
+ return "connected"
+}
+
+// setSecurityHeaders sets security headers for HTTP responses
+func (ws *WebServer) setSecurityHeaders(w http.ResponseWriter) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("X-XSS-Protection", "1; mode=block")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+}
+
+// setJSONHeaders sets headers for JSON API responses
+func (ws *WebServer) setJSONHeaders(w http.ResponseWriter) {
+ ws.setSecurityHeaders(w)
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+}
+
+// writeJSONError writes a JSON error response
+func (ws *WebServer) writeJSONError(w http.ResponseWriter, statusCode int, error, message string) {
+ w.WriteHeader(statusCode)
+ response := ErrorResponse{
+ Error: error,
+ Message: message,
+ }
+ json.NewEncoder(w).Encode(response)
+}
+
+// logRequest logs HTTP requests
+func (ws *WebServer) logRequest(r *http.Request, statusCode int, duration time.Duration) {
+ ws.logger.Info("HTTP request processed",
+ zap.String("method", r.Method),
+ zap.String("path", r.URL.Path),
+ zap.String("remote_addr", r.RemoteAddr),
+ zap.String("user_agent", r.UserAgent()),
+ zap.Int("status", statusCode),
+ zap.Duration("duration", duration),
+ )
+}
+
+// loggingMiddleware provides request logging middleware
+func (ws *WebServer) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+
+ // Create a response writer that captures the status code
+ wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
+
+ next(wrapped, r)
+
+ ws.logRequest(r, wrapped.statusCode, time.Since(start))
+ }
+}
+
+// responseWriter wraps http.ResponseWriter to capture status code
+type responseWriter struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+func (rw *responseWriter) WriteHeader(code int) {
+ rw.statusCode = code
+ rw.ResponseWriter.WriteHeader(code)
+}
+
+// serveBinaryFile serves binary files for download
+func (ws *WebServer) serveBinaryFile(w http.ResponseWriter, r *http.Request, path string) {
+ // Parse the path to extract component and platform
+ parts := strings.Split(path, "/")
+ if len(parts) != 2 {
+ http.Error(w, "Invalid download path. Expected format: /download/component/platform", http.StatusBadRequest)
+ return
+ }
+
+ component := parts[0] // minion or console
+ platform := parts[1] // linux-amd64, windows-amd64.exe, etc.
+
+ // Validate component
+ if component != "minion" && component != "console" {
+ http.Error(w, "Invalid component. Must be 'minion' or 'console'", http.StatusBadRequest)
+ return
+ }
+
+ // Validate platform format
+ validPlatforms := map[string]bool{
+ "linux-amd64": true,
+ "linux-arm64": true,
+ "windows-amd64.exe": true,
+ "windows-arm64.exe": true,
+ "darwin-amd64": true,
+ "darwin-arm64": true,
+ }
+
+ if !validPlatforms[platform] {
+ http.Error(w, "Invalid platform", http.StatusBadRequest)
+ return
+ }
+
+ // For now, return a proper error message for missing binaries
+ // In a production system, this would serve actual pre-built binaries
+ ws.logger.Info("Binary download requested",
+ zap.String("component", component),
+ zap.String("platform", platform),
+ zap.String("remote_addr", r.RemoteAddr))
+
+ // Set appropriate headers for binary download
+ filename := component
+ if strings.HasSuffix(platform, ".exe") {
+ filename += ".exe"
+ }
+
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+
+ // Return informational response since we don't have actual binaries yet
+ // Write status and message manually to preserve Content-Type header
+ w.WriteHeader(http.StatusNotFound)
+ errorMsg := fmt.Sprintf("Binary for %s/%s not available. Pre-built binaries should be placed in binaries/%s/ directory.", component, platform, component)
+ w.Write([]byte(errorMsg))
+}
diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go
new file mode 100644
index 0000000..dace0e4
--- /dev/null
+++ b/internal/web/handlers_test.go
@@ -0,0 +1,626 @@
+package web
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/arhuman/minexus/internal/config"
+ "go.uber.org/zap"
+)
+
+func createTestWebServer() *WebServer {
+ cfg := &config.NexusConfig{
+ MinionPort: 11972,
+ ConsolePort: 11973,
+ WebPort: 8086,
+ WebEnabled: true,
+ WebRoot: "./webroot",
+ }
+
+ logger := zap.NewNop()
+
+ // Create templates using real file system approach
+ templatesPath := filepath.Join(cfg.WebRoot, "templates", "*.html")
+ templates, err := template.ParseGlob(templatesPath)
+ if err != nil {
+ // Fallback to embedded templates for testing if webroot doesn't exist
+ templates, _ = GetTemplates()
+ }
+
+ return &WebServer{
+ config: cfg,
+ nexus: nil, // We'll test without a real nexus server
+ templates: templates,
+ logger: logger,
+ startTime: time.Now(),
+ }
+}
+
+func TestHandleDashboardMethodNotAllowed(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleDashboard(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusMethodNotAllowed {
+ t.Errorf("Expected status 405, got %d", resp.StatusCode)
+ }
+}
+
+func TestHandleDashboardContentServing(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleDashboard(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ contentType := resp.Header.Get("Content-Type")
+ if !strings.Contains(contentType, "text/html") {
+ t.Errorf("Expected HTML content type, got %s", contentType)
+ }
+
+ body := w.Body.String()
+
+ // Verify key dashboard content is present
+ expectedContent := []string{
+ "Dashboard", // Page title from data
+ "System Status", // Section header from template
+ "Connected Minions", // Section header from template
+ "Server Ports", // Section header from template
+ "8086", // Web server port number
+ "11972", // Minion server port number
+ "11973", // Console server port number
+ "Minion (gRPC)", // Port label
+ "Console (mTLS)", // Port label
+ "Web (HTTP)", // Port label
+ }
+
+ for _, expected := range expectedContent {
+ if !strings.Contains(body, expected) {
+ t.Errorf("Expected dashboard to contain '%s', but it was missing", expected)
+ }
+ }
+
+ // Verify template was actually executed (not just empty response)
+ if len(body) < 100 {
+ t.Errorf("Dashboard response too short (%d chars), likely template not rendering", len(body))
+ }
+}
+
+func TestHandleAPIHealth(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleAPIHealth(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ contentType := resp.Header.Get("Content-Type")
+ if contentType != "application/json" {
+ t.Errorf("Expected JSON content type, got %s", contentType)
+ }
+
+ var healthResp HealthResponse
+ if err := json.NewDecoder(resp.Body).Decode(&healthResp); err != nil {
+ t.Fatalf("Failed to decode JSON response: %v", err)
+ }
+
+ if healthResp.Status != "healthy" {
+ t.Errorf("Expected status 'healthy', got %s", healthResp.Status)
+ }
+
+ if healthResp.Timestamp == "" {
+ t.Error("Expected timestamp to be set")
+ }
+}
+
+func TestHandleAPIStatus(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleAPIStatus(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ contentType := resp.Header.Get("Content-Type")
+ if contentType != "application/json" {
+ t.Errorf("Expected JSON content type, got %s", contentType)
+ }
+
+ var statusResp StatusResponse
+ if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil {
+ t.Fatalf("Failed to decode JSON response: %v", err)
+ }
+
+ // Verify required fields are present
+ if statusResp.Version == "" {
+ t.Error("Expected version to be set")
+ }
+ if statusResp.Uptime == "" {
+ t.Error("Expected uptime to be set")
+ }
+ if statusResp.Timestamp == "" {
+ t.Error("Expected timestamp to be set")
+ }
+
+ // Verify server information
+ if statusResp.Servers.Minion.Port != 11972 {
+ t.Errorf("Expected minion port 11972, got %d", statusResp.Servers.Minion.Port)
+ }
+ if statusResp.Servers.Console.Port != 11973 {
+ t.Errorf("Expected console port 11973, got %d", statusResp.Servers.Console.Port)
+ }
+ if statusResp.Servers.Web.Port != 8086 {
+ t.Errorf("Expected web port 8086, got %d", statusResp.Servers.Web.Port)
+ }
+
+ // Verify all servers show as running
+ if statusResp.Servers.Minion.Status != "running" {
+ t.Errorf("Expected minion status 'running', got %s", statusResp.Servers.Minion.Status)
+ }
+ if statusResp.Servers.Console.Status != "running" {
+ t.Errorf("Expected console status 'running', got %s", statusResp.Servers.Console.Status)
+ }
+ if statusResp.Servers.Web.Status != "running" {
+ t.Errorf("Expected web status 'running', got %s", statusResp.Servers.Web.Status)
+ }
+}
+
+func TestHandleAPIMinions(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/minions", nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleAPIMinions(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ contentType := resp.Header.Get("Content-Type")
+ if contentType != "application/json" {
+ t.Errorf("Expected JSON content type, got %s", contentType)
+ }
+
+ var minionsResp MinionsResponse
+ if err := json.NewDecoder(resp.Body).Decode(&minionsResp); err != nil {
+ t.Fatalf("Failed to decode JSON response: %v", err)
+ }
+
+ // With no nexus server, should return 0 minions
+ if minionsResp.Count != 0 {
+ t.Errorf("Expected 0 minions (no nexus server), got %d", minionsResp.Count)
+ }
+
+ if len(minionsResp.Minions) != 0 {
+ t.Errorf("Expected empty minions array, got %d minions", len(minionsResp.Minions))
+ }
+}
+
+func TestHandleDownloadIndex(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodGet, "/download/", nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleDownload(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "Binary Downloads") {
+ t.Error("Expected download index page to contain 'Binary Downloads'")
+ }
+
+ if !strings.Contains(body, "Minion Binaries") {
+ t.Error("Expected download index to contain 'Minion Binaries'")
+ }
+}
+
+func TestHandleDownloadBinary(t *testing.T) {
+ webServer := createTestWebServer()
+
+ testCases := []struct {
+ path string
+ expectedStatus int
+ expectedHeader string
+ }{
+ {"/download/minion/linux-amd64", http.StatusNotFound, "application/octet-stream"},
+ {"/download/console/windows-amd64.exe", http.StatusNotFound, "application/octet-stream"},
+ {"/download/invalid/linux-amd64", http.StatusBadRequest, ""},
+ {"/download/minion/invalid-platform", http.StatusBadRequest, ""},
+ {"/download/minion", http.StatusBadRequest, ""},
+ }
+
+ for _, tc := range testCases {
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ w := httptest.NewRecorder()
+
+ webServer.handleDownload(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != tc.expectedStatus {
+ t.Errorf("Path %s: expected status %d, got %d", tc.path, tc.expectedStatus, resp.StatusCode)
+ }
+
+ if tc.expectedHeader != "" {
+ contentType := resp.Header.Get("Content-Type")
+ if contentType != tc.expectedHeader {
+ t.Errorf("Path %s: expected Content-Type %s, got %s", tc.path, tc.expectedHeader, contentType)
+ }
+ }
+ }
+}
+
+func TestServeBinaryFileValidation(t *testing.T) {
+ webServer := createTestWebServer()
+
+ testCases := []struct {
+ name string
+ path string
+ expectedStatus int
+ expectedBody string
+ }{
+ {
+ name: "Valid minion binary",
+ path: "minion/linux-amd64",
+ expectedStatus: http.StatusNotFound,
+ expectedBody: "Binary for minion/linux-amd64 not available",
+ },
+ {
+ name: "Valid console binary",
+ path: "console/windows-amd64.exe",
+ expectedStatus: http.StatusNotFound,
+ expectedBody: "Binary for console/windows-amd64.exe not available",
+ },
+ {
+ name: "Invalid component",
+ path: "invalid/linux-amd64",
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "Invalid component",
+ },
+ {
+ name: "Invalid platform",
+ path: "minion/invalid-platform",
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "Invalid platform",
+ },
+ {
+ name: "Invalid path format",
+ path: "minion",
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "Invalid download path",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/download/"+tc.path, nil)
+ w := httptest.NewRecorder()
+
+ webServer.serveBinaryFile(w, req, tc.path)
+
+ resp := w.Result()
+ if resp.StatusCode != tc.expectedStatus {
+ t.Errorf("Expected status %d, got %d", tc.expectedStatus, resp.StatusCode)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, tc.expectedBody) {
+ t.Errorf("Expected body to contain %q, got %q", tc.expectedBody, body)
+ }
+ })
+ }
+}
+
+func TestSetSecurityHeaders(t *testing.T) {
+ webServer := createTestWebServer()
+ w := httptest.NewRecorder()
+
+ webServer.setSecurityHeaders(w)
+
+ expectedHeaders := map[string]string{
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "X-XSS-Protection": "1; mode=block",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+ }
+
+ for header, expectedValue := range expectedHeaders {
+ if got := w.Header().Get(header); got != expectedValue {
+ t.Errorf("Expected header %s: %s, got %s", header, expectedValue, got)
+ }
+ }
+}
+
+func TestSetJSONHeaders(t *testing.T) {
+ webServer := createTestWebServer()
+ w := httptest.NewRecorder()
+
+ webServer.setJSONHeaders(w)
+
+ expectedHeaders := map[string]string{
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "X-XSS-Protection": "1; mode=block",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+ }
+
+ for header, expectedValue := range expectedHeaders {
+ if got := w.Header().Get(header); got != expectedValue {
+ t.Errorf("Expected header %s: %s, got %s", header, expectedValue, got)
+ }
+ }
+}
+
+func TestWriteJSONError(t *testing.T) {
+ webServer := createTestWebServer()
+ w := httptest.NewRecorder()
+
+ webServer.writeJSONError(w, http.StatusBadRequest, "test error", "test message")
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("Expected status 400, got %d", resp.StatusCode)
+ }
+
+ var errorResp ErrorResponse
+ if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
+ t.Fatalf("Failed to decode JSON response: %v", err)
+ }
+
+ if errorResp.Error != "test error" {
+ t.Errorf("Expected error 'test error', got %s", errorResp.Error)
+ }
+
+ if errorResp.Message != "test message" {
+ t.Errorf("Expected message 'test message', got %s", errorResp.Message)
+ }
+}
+
+func TestGetUptime(t *testing.T) {
+ startTime := time.Now().Add(-time.Hour * 2)
+ webServer := &WebServer{startTime: startTime}
+
+ uptime := webServer.getUptime()
+
+ // Just check that uptime contains expected time components
+ if !strings.Contains(uptime, "h") {
+ t.Error("Expected uptime to contain hours")
+ }
+}
+
+func TestLogRequest(t *testing.T) {
+ webServer := createTestWebServer()
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ duration := time.Millisecond * 100
+
+ // This test mainly ensures the method doesn't panic
+ webServer.logRequest(req, 200, duration)
+}
+
+func TestLoggingMiddleware(t *testing.T) {
+ webServer := createTestWebServer()
+
+ testHandler := func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test"))
+ }
+
+ wrappedHandler := webServer.loggingMiddleware(testHandler)
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ w := httptest.NewRecorder()
+
+ wrappedHandler(w, req)
+
+ resp := w.Result()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ body := w.Body.String()
+ if body != "test" {
+ t.Errorf("Expected body 'test', got %s", body)
+ }
+}
+
+func TestResponseWriter(t *testing.T) {
+ w := httptest.NewRecorder()
+ rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
+
+ rw.WriteHeader(http.StatusNotFound)
+
+ if rw.statusCode != http.StatusNotFound {
+ t.Errorf("Expected status code 404, got %d", rw.statusCode)
+ }
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("Expected underlying recorder code 404, got %d", w.Code)
+ }
+}
+
+func TestBuildDashboardDataWithoutNexus(t *testing.T) {
+ webServer := createTestWebServer()
+ data := webServer.buildDashboardData()
+
+ if data.Title != "Dashboard" {
+ t.Errorf("Expected title 'Dashboard', got %s", data.Title)
+ }
+
+ if data.MinionCount != 0 {
+ t.Errorf("Expected minion count 0 (no nexus server), got %d", data.MinionCount)
+ }
+
+ if data.SystemStatus != "warning" {
+ t.Errorf("Expected system status 'warning' (no minions), got %s", data.SystemStatus)
+ }
+
+ if data.WebPort != 8086 {
+ t.Errorf("Expected web port 8086, got %d", data.WebPort)
+ }
+}
+
+func TestGetConnectedMinionsWithoutNexus(t *testing.T) {
+ webServer := createTestWebServer()
+ minions := webServer.getConnectedMinions()
+
+ if len(minions) != 0 {
+ t.Errorf("Expected 0 minions (no nexus server), got %d", len(minions))
+ }
+}
+
+func TestGetDatabaseStatusWithoutNexus(t *testing.T) {
+ webServer := createTestWebServer()
+ status := webServer.getDatabaseStatus()
+
+ if status != "disconnected" {
+ t.Errorf("Expected 'disconnected' (no nexus server), got %s", status)
+ }
+}
+
+// Test that validates platform names are correct
+func TestValidPlatformNames(t *testing.T) {
+ webServer := createTestWebServer()
+
+ validPlatforms := []string{
+ "linux-amd64",
+ "linux-arm64",
+ "windows-amd64.exe",
+ "windows-arm64.exe",
+ "darwin-amd64",
+ "darwin-arm64",
+ }
+
+ for _, platform := range validPlatforms {
+ path := fmt.Sprintf("minion/%s", platform)
+ req := httptest.NewRequest(http.MethodGet, "/download/"+path, nil)
+ w := httptest.NewRecorder()
+
+ webServer.serveBinaryFile(w, req, path)
+
+ resp := w.Result()
+ // Should get 404 (not found) rather than 400 (bad request) for valid platforms
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("Platform %s: expected status 404 (valid platform), got %d", platform, resp.StatusCode)
+ }
+ }
+}
+
+// Integration test that actually starts HTTP server and tests content serving
+func TestWebServerIntegration(t *testing.T) {
+ webServer := createTestWebServer()
+
+ // Setup routes manually like StartWebServer does
+ mux := http.NewServeMux()
+
+ // Static assets from file system
+ staticDir := fmt.Sprintf("%s/static", webServer.config.WebRoot)
+ staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
+ mux.Handle("/static/", staticHandler)
+
+ // Dashboard with file system templates
+ mux.HandleFunc("/", webServer.loggingMiddleware(webServer.handleDashboard))
+
+ // Binary downloads
+ mux.HandleFunc("/download/", webServer.loggingMiddleware(webServer.handleDownload))
+
+ // API endpoints
+ mux.HandleFunc("/api/status", webServer.loggingMiddleware(webServer.handleAPIStatus))
+ mux.HandleFunc("/api/minions", webServer.loggingMiddleware(webServer.handleAPIMinions))
+ mux.HandleFunc("/api/health", webServer.loggingMiddleware(webServer.handleAPIHealth))
+
+ // Create test server
+ testServer := httptest.NewServer(mux)
+ defer testServer.Close()
+
+ // Test dashboard endpoint
+ resp, err := http.Get(testServer.URL + "/")
+ if err != nil {
+ t.Fatalf("Failed to GET dashboard: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Dashboard: expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Test API health endpoint
+ resp, err = http.Get(testServer.URL + "/api/health")
+ if err != nil {
+ t.Fatalf("Failed to GET health: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Health: expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Verify JSON response content
+ var healthResp HealthResponse
+ if err := json.NewDecoder(resp.Body).Decode(&healthResp); err != nil {
+ t.Fatalf("Failed to decode health JSON: %v", err)
+ }
+
+ if healthResp.Status != "healthy" {
+ t.Errorf("Expected healthy status, got %s", healthResp.Status)
+ }
+
+ // Test API status endpoint
+ resp, err = http.Get(testServer.URL + "/api/status")
+ if err != nil {
+ t.Fatalf("Failed to GET status: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Status: expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Test download index
+ resp, err = http.Get(testServer.URL + "/download/")
+ if err != nil {
+ t.Fatalf("Failed to GET download index: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Download index: expected status 200, got %d", resp.StatusCode)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..dc48b5b
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,60 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/arhuman/minexus/internal/config"
+ "github.com/arhuman/minexus/internal/nexus"
+ "go.uber.org/zap"
+)
+
+// StartWebServer starts the HTTP web server
+func StartWebServer(cfg *config.NexusConfig, nexusServer *nexus.Server, logger *zap.Logger) error {
+ if !cfg.WebEnabled {
+ logger.Info("Web server disabled")
+ return nil
+ }
+
+ // Create web server instance
+ webServer, err := NewWebServer(cfg, nexusServer, logger)
+ if err != nil {
+ return fmt.Errorf("failed to create web server: %w", err)
+ }
+
+ // Create HTTP multiplexer
+ mux := http.NewServeMux()
+
+ // Static assets from file system
+ staticDir := fmt.Sprintf("%s/static", cfg.WebRoot)
+ staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
+ mux.Handle("/static/", staticHandler)
+
+ // Dashboard with file system templates
+ mux.HandleFunc("/", webServer.loggingMiddleware(webServer.handleDashboard))
+
+ // Binary downloads
+ mux.HandleFunc("/download/", webServer.loggingMiddleware(webServer.handleDownload))
+
+ // API endpoints
+ mux.HandleFunc("/api/status", webServer.loggingMiddleware(webServer.handleAPIStatus))
+ mux.HandleFunc("/api/minions", webServer.loggingMiddleware(webServer.handleAPIMinions))
+ mux.HandleFunc("/api/health", webServer.loggingMiddleware(webServer.handleAPIHealth))
+
+ // Create HTTP server with appropriate timeouts
+ server := &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.WebPort),
+ Handler: mux,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ logger.Info("Web server starting with file system assets",
+ zap.Int("port", cfg.WebPort),
+ zap.String("webroot", cfg.WebRoot),
+ zap.String("address", server.Addr))
+
+ return server.ListenAndServe()
+}
diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css
new file mode 100644
index 0000000..5da97d7
--- /dev/null
+++ b/internal/web/static/css/main.css
@@ -0,0 +1,246 @@
+/* Reset and base styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ line-height: 1.6;
+ color: var(--text-primary);
+ background: var(--bg-primary);
+}
+
+/* Header */
+header {
+ background: var(--card-bg);
+ border-bottom: var(--border);
+ padding: 1rem 2rem;
+ box-shadow: var(--shadow);
+}
+
+nav {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.logo {
+ height: 40px;
+ width: auto;
+}
+
+h1 {
+ color: var(--text-primary);
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+/* Main content */
+main {
+ flex: 1;
+ padding: 2rem;
+}
+
+.dashboard {
+ display: grid;
+ gap: 1.5rem;
+}
+
+.status-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1.5rem;
+}
+
+.status-card {
+ background: var(--card-bg);
+ border-radius: 8px;
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+ border: var(--border);
+}
+
+.status-card h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ color: var(--text-primary);
+}
+
+/* Status indicators */
+.status-indicator {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ font-weight: 500;
+ text-transform: uppercase;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+}
+
+.status-indicator.healthy,
+.status-indicator.running {
+ background: rgba(16, 185, 129, 0.1);
+ color: var(--success);
+}
+
+.status-indicator.warning {
+ background: rgba(245, 158, 11, 0.1);
+ color: var(--warning);
+}
+
+.status-indicator.error {
+ background: rgba(239, 68, 68, 0.1);
+ color: var(--error);
+}
+
+/* Minion display */
+.minion-count {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--primary);
+ margin-bottom: 1rem;
+}
+
+.minion-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.minion-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+}
+
+.minion-id {
+ font-family: monospace;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.minion-status {
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.minion-status.active {
+ background: rgba(16, 185, 129, 0.1);
+ color: var(--success);
+}
+
+.minion-status.inactive {
+ background: rgba(239, 68, 68, 0.1);
+ color: var(--error);
+}
+
+/* Port list */
+.port-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.port-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+}
+
+.status-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+}
+
+.status-dot.running {
+ background: var(--success);
+}
+
+.status-dot.stopped {
+ background: var(--error);
+}
+
+/* Action buttons */
+.action-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.btn {
+ display: inline-block;
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ text-decoration: none;
+ font-weight: 500;
+ text-align: center;
+ transition: all 0.2s ease;
+ border: 1px solid transparent;
+}
+
+.btn.primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn.primary:hover {
+ background: color-mix(in srgb, var(--primary) 90%, black);
+}
+
+.btn.secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: var(--border);
+}
+
+.btn.secondary:hover {
+ background: var(--bg-primary);
+}
+
+/* Footer */
+footer {
+ background: var(--card-bg);
+ border-top: var(--border);
+ padding: 1rem 2rem;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ main {
+ padding: 1rem;
+ }
+
+ .status-grid {
+ grid-template-columns: 1fr;
+ }
+
+ nav {
+ flex-direction: column;
+ text-align: center;
+ gap: 0.5rem;
+ }
+
+ .logo {
+ height: 32px;
+ }
+
+ h1 {
+ font-size: 1.25rem;
+ }
+}
\ No newline at end of file
diff --git a/internal/web/static/css/theme.css b/internal/web/static/css/theme.css
new file mode 100644
index 0000000..d25ddaa
--- /dev/null
+++ b/internal/web/static/css/theme.css
@@ -0,0 +1,171 @@
+:root {
+ /* Colors - easily customizable */
+ --primary: #2563eb;
+ --secondary: #64748b;
+ --success: #10b981;
+ --warning: #f59e0b;
+ --error: #ef4444;
+
+ /* Background colors */
+ --bg-primary: #f8fafc;
+ --bg-secondary: #f1f5f9;
+ --card-bg: #ffffff;
+
+ /* Text colors */
+ --text-primary: #1e293b;
+ --text-secondary: #64748b;
+ --text-muted: #94a3b8;
+
+ /* Shadows and borders */
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
+ --border: 1px solid #e2e8f0;
+}
+
+/* Dark theme variant */
+[data-theme="dark"] {
+ --primary: #3b82f6;
+ --secondary: #94a3b8;
+ --success: #22c55e;
+ --warning: #fbbf24;
+ --error: #f87171;
+
+ --bg-primary: #0f172a;
+ --bg-secondary: #1e293b;
+ --card-bg: #334155;
+
+ --text-primary: #f1f5f9;
+ --text-secondary: #cbd5e1;
+ --text-muted: #94a3b8;
+
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
+ --border: 1px solid #475569;
+}
+
+/* Theme toggle button */
+.theme-toggle {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: var(--card-bg);
+ border: var(--border);
+ border-radius: 6px;
+ padding: 0.5rem;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ color: var(--text-primary);
+ font-size: 1.25rem;
+}
+
+.theme-toggle:hover {
+ background: var(--bg-secondary);
+}
+
+/* Smooth transitions for theme changes */
+* {
+ transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+}
+
+/* Custom scrollbar for webkit browsers */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--text-muted);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Focus styles for accessibility */
+.btn:focus,
+button:focus,
+input:focus {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+/* Loading animation */
+.loading {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--text-muted);
+ border-radius: 50%;
+ border-top-color: var(--primary);
+ animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Pulse animation for status indicators */
+.status-indicator.loading {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: .5;
+ }
+}
+
+/* Auto-refresh indicator */
+.auto-refresh {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ background: var(--card-bg);
+ border: var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ box-shadow: var(--shadow);
+}
+
+.auto-refresh.active {
+ color: var(--success);
+}
+
+/* Custom properties for component variants */
+.status-card.critical {
+ border-left: 4px solid var(--error);
+}
+
+.status-card.warning {
+ border-left: 4px solid var(--warning);
+}
+
+.status-card.success {
+ border-left: 4px solid var(--success);
+}
+
+/* Hover effects */
+.status-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px 0 rgb(0 0 0 / 0.15);
+}
+
+.minion-item:hover {
+ background: var(--bg-primary);
+}
+
+.port-item:hover {
+ background: var(--bg-primary);
+}
\ No newline at end of file
diff --git a/internal/web/static/images/favicon.ico b/internal/web/static/images/favicon.ico
new file mode 100644
index 0000000..9a05a79
--- /dev/null
+++ b/internal/web/static/images/favicon.ico
@@ -0,0 +1 @@
+data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZmF2aWNvbkdyYWRpZW50IiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj4KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3R5bGU9InN0b3AtY29sb3I6IzI1NjNlYjtzdG9wLW9wYWNpdHk6MSIgLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdHlsZT0ic3RvcC1jb2xvcjojMWQ0ZWQ4O3N0b3Atb3BhY2l0eToxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgCiAgPCEtLSBCYWNrZ3JvdW5kIGNpcmNsZSAtLT4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNCIgZmlsbD0idXJsKCNmYXZpY29uR3JhZGllbnQpIiBzdHJva2U9IiMxZTQwYWYiIHN0cm9rZS13aWR0aD0iMSIvPgogIAogIDwhLS0gTmV0d29yayBub2RlIHJlcHJlc2VudGF0aW9uIC0tPgogIDxjaXJjbGUgY3g9IjE2IiBjeT0iMTYiIHI9IjIiIGZpbGw9IndoaXRlIi8+CiAgPGNpcmNsZSBjeD0iMTAiIGN5PSIxMCIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgPGNpcmNsZSBjeD0iMjIiIGN5PSIxMCIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgPGNpcmNsZSBjeD0iMTAiIGN5PSIyMiIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgPGNpcmNsZSBjeD0iMjIiIGN5PSIyMiIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgCiAgPCEtLSBDb25uZWN0aW9uIGxpbmVzIC0tPgogIDxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMTAiIHkyPSIxMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxIiBvcGFjaXR5PSIwLjYiLz4KICA8bGluZSB4MT0iMTYiIHkxPSIxNiIgeDI9IjIyIiB5Mj0iMTAiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMSIgb3BhY2l0eT0iMC42Ii8+CiAgPGxpbmUgeDE9IjE2IiB5MT0iMTYiIHgyPSIxMCIgeTI9IjIyIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEiIG9wYWNpdHk9IjAuNiIvPgogIDxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjIiIHkyPSIyMiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxIiBvcGFjaXR5PSIwLjYiLz4KPC9zdmc+
\ No newline at end of file
diff --git a/internal/web/static/images/logo.svg b/internal/web/static/images/logo.svg
new file mode 100644
index 0000000..3c658df
--- /dev/null
+++ b/internal/web/static/images/logo.svg
@@ -0,0 +1,29 @@
+
+
\ No newline at end of file
diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js
new file mode 100644
index 0000000..ab5d5c5
--- /dev/null
+++ b/internal/web/static/js/api.js
@@ -0,0 +1,183 @@
+class MinexusAPI {
+ constructor(baseURL = '') {
+ this.baseURL = baseURL;
+ }
+
+ async getStatus() {
+ try {
+ const response = await fetch(`${this.baseURL}/api/status`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to fetch status:', error);
+ throw error;
+ }
+ }
+
+ async getMinions() {
+ try {
+ const response = await fetch(`${this.baseURL}/api/minions`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to fetch minions:', error);
+ throw error;
+ }
+ }
+
+ async getHealth() {
+ try {
+ const response = await fetch(`${this.baseURL}/api/health`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to fetch health:', error);
+ throw error;
+ }
+ }
+}
+
+// Create global API instance
+const api = new MinexusAPI();
+
+// Utility functions for formatting
+function formatUptime(uptimeSeconds) {
+ const days = Math.floor(uptimeSeconds / 86400);
+ const hours = Math.floor((uptimeSeconds % 86400) / 3600);
+ const minutes = Math.floor((uptimeSeconds % 3600) / 60);
+ const seconds = uptimeSeconds % 60;
+
+ let result = '';
+ if (days > 0) result += `${days}d `;
+ if (hours > 0) result += `${hours}h `;
+ if (minutes > 0 || result === '') result += `${minutes}m `;
+ if (seconds > 0 && days === 0) result += `${seconds}s`;
+
+ return result.trim();
+}
+
+function formatTimestamp(timestamp) {
+ return new Date(timestamp).toLocaleString();
+}
+
+function getStatusClass(status) {
+ switch (status?.toLowerCase()) {
+ case 'healthy':
+ case 'running':
+ case 'active':
+ return 'success';
+ case 'warning':
+ return 'warning';
+ case 'error':
+ case 'unhealthy':
+ case 'stopped':
+ case 'inactive':
+ return 'error';
+ default:
+ return 'secondary';
+ }
+}
+
+// Theme management
+function getTheme() {
+ return localStorage.getItem('theme') || 'light';
+}
+
+function setTheme(theme) {
+ localStorage.setItem('theme', theme);
+ document.documentElement.setAttribute('data-theme', theme);
+}
+
+function toggleTheme() {
+ const currentTheme = getTheme();
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
+ setTheme(newTheme);
+}
+
+// Initialize theme on load
+document.addEventListener('DOMContentLoaded', function () {
+ setTheme(getTheme());
+});
+
+// Error handling utilities
+function showError(message, container = document.body) {
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'error-message';
+ errorDiv.style.cssText = `
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: var(--error);
+ color: white;
+ padding: 1rem;
+ border-radius: 6px;
+ box-shadow: var(--shadow);
+ z-index: 1000;
+ max-width: 300px;
+ `;
+ errorDiv.textContent = message;
+
+ container.appendChild(errorDiv);
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => {
+ if (errorDiv.parentNode) {
+ errorDiv.parentNode.removeChild(errorDiv);
+ }
+ }, 5000);
+
+ // Click to dismiss
+ errorDiv.addEventListener('click', () => {
+ if (errorDiv.parentNode) {
+ errorDiv.parentNode.removeChild(errorDiv);
+ }
+ });
+}
+
+function showSuccess(message, container = document.body) {
+ const successDiv = document.createElement('div');
+ successDiv.className = 'success-message';
+ successDiv.style.cssText = `
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: var(--success);
+ color: white;
+ padding: 1rem;
+ border-radius: 6px;
+ box-shadow: var(--shadow);
+ z-index: 1000;
+ max-width: 300px;
+ `;
+ successDiv.textContent = message;
+
+ container.appendChild(successDiv);
+
+ // Auto-remove after 3 seconds
+ setTimeout(() => {
+ if (successDiv.parentNode) {
+ successDiv.parentNode.removeChild(successDiv);
+ }
+ }, 3000);
+}
+
+// Loading indicator utilities
+function showLoading(element) {
+ if (element) {
+ element.classList.add('loading');
+ element.innerHTML = '';
+ }
+}
+
+function hideLoading(element, originalContent) {
+ if (element) {
+ element.classList.remove('loading');
+ element.innerHTML = originalContent || '';
+ }
+}
\ No newline at end of file
diff --git a/internal/web/static/js/dashboard.js b/internal/web/static/js/dashboard.js
new file mode 100644
index 0000000..0569459
--- /dev/null
+++ b/internal/web/static/js/dashboard.js
@@ -0,0 +1,320 @@
+document.addEventListener('DOMContentLoaded', function () {
+ let refreshInterval;
+ let isAutoRefreshEnabled = true;
+
+ // Initialize dashboard
+ initializeDashboard();
+ setupEventListeners();
+ startAutoRefresh();
+
+ function initializeDashboard() {
+ // Add theme toggle button if not exists
+ addThemeToggle();
+
+ // Add auto-refresh indicator
+ addAutoRefreshIndicator();
+
+ // Initial data load
+ refreshDashboard();
+ }
+
+ function setupEventListeners() {
+ // Theme toggle
+ const themeToggle = document.getElementById('theme-toggle');
+ if (themeToggle) {
+ themeToggle.addEventListener('click', toggleTheme);
+ }
+
+ // Auto-refresh toggle
+ const autoRefreshToggle = document.getElementById('auto-refresh-toggle');
+ if (autoRefreshToggle) {
+ autoRefreshToggle.addEventListener('click', toggleAutoRefresh);
+ }
+
+ // Manual refresh button
+ const refreshButton = document.getElementById('refresh-button');
+ if (refreshButton) {
+ refreshButton.addEventListener('click', () => {
+ refreshDashboard();
+ showSuccess('Dashboard refreshed');
+ });
+ }
+
+ // Handle visibility change (pause refresh when tab not visible)
+ document.addEventListener('visibilitychange', function () {
+ if (document.hidden) {
+ stopAutoRefresh();
+ } else if (isAutoRefreshEnabled) {
+ startAutoRefresh();
+ }
+ });
+ }
+
+ function addThemeToggle() {
+ if (document.getElementById('theme-toggle')) return;
+
+ const themeToggle = document.createElement('button');
+ themeToggle.id = 'theme-toggle';
+ themeToggle.className = 'theme-toggle';
+ themeToggle.innerHTML = '🌙';
+ themeToggle.title = 'Toggle dark mode';
+ document.body.appendChild(themeToggle);
+
+ // Update icon based on current theme
+ updateThemeToggleIcon();
+ }
+
+ function updateThemeToggleIcon() {
+ const themeToggle = document.getElementById('theme-toggle');
+ if (themeToggle) {
+ const isDark = getTheme() === 'dark';
+ themeToggle.innerHTML = isDark ? '☀️' : '🌙';
+ themeToggle.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
+ }
+ }
+
+ function addAutoRefreshIndicator() {
+ if (document.getElementById('auto-refresh')) return;
+
+ const indicator = document.createElement('div');
+ indicator.id = 'auto-refresh';
+ indicator.className = 'auto-refresh';
+ indicator.innerHTML = `
+
+ 🔄 Auto-refresh: ON
+
+
+ `;
+ document.body.appendChild(indicator);
+ }
+
+ function startAutoRefresh() {
+ stopAutoRefresh(); // Clear any existing interval
+
+ let countdown = 30; // 30 seconds
+ updateRefreshCountdown(countdown);
+
+ refreshInterval = setInterval(() => {
+ countdown--;
+ updateRefreshCountdown(countdown);
+
+ if (countdown <= 0) {
+ refreshDashboard();
+ countdown = 30; // Reset countdown
+ }
+ }, 1000);
+
+ updateAutoRefreshStatus(true);
+ }
+
+ function stopAutoRefresh() {
+ if (refreshInterval) {
+ clearInterval(refreshInterval);
+ refreshInterval = null;
+ }
+ updateRefreshCountdown(0);
+ }
+
+ function toggleAutoRefresh() {
+ isAutoRefreshEnabled = !isAutoRefreshEnabled;
+
+ if (isAutoRefreshEnabled) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+
+ updateAutoRefreshStatus(isAutoRefreshEnabled);
+ }
+
+ function updateAutoRefreshStatus(enabled) {
+ const status = document.getElementById('refresh-status');
+ const indicator = document.getElementById('auto-refresh');
+
+ if (status) {
+ status.textContent = enabled ? 'ON' : 'OFF';
+ }
+
+ if (indicator) {
+ indicator.className = enabled ? 'auto-refresh active' : 'auto-refresh';
+ }
+ }
+
+ function updateRefreshCountdown(seconds) {
+ const countdown = document.getElementById('refresh-countdown');
+ if (countdown) {
+ if (seconds > 0) {
+ countdown.textContent = `(${seconds}s)`;
+ } else {
+ countdown.textContent = '';
+ }
+ }
+ }
+
+ async function refreshDashboard() {
+ try {
+ // Show loading state
+ showLoadingState();
+
+ // Fetch all data concurrently
+ const [statusData, minionsData] = await Promise.all([
+ api.getStatus(),
+ api.getMinions()
+ ]);
+
+ // Update dashboard sections
+ updateSystemStatus(statusData);
+ updateMinionStatus(minionsData);
+ updateServerPorts(statusData);
+
+ // Hide loading state
+ hideLoadingState();
+
+ } catch (error) {
+ console.error('Failed to refresh dashboard:', error);
+ showError('Failed to refresh dashboard data');
+ hideLoadingState();
+ }
+ }
+
+ function showLoadingState() {
+ const statusCards = document.querySelectorAll('.status-card');
+ statusCards.forEach(card => {
+ const indicator = card.querySelector('.status-indicator');
+ if (indicator) {
+ indicator.classList.add('loading');
+ }
+ });
+ }
+
+ function hideLoadingState() {
+ const statusCards = document.querySelectorAll('.status-card');
+ statusCards.forEach(card => {
+ const indicator = card.querySelector('.status-indicator');
+ if (indicator) {
+ indicator.classList.remove('loading');
+ }
+ });
+ }
+
+ function updateSystemStatus(data) {
+ const versionElement = document.querySelector('.status-card p:nth-of-type(1)');
+ const uptimeElement = document.querySelector('.status-card p:nth-of-type(2)');
+ const statusIndicator = document.querySelector('.status-indicator');
+
+ if (versionElement) {
+ versionElement.textContent = `Version: ${data.version || 'Unknown'}`;
+ }
+
+ if (uptimeElement) {
+ uptimeElement.textContent = `Uptime: ${data.uptime || 'Unknown'}`;
+ }
+
+ if (statusIndicator) {
+ const isHealthy = data.servers &&
+ data.servers.minion?.status === 'running' &&
+ data.servers.console?.status === 'running';
+ const status = isHealthy ? 'healthy' : 'warning';
+
+ statusIndicator.className = `status-indicator ${status}`;
+ statusIndicator.textContent = status.toUpperCase();
+ }
+ }
+
+ function updateMinionStatus(data) {
+ const countElement = document.querySelector('.minion-count');
+ const listElement = document.querySelector('.minion-list');
+
+ if (countElement) {
+ countElement.textContent = data.count || 0;
+ }
+
+ if (listElement) {
+ listElement.innerHTML = '';
+
+ if (data.minions && data.minions.length > 0) {
+ data.minions.forEach(minion => {
+ const minionItem = document.createElement('div');
+ minionItem.className = 'minion-item';
+ minionItem.innerHTML = `
+ ${minion.id}
+ ${minion.status}
+ `;
+ listElement.appendChild(minionItem);
+ });
+ } else {
+ const noMinions = document.createElement('div');
+ noMinions.className = 'minion-item';
+ noMinions.innerHTML = 'No minions connected';
+ listElement.appendChild(noMinions);
+ }
+ }
+ }
+
+ function updateServerPorts(data) {
+ const portList = document.querySelector('.port-list');
+ if (!portList || !data.servers) return;
+
+ const ports = [
+ { name: 'Minion (gRPC)', port: data.servers.minion?.port, status: data.servers.minion?.status },
+ { name: 'Console (mTLS)', port: data.servers.console?.port, status: data.servers.console?.status },
+ { name: 'Web (HTTP)', port: data.servers.web?.port, status: data.servers.web?.status }
+ ];
+
+ portList.innerHTML = '';
+
+ ports.forEach(portInfo => {
+ const portItem = document.createElement('div');
+ portItem.className = 'port-item';
+ portItem.innerHTML = `
+ ${portInfo.name}: ${portInfo.port || 'Unknown'}
+
+ `;
+ portList.appendChild(portItem);
+ });
+ }
+
+ // Override theme toggle to update icon
+ const originalToggleTheme = window.toggleTheme;
+ window.toggleTheme = function () {
+ if (originalToggleTheme) {
+ originalToggleTheme();
+ }
+ updateThemeToggleIcon();
+ };
+
+ // Add keyboard shortcuts
+ document.addEventListener('keydown', function (e) {
+ // Ctrl/Cmd + R for refresh
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
+ e.preventDefault();
+ refreshDashboard();
+ showSuccess('Dashboard refreshed');
+ }
+
+ // Ctrl/Cmd + D for dark mode toggle
+ if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
+ e.preventDefault();
+ toggleTheme();
+ }
+
+ // Space to toggle auto-refresh
+ if (e.code === 'Space' && e.target === document.body) {
+ e.preventDefault();
+ toggleAutoRefresh();
+ }
+ });
+
+ // Handle connection errors gracefully
+ window.addEventListener('online', function () {
+ showSuccess('Connection restored');
+ if (isAutoRefreshEnabled) {
+ startAutoRefresh();
+ }
+ });
+
+ window.addEventListener('offline', function () {
+ showError('Connection lost');
+ stopAutoRefresh();
+ });
+});
\ No newline at end of file
diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html
new file mode 100644
index 0000000..8690a7b
--- /dev/null
+++ b/internal/web/templates/base.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ {{.Title}} - Minexus
+
+
+
+
+
+
+
+
+
+
+ {{template "content" .}}
+
+
+
+ {{template "scripts" .}}
+
+
+
\ No newline at end of file
diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html
new file mode 100644
index 0000000..5ea5cad
--- /dev/null
+++ b/internal/web/templates/dashboard.html
@@ -0,0 +1,56 @@
+{{define "content"}}
+
+
+
+
System Status
+
{{.SystemStatus}}
+
Version: {{.Version}}
+
Uptime: {{.Uptime}}
+
+
+
+
Connected Minions
+
{{.MinionCount}}
+
+ {{range .Minions}}
+
+ {{.ID}}
+ {{.Status}}
+
+ {{end}}
+
+
+
+
+
Server Ports
+
+
+ Minion (gRPC): {{.MinionPort}}
+
+
+
+ Console (mTLS): {{.ConsolePort}}
+
+
+
+ Web (HTTP): {{.WebPort}}
+
+
+
+
+
+
+
+
+{{end}}
+
+{{define "scripts"}}
+
+{{end}}
\ No newline at end of file
diff --git a/webroot/static/css/main.css b/webroot/static/css/main.css
new file mode 100644
index 0000000..5da97d7
--- /dev/null
+++ b/webroot/static/css/main.css
@@ -0,0 +1,246 @@
+/* Reset and base styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ line-height: 1.6;
+ color: var(--text-primary);
+ background: var(--bg-primary);
+}
+
+/* Header */
+header {
+ background: var(--card-bg);
+ border-bottom: var(--border);
+ padding: 1rem 2rem;
+ box-shadow: var(--shadow);
+}
+
+nav {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.logo {
+ height: 40px;
+ width: auto;
+}
+
+h1 {
+ color: var(--text-primary);
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+/* Main content */
+main {
+ flex: 1;
+ padding: 2rem;
+}
+
+.dashboard {
+ display: grid;
+ gap: 1.5rem;
+}
+
+.status-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1.5rem;
+}
+
+.status-card {
+ background: var(--card-bg);
+ border-radius: 8px;
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+ border: var(--border);
+}
+
+.status-card h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ color: var(--text-primary);
+}
+
+/* Status indicators */
+.status-indicator {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ font-weight: 500;
+ text-transform: uppercase;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+}
+
+.status-indicator.healthy,
+.status-indicator.running {
+ background: rgba(16, 185, 129, 0.1);
+ color: var(--success);
+}
+
+.status-indicator.warning {
+ background: rgba(245, 158, 11, 0.1);
+ color: var(--warning);
+}
+
+.status-indicator.error {
+ background: rgba(239, 68, 68, 0.1);
+ color: var(--error);
+}
+
+/* Minion display */
+.minion-count {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--primary);
+ margin-bottom: 1rem;
+}
+
+.minion-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.minion-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+}
+
+.minion-id {
+ font-family: monospace;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.minion-status {
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.minion-status.active {
+ background: rgba(16, 185, 129, 0.1);
+ color: var(--success);
+}
+
+.minion-status.inactive {
+ background: rgba(239, 68, 68, 0.1);
+ color: var(--error);
+}
+
+/* Port list */
+.port-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.port-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+}
+
+.status-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+}
+
+.status-dot.running {
+ background: var(--success);
+}
+
+.status-dot.stopped {
+ background: var(--error);
+}
+
+/* Action buttons */
+.action-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.btn {
+ display: inline-block;
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ text-decoration: none;
+ font-weight: 500;
+ text-align: center;
+ transition: all 0.2s ease;
+ border: 1px solid transparent;
+}
+
+.btn.primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn.primary:hover {
+ background: color-mix(in srgb, var(--primary) 90%, black);
+}
+
+.btn.secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: var(--border);
+}
+
+.btn.secondary:hover {
+ background: var(--bg-primary);
+}
+
+/* Footer */
+footer {
+ background: var(--card-bg);
+ border-top: var(--border);
+ padding: 1rem 2rem;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ main {
+ padding: 1rem;
+ }
+
+ .status-grid {
+ grid-template-columns: 1fr;
+ }
+
+ nav {
+ flex-direction: column;
+ text-align: center;
+ gap: 0.5rem;
+ }
+
+ .logo {
+ height: 32px;
+ }
+
+ h1 {
+ font-size: 1.25rem;
+ }
+}
\ No newline at end of file
diff --git a/webroot/static/css/theme.css b/webroot/static/css/theme.css
new file mode 100644
index 0000000..d25ddaa
--- /dev/null
+++ b/webroot/static/css/theme.css
@@ -0,0 +1,171 @@
+:root {
+ /* Colors - easily customizable */
+ --primary: #2563eb;
+ --secondary: #64748b;
+ --success: #10b981;
+ --warning: #f59e0b;
+ --error: #ef4444;
+
+ /* Background colors */
+ --bg-primary: #f8fafc;
+ --bg-secondary: #f1f5f9;
+ --card-bg: #ffffff;
+
+ /* Text colors */
+ --text-primary: #1e293b;
+ --text-secondary: #64748b;
+ --text-muted: #94a3b8;
+
+ /* Shadows and borders */
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
+ --border: 1px solid #e2e8f0;
+}
+
+/* Dark theme variant */
+[data-theme="dark"] {
+ --primary: #3b82f6;
+ --secondary: #94a3b8;
+ --success: #22c55e;
+ --warning: #fbbf24;
+ --error: #f87171;
+
+ --bg-primary: #0f172a;
+ --bg-secondary: #1e293b;
+ --card-bg: #334155;
+
+ --text-primary: #f1f5f9;
+ --text-secondary: #cbd5e1;
+ --text-muted: #94a3b8;
+
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
+ --border: 1px solid #475569;
+}
+
+/* Theme toggle button */
+.theme-toggle {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: var(--card-bg);
+ border: var(--border);
+ border-radius: 6px;
+ padding: 0.5rem;
+ cursor: pointer;
+ box-shadow: var(--shadow);
+ color: var(--text-primary);
+ font-size: 1.25rem;
+}
+
+.theme-toggle:hover {
+ background: var(--bg-secondary);
+}
+
+/* Smooth transitions for theme changes */
+* {
+ transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+}
+
+/* Custom scrollbar for webkit browsers */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--text-muted);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Focus styles for accessibility */
+.btn:focus,
+button:focus,
+input:focus {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+/* Loading animation */
+.loading {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--text-muted);
+ border-radius: 50%;
+ border-top-color: var(--primary);
+ animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Pulse animation for status indicators */
+.status-indicator.loading {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: .5;
+ }
+}
+
+/* Auto-refresh indicator */
+.auto-refresh {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ background: var(--card-bg);
+ border: var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ box-shadow: var(--shadow);
+}
+
+.auto-refresh.active {
+ color: var(--success);
+}
+
+/* Custom properties for component variants */
+.status-card.critical {
+ border-left: 4px solid var(--error);
+}
+
+.status-card.warning {
+ border-left: 4px solid var(--warning);
+}
+
+.status-card.success {
+ border-left: 4px solid var(--success);
+}
+
+/* Hover effects */
+.status-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px 0 rgb(0 0 0 / 0.15);
+}
+
+.minion-item:hover {
+ background: var(--bg-primary);
+}
+
+.port-item:hover {
+ background: var(--bg-primary);
+}
\ No newline at end of file
diff --git a/webroot/static/images/favicon.ico b/webroot/static/images/favicon.ico
new file mode 100644
index 0000000..9a05a79
--- /dev/null
+++ b/webroot/static/images/favicon.ico
@@ -0,0 +1 @@
+data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZmF2aWNvbkdyYWRpZW50IiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj4KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3R5bGU9InN0b3AtY29sb3I6IzI1NjNlYjtzdG9wLW9wYWNpdHk6MSIgLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdHlsZT0ic3RvcC1jb2xvcjojMWQ0ZWQ4O3N0b3Atb3BhY2l0eToxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgCiAgPCEtLSBCYWNrZ3JvdW5kIGNpcmNsZSAtLT4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNCIgZmlsbD0idXJsKCNmYXZpY29uR3JhZGllbnQpIiBzdHJva2U9IiMxZTQwYWYiIHN0cm9rZS13aWR0aD0iMSIvPgogIAogIDwhLS0gTmV0d29yayBub2RlIHJlcHJlc2VudGF0aW9uIC0tPgogIDxjaXJjbGUgY3g9IjE2IiBjeT0iMTYiIHI9IjIiIGZpbGw9IndoaXRlIi8+CiAgPGNpcmNsZSBjeD0iMTAiIGN5PSIxMCIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgPGNpcmNsZSBjeD0iMjIiIGN5PSIxMCIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgPGNpcmNsZSBjeD0iMTAiIGN5PSIyMiIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgPGNpcmNsZSBjeD0iMjIiIGN5PSIyMiIgcj0iMS41IiBmaWxsPSJ3aGl0ZSIgb3BhY2l0eT0iMC44Ii8+CiAgCiAgPCEtLSBDb25uZWN0aW9uIGxpbmVzIC0tPgogIDxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMTAiIHkyPSIxMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxIiBvcGFjaXR5PSIwLjYiLz4KICA8bGluZSB4MT0iMTYiIHkxPSIxNiIgeDI9IjIyIiB5Mj0iMTAiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMSIgb3BhY2l0eT0iMC42Ii8+CiAgPGxpbmUgeDE9IjE2IiB5MT0iMTYiIHgyPSIxMCIgeTI9IjIyIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEiIG9wYWNpdHk9IjAuNiIvPgogIDxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjIiIHkyPSIyMiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxIiBvcGFjaXR5PSIwLjYiLz4KPC9zdmc+
\ No newline at end of file
diff --git a/webroot/static/images/logo.svg b/webroot/static/images/logo.svg
new file mode 100644
index 0000000..3c658df
--- /dev/null
+++ b/webroot/static/images/logo.svg
@@ -0,0 +1,29 @@
+
+
\ No newline at end of file
diff --git a/webroot/static/js/api.js b/webroot/static/js/api.js
new file mode 100644
index 0000000..ab5d5c5
--- /dev/null
+++ b/webroot/static/js/api.js
@@ -0,0 +1,183 @@
+class MinexusAPI {
+ constructor(baseURL = '') {
+ this.baseURL = baseURL;
+ }
+
+ async getStatus() {
+ try {
+ const response = await fetch(`${this.baseURL}/api/status`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to fetch status:', error);
+ throw error;
+ }
+ }
+
+ async getMinions() {
+ try {
+ const response = await fetch(`${this.baseURL}/api/minions`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to fetch minions:', error);
+ throw error;
+ }
+ }
+
+ async getHealth() {
+ try {
+ const response = await fetch(`${this.baseURL}/api/health`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return await response.json();
+ } catch (error) {
+ console.error('Failed to fetch health:', error);
+ throw error;
+ }
+ }
+}
+
+// Create global API instance
+const api = new MinexusAPI();
+
+// Utility functions for formatting
+function formatUptime(uptimeSeconds) {
+ const days = Math.floor(uptimeSeconds / 86400);
+ const hours = Math.floor((uptimeSeconds % 86400) / 3600);
+ const minutes = Math.floor((uptimeSeconds % 3600) / 60);
+ const seconds = uptimeSeconds % 60;
+
+ let result = '';
+ if (days > 0) result += `${days}d `;
+ if (hours > 0) result += `${hours}h `;
+ if (minutes > 0 || result === '') result += `${minutes}m `;
+ if (seconds > 0 && days === 0) result += `${seconds}s`;
+
+ return result.trim();
+}
+
+function formatTimestamp(timestamp) {
+ return new Date(timestamp).toLocaleString();
+}
+
+function getStatusClass(status) {
+ switch (status?.toLowerCase()) {
+ case 'healthy':
+ case 'running':
+ case 'active':
+ return 'success';
+ case 'warning':
+ return 'warning';
+ case 'error':
+ case 'unhealthy':
+ case 'stopped':
+ case 'inactive':
+ return 'error';
+ default:
+ return 'secondary';
+ }
+}
+
+// Theme management
+function getTheme() {
+ return localStorage.getItem('theme') || 'light';
+}
+
+function setTheme(theme) {
+ localStorage.setItem('theme', theme);
+ document.documentElement.setAttribute('data-theme', theme);
+}
+
+function toggleTheme() {
+ const currentTheme = getTheme();
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
+ setTheme(newTheme);
+}
+
+// Initialize theme on load
+document.addEventListener('DOMContentLoaded', function () {
+ setTheme(getTheme());
+});
+
+// Error handling utilities
+function showError(message, container = document.body) {
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'error-message';
+ errorDiv.style.cssText = `
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: var(--error);
+ color: white;
+ padding: 1rem;
+ border-radius: 6px;
+ box-shadow: var(--shadow);
+ z-index: 1000;
+ max-width: 300px;
+ `;
+ errorDiv.textContent = message;
+
+ container.appendChild(errorDiv);
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => {
+ if (errorDiv.parentNode) {
+ errorDiv.parentNode.removeChild(errorDiv);
+ }
+ }, 5000);
+
+ // Click to dismiss
+ errorDiv.addEventListener('click', () => {
+ if (errorDiv.parentNode) {
+ errorDiv.parentNode.removeChild(errorDiv);
+ }
+ });
+}
+
+function showSuccess(message, container = document.body) {
+ const successDiv = document.createElement('div');
+ successDiv.className = 'success-message';
+ successDiv.style.cssText = `
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: var(--success);
+ color: white;
+ padding: 1rem;
+ border-radius: 6px;
+ box-shadow: var(--shadow);
+ z-index: 1000;
+ max-width: 300px;
+ `;
+ successDiv.textContent = message;
+
+ container.appendChild(successDiv);
+
+ // Auto-remove after 3 seconds
+ setTimeout(() => {
+ if (successDiv.parentNode) {
+ successDiv.parentNode.removeChild(successDiv);
+ }
+ }, 3000);
+}
+
+// Loading indicator utilities
+function showLoading(element) {
+ if (element) {
+ element.classList.add('loading');
+ element.innerHTML = '';
+ }
+}
+
+function hideLoading(element, originalContent) {
+ if (element) {
+ element.classList.remove('loading');
+ element.innerHTML = originalContent || '';
+ }
+}
\ No newline at end of file
diff --git a/webroot/static/js/dashboard.js b/webroot/static/js/dashboard.js
new file mode 100644
index 0000000..0569459
--- /dev/null
+++ b/webroot/static/js/dashboard.js
@@ -0,0 +1,320 @@
+document.addEventListener('DOMContentLoaded', function () {
+ let refreshInterval;
+ let isAutoRefreshEnabled = true;
+
+ // Initialize dashboard
+ initializeDashboard();
+ setupEventListeners();
+ startAutoRefresh();
+
+ function initializeDashboard() {
+ // Add theme toggle button if not exists
+ addThemeToggle();
+
+ // Add auto-refresh indicator
+ addAutoRefreshIndicator();
+
+ // Initial data load
+ refreshDashboard();
+ }
+
+ function setupEventListeners() {
+ // Theme toggle
+ const themeToggle = document.getElementById('theme-toggle');
+ if (themeToggle) {
+ themeToggle.addEventListener('click', toggleTheme);
+ }
+
+ // Auto-refresh toggle
+ const autoRefreshToggle = document.getElementById('auto-refresh-toggle');
+ if (autoRefreshToggle) {
+ autoRefreshToggle.addEventListener('click', toggleAutoRefresh);
+ }
+
+ // Manual refresh button
+ const refreshButton = document.getElementById('refresh-button');
+ if (refreshButton) {
+ refreshButton.addEventListener('click', () => {
+ refreshDashboard();
+ showSuccess('Dashboard refreshed');
+ });
+ }
+
+ // Handle visibility change (pause refresh when tab not visible)
+ document.addEventListener('visibilitychange', function () {
+ if (document.hidden) {
+ stopAutoRefresh();
+ } else if (isAutoRefreshEnabled) {
+ startAutoRefresh();
+ }
+ });
+ }
+
+ function addThemeToggle() {
+ if (document.getElementById('theme-toggle')) return;
+
+ const themeToggle = document.createElement('button');
+ themeToggle.id = 'theme-toggle';
+ themeToggle.className = 'theme-toggle';
+ themeToggle.innerHTML = '🌙';
+ themeToggle.title = 'Toggle dark mode';
+ document.body.appendChild(themeToggle);
+
+ // Update icon based on current theme
+ updateThemeToggleIcon();
+ }
+
+ function updateThemeToggleIcon() {
+ const themeToggle = document.getElementById('theme-toggle');
+ if (themeToggle) {
+ const isDark = getTheme() === 'dark';
+ themeToggle.innerHTML = isDark ? '☀️' : '🌙';
+ themeToggle.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
+ }
+ }
+
+ function addAutoRefreshIndicator() {
+ if (document.getElementById('auto-refresh')) return;
+
+ const indicator = document.createElement('div');
+ indicator.id = 'auto-refresh';
+ indicator.className = 'auto-refresh';
+ indicator.innerHTML = `
+
+ 🔄 Auto-refresh: ON
+
+
+ `;
+ document.body.appendChild(indicator);
+ }
+
+ function startAutoRefresh() {
+ stopAutoRefresh(); // Clear any existing interval
+
+ let countdown = 30; // 30 seconds
+ updateRefreshCountdown(countdown);
+
+ refreshInterval = setInterval(() => {
+ countdown--;
+ updateRefreshCountdown(countdown);
+
+ if (countdown <= 0) {
+ refreshDashboard();
+ countdown = 30; // Reset countdown
+ }
+ }, 1000);
+
+ updateAutoRefreshStatus(true);
+ }
+
+ function stopAutoRefresh() {
+ if (refreshInterval) {
+ clearInterval(refreshInterval);
+ refreshInterval = null;
+ }
+ updateRefreshCountdown(0);
+ }
+
+ function toggleAutoRefresh() {
+ isAutoRefreshEnabled = !isAutoRefreshEnabled;
+
+ if (isAutoRefreshEnabled) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+
+ updateAutoRefreshStatus(isAutoRefreshEnabled);
+ }
+
+ function updateAutoRefreshStatus(enabled) {
+ const status = document.getElementById('refresh-status');
+ const indicator = document.getElementById('auto-refresh');
+
+ if (status) {
+ status.textContent = enabled ? 'ON' : 'OFF';
+ }
+
+ if (indicator) {
+ indicator.className = enabled ? 'auto-refresh active' : 'auto-refresh';
+ }
+ }
+
+ function updateRefreshCountdown(seconds) {
+ const countdown = document.getElementById('refresh-countdown');
+ if (countdown) {
+ if (seconds > 0) {
+ countdown.textContent = `(${seconds}s)`;
+ } else {
+ countdown.textContent = '';
+ }
+ }
+ }
+
+ async function refreshDashboard() {
+ try {
+ // Show loading state
+ showLoadingState();
+
+ // Fetch all data concurrently
+ const [statusData, minionsData] = await Promise.all([
+ api.getStatus(),
+ api.getMinions()
+ ]);
+
+ // Update dashboard sections
+ updateSystemStatus(statusData);
+ updateMinionStatus(minionsData);
+ updateServerPorts(statusData);
+
+ // Hide loading state
+ hideLoadingState();
+
+ } catch (error) {
+ console.error('Failed to refresh dashboard:', error);
+ showError('Failed to refresh dashboard data');
+ hideLoadingState();
+ }
+ }
+
+ function showLoadingState() {
+ const statusCards = document.querySelectorAll('.status-card');
+ statusCards.forEach(card => {
+ const indicator = card.querySelector('.status-indicator');
+ if (indicator) {
+ indicator.classList.add('loading');
+ }
+ });
+ }
+
+ function hideLoadingState() {
+ const statusCards = document.querySelectorAll('.status-card');
+ statusCards.forEach(card => {
+ const indicator = card.querySelector('.status-indicator');
+ if (indicator) {
+ indicator.classList.remove('loading');
+ }
+ });
+ }
+
+ function updateSystemStatus(data) {
+ const versionElement = document.querySelector('.status-card p:nth-of-type(1)');
+ const uptimeElement = document.querySelector('.status-card p:nth-of-type(2)');
+ const statusIndicator = document.querySelector('.status-indicator');
+
+ if (versionElement) {
+ versionElement.textContent = `Version: ${data.version || 'Unknown'}`;
+ }
+
+ if (uptimeElement) {
+ uptimeElement.textContent = `Uptime: ${data.uptime || 'Unknown'}`;
+ }
+
+ if (statusIndicator) {
+ const isHealthy = data.servers &&
+ data.servers.minion?.status === 'running' &&
+ data.servers.console?.status === 'running';
+ const status = isHealthy ? 'healthy' : 'warning';
+
+ statusIndicator.className = `status-indicator ${status}`;
+ statusIndicator.textContent = status.toUpperCase();
+ }
+ }
+
+ function updateMinionStatus(data) {
+ const countElement = document.querySelector('.minion-count');
+ const listElement = document.querySelector('.minion-list');
+
+ if (countElement) {
+ countElement.textContent = data.count || 0;
+ }
+
+ if (listElement) {
+ listElement.innerHTML = '';
+
+ if (data.minions && data.minions.length > 0) {
+ data.minions.forEach(minion => {
+ const minionItem = document.createElement('div');
+ minionItem.className = 'minion-item';
+ minionItem.innerHTML = `
+ ${minion.id}
+ ${minion.status}
+ `;
+ listElement.appendChild(minionItem);
+ });
+ } else {
+ const noMinions = document.createElement('div');
+ noMinions.className = 'minion-item';
+ noMinions.innerHTML = 'No minions connected';
+ listElement.appendChild(noMinions);
+ }
+ }
+ }
+
+ function updateServerPorts(data) {
+ const portList = document.querySelector('.port-list');
+ if (!portList || !data.servers) return;
+
+ const ports = [
+ { name: 'Minion (gRPC)', port: data.servers.minion?.port, status: data.servers.minion?.status },
+ { name: 'Console (mTLS)', port: data.servers.console?.port, status: data.servers.console?.status },
+ { name: 'Web (HTTP)', port: data.servers.web?.port, status: data.servers.web?.status }
+ ];
+
+ portList.innerHTML = '';
+
+ ports.forEach(portInfo => {
+ const portItem = document.createElement('div');
+ portItem.className = 'port-item';
+ portItem.innerHTML = `
+ ${portInfo.name}: ${portInfo.port || 'Unknown'}
+
+ `;
+ portList.appendChild(portItem);
+ });
+ }
+
+ // Override theme toggle to update icon
+ const originalToggleTheme = window.toggleTheme;
+ window.toggleTheme = function () {
+ if (originalToggleTheme) {
+ originalToggleTheme();
+ }
+ updateThemeToggleIcon();
+ };
+
+ // Add keyboard shortcuts
+ document.addEventListener('keydown', function (e) {
+ // Ctrl/Cmd + R for refresh
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
+ e.preventDefault();
+ refreshDashboard();
+ showSuccess('Dashboard refreshed');
+ }
+
+ // Ctrl/Cmd + D for dark mode toggle
+ if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
+ e.preventDefault();
+ toggleTheme();
+ }
+
+ // Space to toggle auto-refresh
+ if (e.code === 'Space' && e.target === document.body) {
+ e.preventDefault();
+ toggleAutoRefresh();
+ }
+ });
+
+ // Handle connection errors gracefully
+ window.addEventListener('online', function () {
+ showSuccess('Connection restored');
+ if (isAutoRefreshEnabled) {
+ startAutoRefresh();
+ }
+ });
+
+ window.addEventListener('offline', function () {
+ showError('Connection lost');
+ stopAutoRefresh();
+ });
+});
\ No newline at end of file
diff --git a/webroot/templates/base.html b/webroot/templates/base.html
new file mode 100644
index 0000000..8690a7b
--- /dev/null
+++ b/webroot/templates/base.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ {{.Title}} - Minexus
+
+
+
+
+
+
+
+
+
+
+ {{template "content" .}}
+
+
+
+ {{template "scripts" .}}
+
+
+
\ No newline at end of file
diff --git a/webroot/templates/dashboard.html b/webroot/templates/dashboard.html
new file mode 100644
index 0000000..5ea5cad
--- /dev/null
+++ b/webroot/templates/dashboard.html
@@ -0,0 +1,56 @@
+{{define "content"}}
+
+
+
+
System Status
+
{{.SystemStatus}}
+
Version: {{.Version}}
+
Uptime: {{.Uptime}}
+
+
+
+
Connected Minions
+
{{.MinionCount}}
+
+ {{range .Minions}}
+
+ {{.ID}}
+ {{.Status}}
+
+ {{end}}
+
+
+
+
+
Server Ports
+
+
+ Minion (gRPC): {{.MinionPort}}
+
+
+
+ Console (mTLS): {{.ConsolePort}}
+
+
+
+ Web (HTTP): {{.WebPort}}
+
+
+
+
+
+
+
+
+{{end}}
+
+{{define "scripts"}}
+
+{{end}}
\ No newline at end of file