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

+
+

Minion Binaries

+ Linux x64 + Linux ARM64 + Windows x64 + Windows ARM64 + macOS x64 + macOS ARM64 +
+
+

Console Binaries

+ Linux x64 + Linux ARM64 + Windows x64 + Windows ARM64 + macOS x64 + macOS ARM64 +
+

← 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + MINEXUS + Network Control + \ 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}} + +
+
+
+ +
+

Quick Actions

+ +
+
+
+{{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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + MINEXUS + Network Control + \ 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}} + +
+
+
+ +
+

Quick Actions

+ +
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} \ No newline at end of file