From 608a4a6e1faeff4ef48e02baba25e5f45ce49cc7 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 20 Aug 2025 20:05:37 +0300 Subject: [PATCH 01/12] fix: replace syscall.Stat_t with cross-platform file ownership check - Remove platform-specific syscall import that fails on Linux CI/CD - Replace syscall.Stat_t usage with portable 'ls -la' command parsing - Maintain same functionality for detecting root-owned PID files - Ensures compatibility across macOS, Linux, and CI/CD environments Resolves build failures in CI/CD pipelines while preserving the nginx permission fix functionality for first-run installations. --- internal/installer/nginx.go | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/internal/installer/nginx.go b/internal/installer/nginx.go index 556511b..21ad481 100644 --- a/internal/installer/nginx.go +++ b/internal/installer/nginx.go @@ -8,7 +8,6 @@ import ( "path/filepath" "runtime" "strings" - "syscall" "time" ) @@ -356,14 +355,15 @@ func (ni *NginxInstaller) createNginxDirectories() error { } // Remove existing PID file if it exists and is owned by root - if stat, err := os.Stat(pidFile); err == nil { - // Check if file is owned by root (UID 0) - if sys := stat.Sys(); sys != nil { - if stat, ok := sys.(*syscall.Stat_t); ok && stat.Uid == 0 { - cmd := exec.Command("sudo", "rm", "-f", pidFile) - if err := cmd.Run(); err == nil { - fmt.Printf("✅ Removed root-owned PID file %s\n", pidFile) - } + if _, err := os.Stat(pidFile); err == nil { + // Check if file is owned by root using ls command + cmd := exec.Command("ls", "-la", pidFile) + output, err := cmd.Output() + if err == nil && strings.Contains(string(output), " root ") { + // File is owned by root, remove it + cmd := exec.Command("sudo", "rm", "-f", pidFile) + if err := cmd.Run(); err == nil { + fmt.Printf("✅ Removed root-owned PID file %s\n", pidFile) } } } @@ -700,13 +700,15 @@ func (ni *NginxInstaller) startNginxService() error { pidFile = "/usr/local/var/run/nginx.pid" } - if stat, err := os.Stat(pidFile); err == nil { - if sys := stat.Sys(); sys != nil { - if stat, ok := sys.(*syscall.Stat_t); ok && stat.Uid == 0 { - cmd := exec.Command("sudo", "rm", "-f", pidFile) - if err := cmd.Run(); err == nil { - fmt.Printf("✅ Cleaned root-owned PID file before start\n") - } + if _, err := os.Stat(pidFile); err == nil { + // Check if file is owned by root using ls command + cmd := exec.Command("ls", "-la", pidFile) + output, err := cmd.Output() + if err == nil && strings.Contains(string(output), " root ") { + // File is owned by root, remove it + cmd := exec.Command("sudo", "rm", "-f", pidFile) + if err := cmd.Run(); err == nil { + fmt.Printf("✅ Cleaned root-owned PID file before start\n") } } } From e228169730834b16dc58b2d05f7ecc4bbdc97a26 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Mon, 15 Sep 2025 17:46:26 +0300 Subject: [PATCH 02/12] fix: add it to manager --- internal/services/manager.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/services/manager.go b/internal/services/manager.go index 6865011..5b3edf7 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -25,6 +25,7 @@ type Manager struct { clients map[*websocket.Conn]bool clientsMutex sync.RWMutex dependencyManager *DependencyManager + Id int64 } type WebSocketMessage struct { From 4767d3e718428ca05c3c7a9b68e7bcdc7dc11cd9 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 20:33:00 +0300 Subject: [PATCH 03/12] fix: add JAVA_HOME validation and user-friendly error display for wrapper validation - Add JAVA_HOME environment variable check in wrapper validation functions - Prevent API hanging by failing fast when JAVA_HOME is not set - Create JavaHomeErrorDisplay component with detailed setup instructions - Add CopyButton component for easy copying of shell configuration commands - Provide platform-specific Java detection commands (macOS, Linux) - Include examples and step-by-step instructions for bash, zsh, and fish shells - Replace generic error display with comprehensive JAVA_HOME troubleshooting UI --- .../JavaHomeErrorDisplay.tsx | 153 ++++++++++++++++++ .../WrapperManagementModal.tsx | 9 +- web/src/components/ui/copy-button.tsx | 47 ++++++ 3 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx create mode 100644 web/src/components/ui/copy-button.tsx diff --git a/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx b/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx new file mode 100644 index 0000000..8f72d4e --- /dev/null +++ b/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { AlertTriangle, Coffee, Terminal } from 'lucide-react'; +import { CopyButton } from '@/components/ui/copy-button'; + +interface JavaHomeErrorDisplayProps { + error: string; +} + +export const JavaHomeErrorDisplay: React.FC = ({ error }) => { + // Check if this is a JAVA_HOME related error + const isJavaHomeError = error.includes('JAVA_HOME environment variable is not set'); + + if (!isJavaHomeError) { + return ( +
+
+ + Error +
+

{error}

+
+ ); + } + + // Parse the error message for different shell configurations + const shellConfigs = [ + { + shell: 'Bash', + file: '~/.bashrc', + command: 'export JAVA_HOME=/path/to/java', + reload: 'source ~/.bashrc' + }, + { + shell: 'Zsh', + file: '~/.zshrc', + command: 'export JAVA_HOME=/path/to/java', + reload: 'source ~/.zshrc' + }, + { + shell: 'Fish', + file: '~/.config/fish/config.fish', + command: 'set -x JAVA_HOME /path/to/java', + reload: 'source ~/.config/fish/config.fish' + } + ]; + + const findJavaCommands = [ + { + os: 'macOS', + command: '/usr/libexec/java_home' + }, + { + os: 'Linux', + command: 'which java' + }, + { + os: 'Linux (alternative)', + command: 'whereis java' + } + ]; + + return ( +
+
+ + JAVA_HOME Not Set +
+ +

+ The JAVA_HOME environment variable is required for wrapper validation but is not currently set. +

+ +
+
+

+ + 1. Find your Java installation +

+
+ {findJavaCommands.map((cmd, index) => ( +
+
+ {cmd.os}: + {cmd.command} +
+ +
+ ))} +
+
+ +
+

+ 2. Set JAVA_HOME in your shell configuration +

+
+ {shellConfigs.map((config, index) => ( +
+
+ + {config.shell} ({config.file}) + +
+
+
+ + {config.command} + + +
+
+ + {config.reload} + + +
+
+
+ ))} +
+
+ +
+

+ 3. Restart your terminal or reload configuration +

+

+ After setting JAVA_HOME, restart your terminal or run the appropriate source command above. + Then restart Vertex to pick up the new environment variable. +

+
+ +
+

💡 Example

+

+ If Java is installed at /usr/lib/jvm/java-17-openjdk: +

+
+ + export JAVA_HOME=/usr/lib/jvm/java-17-openjdk + + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/WrapperManagementModal/WrapperManagementModal.tsx b/web/src/components/WrapperManagementModal/WrapperManagementModal.tsx index 51408a5..af7928d 100644 --- a/web/src/components/WrapperManagementModal/WrapperManagementModal.tsx +++ b/web/src/components/WrapperManagementModal/WrapperManagementModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { X, Wrench, Package, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { JavaHomeErrorDisplay } from './JavaHomeErrorDisplay'; interface WrapperValidation { serviceId: string; @@ -180,13 +181,7 @@ const WrapperManagementModal: React.FC = ({ Validating wrapper... ) : error ? ( -
-
- - Error -
-

{error}

-
+ ) : ( <> {renderWrapperStatus()} diff --git a/web/src/components/ui/copy-button.tsx b/web/src/components/ui/copy-button.tsx new file mode 100644 index 0000000..f587751 --- /dev/null +++ b/web/src/components/ui/copy-button.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { Button } from './button'; + +interface CopyButtonProps { + text: string; + className?: string; + size?: 'sm' | 'default' | 'lg'; + variant?: 'outline' | 'ghost' | 'default'; + label?: string; +} + +export const CopyButton: React.FC = ({ + text, + className = '', + size = 'sm', + variant = 'outline', + label = 'Copy' +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text:', err); + } + }; + + return ( + + ); +}; \ No newline at end of file From f423d52b5dfdb3d6eeddcafc2879eb979891b500 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 20:39:12 +0300 Subject: [PATCH 04/12] improve: enhance JAVA_HOME setup instructions with proper macOS and Linux commands - Add platform-specific JAVA_HOME configuration for macOS and Linux - Include proper PATH setup alongside JAVA_HOME export - Provide macOS-specific commands using /usr/libexec/java_home utility - Add support for specific Java version selection (e.g., Java 21) - Include Linux commands using readlink and alternatives - Organize instructions by platform with visual indicators - Add comprehensive examples for both general and specific Java versions - Each command now properly sets both JAVA_HOME and updates PATH - All commands are individually copyable with dedicated copy buttons --- .../JavaHomeErrorDisplay.tsx | 235 ++++++++++++++---- 1 file changed, 188 insertions(+), 47 deletions(-) diff --git a/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx b/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx index 8f72d4e..9756c85 100644 --- a/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx +++ b/web/src/components/WrapperManagementModal/JavaHomeErrorDisplay.tsx @@ -22,24 +22,63 @@ export const JavaHomeErrorDisplay: React.FC = ({ erro ); } - // Parse the error message for different shell configurations - const shellConfigs = [ + // Platform-specific configurations + const macOSConfigs = [ { shell: 'Bash', file: '~/.bashrc', - command: 'export JAVA_HOME=/path/to/java', + commands: [ + 'export JAVA_HOME=$(/usr/libexec/java_home)', + 'export PATH=$JAVA_HOME/bin:$PATH' + ], reload: 'source ~/.bashrc' }, { shell: 'Zsh', file: '~/.zshrc', - command: 'export JAVA_HOME=/path/to/java', + commands: [ + 'export JAVA_HOME=$(/usr/libexec/java_home)', + 'export PATH=$JAVA_HOME/bin:$PATH' + ], reload: 'source ~/.zshrc' }, { shell: 'Fish', file: '~/.config/fish/config.fish', - command: 'set -x JAVA_HOME /path/to/java', + commands: [ + 'set -x JAVA_HOME (/usr/libexec/java_home)', + 'set -x PATH $JAVA_HOME/bin $PATH' + ], + reload: 'source ~/.config/fish/config.fish' + } + ]; + + const linuxConfigs = [ + { + shell: 'Bash', + file: '~/.bashrc', + commands: [ + 'export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))', + 'export PATH=$JAVA_HOME/bin:$PATH' + ], + reload: 'source ~/.bashrc' + }, + { + shell: 'Zsh', + file: '~/.zshrc', + commands: [ + 'export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))', + 'export PATH=$JAVA_HOME/bin:$PATH' + ], + reload: 'source ~/.zshrc' + }, + { + shell: 'Fish', + file: '~/.config/fish/config.fish', + commands: [ + 'set -x JAVA_HOME (dirname (dirname (readlink -f (which java))))', + 'set -x PATH $JAVA_HOME/bin $PATH' + ], reload: 'source ~/.config/fish/config.fish' } ]; @@ -47,15 +86,23 @@ export const JavaHomeErrorDisplay: React.FC = ({ erro const findJavaCommands = [ { os: 'macOS', + description: 'Find Java installation', command: '/usr/libexec/java_home' }, + { + os: 'macOS', + description: 'Find specific Java version (e.g., Java 21)', + command: '/usr/libexec/java_home -v 21' + }, { os: 'Linux', - command: 'which java' + description: 'Find Java installation', + command: 'dirname $(dirname $(readlink -f $(which java)))' }, { - os: 'Linux (alternative)', - command: 'whereis java' + os: 'Linux', + description: 'Alternative: List Java installations', + command: 'sudo update-alternatives --list java' } ]; @@ -79,9 +126,12 @@ export const JavaHomeErrorDisplay: React.FC = ({ erro
{findJavaCommands.map((cmd, index) => (
-
- {cmd.os}: - {cmd.command} +
+
+ {cmd.os}: + {cmd.description} +
+ {cmd.command}
@@ -90,33 +140,76 @@ export const JavaHomeErrorDisplay: React.FC = ({ erro
-

- 2. Set JAVA_HOME in your shell configuration +

+ 2. Add to your shell configuration

-
- {shellConfigs.map((config, index) => ( -
-
- - {config.shell} ({config.file}) - + + {/* macOS Section */} +
+
+ 🍎 macOS (recommended) +
+
+ {macOSConfigs.map((config, index) => ( +
+
+ + {config.shell} ({config.file}) + +
+
+ {config.commands.map((command, cmdIndex) => ( +
+ + {command} + + +
+ ))} +
+ + {config.reload} + + +
+
-
-
- - {config.command} - - + ))} +
+
+ + {/* Linux Section */} +
+
+ 🐧 Linux +
+
+ {linuxConfigs.map((config, index) => ( +
+
+ + {config.shell} ({config.file}) +
-
- - {config.reload} - - +
+ {config.commands.map((command, cmdIndex) => ( +
+ + {command} + + +
+ ))} +
+ + {config.reload} + + +
-
- ))} + ))} +
@@ -131,20 +224,68 @@ export const JavaHomeErrorDisplay: React.FC = ({ erro
-

💡 Example

-

- If Java is installed at /usr/lib/jvm/java-17-openjdk: -

-
- - export JAVA_HOME=/usr/lib/jvm/java-17-openjdk - - +

💡 Examples

+ +
+
+

+ macOS with latest Java: +

+
+
+ + export JAVA_HOME=$(/usr/libexec/java_home) + + +
+
+ + export PATH=$JAVA_HOME/bin:$PATH + + +
+
+
+ +
+

+ macOS with specific Java version (e.g., Java 21): +

+
+
+ + export JAVA_HOME=$(/usr/libexec/java_home -v 21) + + +
+
+ + export PATH=$JAVA_HOME/bin:$PATH + + +
+
+
From f9ae6ba8cce607d0955a92437289e01d714ebe96 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 20:41:22 +0300 Subject: [PATCH 05/12] feat: return proper error when java_home is not set --- internal/services/buildsystem.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/services/buildsystem.go b/internal/services/buildsystem.go index 4cf18f2..731534e 100644 --- a/internal/services/buildsystem.go +++ b/internal/services/buildsystem.go @@ -275,6 +275,12 @@ func ValidateWrapperIntegrity(serviceDir string, buildSystem BuildSystemType) (b // validateMavenWrapperIntegrity checks Maven wrapper files func validateMavenWrapperIntegrity(serviceDir string) (bool, error) { + // Check if JAVA_HOME is set + javaHome := os.Getenv("JAVA_HOME") + if javaHome == "" { + return false, fmt.Errorf("JAVA_HOME environment variable is not set. Please set JAVA_HOME to fix wrapper validation.\n\nTo set JAVA_HOME:\n• For bash (~/.bashrc): export JAVA_HOME=/path/to/java\n• For zsh (~/.zshrc): export JAVA_HOME=/path/to/java\n• For fish (~/.config/fish/config.fish): set -x JAVA_HOME /path/to/java\n• Then restart your terminal or run: source ~/.bashrc (or ~/.zshrc)\n\nTo find Java location:\n• macOS: /usr/libexec/java_home\n• Linux: which java or whereis java") + } + requiredFiles := []string{"mvnw", ".mvn/wrapper/maven-wrapper.properties"} for _, file := range requiredFiles { @@ -308,6 +314,12 @@ func validateMavenWrapperIntegrity(serviceDir string) (bool, error) { // validateGradleWrapperIntegrity checks Gradle wrapper files func validateGradleWrapperIntegrity(serviceDir string) (bool, error) { + // Check if JAVA_HOME is set + javaHome := os.Getenv("JAVA_HOME") + if javaHome == "" { + return false, fmt.Errorf("JAVA_HOME environment variable is not set. Please set JAVA_HOME to fix wrapper validation.\n\nTo set JAVA_HOME:\n• For bash (~/.bashrc): export JAVA_HOME=/path/to/java\n• For zsh (~/.zshrc): export JAVA_HOME=/path/to/java\n• For fish (~/.config/fish/config.fish): set -x JAVA_HOME /path/to/java\n• Then restart your terminal or run: source ~/.bashrc (or ~/.zshrc)\n\nTo find Java location:\n• macOS: /usr/libexec/java_home\n• Linux: which java or whereis java") + } + requiredFiles := []string{"gradlew", "gradle/wrapper/gradle-wrapper.properties"} for _, file := range requiredFiles { From 81a2bbe1f0f48b1537d27bf837a2af28ee52d835 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 20:48:23 +0300 Subject: [PATCH 06/12] fix: add scrolling to sidebar navigation when menu items overflow - Convert sidebar to flexbox layout with flex-col direction - Make navigation area scrollable with overflow-y-auto and flex-1 - Keep footer fixed at bottom with flex-shrink-0 - Maintain existing responsive behavior and animations - Prevents menu items from being hidden when sidebar content exceeds viewport height - Ensures all 12 navigation items remain accessible regardless of screen height --- web/src/components/Sidebar/Sidebar.tsx | 84 +++++++++++++------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/web/src/components/Sidebar/Sidebar.tsx b/web/src/components/Sidebar/Sidebar.tsx index d72c9f3..3022a52 100644 --- a/web/src/components/Sidebar/Sidebar.tsx +++ b/web/src/components/Sidebar/Sidebar.tsx @@ -113,55 +113,57 @@ export function Sidebar({ {/* Sidebar */}
- {/* Navigation */} -
- {!isCollapsed && ( -
-
{item.label}
- {item.description && ( -
- {item.description} -
- )} +
+ {item.icon}
- )} - {!isCollapsed && activeSection === item.id && ( -
- )} - - ))} - + {!isCollapsed && ( +
+
{item.label}
+ {item.description && ( +
+ {item.description} +
+ )} +
+ )} + {!isCollapsed && activeSection === item.id && ( +
+ )} + + ))} + +
- {/* Footer */} + {/* Footer - Fixed at bottom */} {!isCollapsed && ( -
+
Vertex Service Manager
v2.0.0
From 4b1d1a4cd49314432bf622bf5190909daf8a6635 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 21:05:03 +0300 Subject: [PATCH 07/12] fix: implement profile-scoped service name validation instead of global uniqueness - Remove global UNIQUE constraint on service names in database schema - Add database migration to handle existing installations safely - Implement ValidateServiceNameUniquenessInProfile function for profile-scoped validation - Update RenameService to use profile-scoped validation instead of global check - Add name conflict validation in AddServiceToProfile to prevent duplicates within profiles - Service names are now unique within each profile, allowing different profiles to have services with same names - Maintains backward compatibility with automatic migration for existing databases This resolves the issue where different profiles couldn't have services with the same logical names like "frontend", "backend", "database" etc. --- internal/database/database.go | 98 ++++++++++++++++++++++++++++++++++- internal/services/manager.go | 56 ++++++++++++++++++-- internal/services/profile.go | 14 ++++- 3 files changed, 161 insertions(+), 7 deletions(-) diff --git a/internal/database/database.go b/internal/database/database.go index cbe717c..cb80838 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,9 +3,11 @@ package database import ( "database/sql" "fmt" + "log" "os" "path/filepath" "runtime" + "strings" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" @@ -108,7 +110,7 @@ func (db *Database) initTables() error { createServicesTable := ` CREATE TABLE IF NOT EXISTS services ( id TEXT PRIMARY KEY, -- UUID - name TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, dir TEXT NOT NULL, extra_env TEXT, java_opts TEXT, @@ -342,6 +344,11 @@ func (db *Database) initTables() error { return fmt.Errorf("failed to migrate services to UUID: %w", err) } + // Remove global UNIQUE constraint on service names to allow profile-scoped uniqueness + if err := db.migrateRemoveServiceNameUniqueConstraint(); err != nil { + return fmt.Errorf("failed to migrate service name constraint: %w", err) + } + return nil } @@ -381,6 +388,95 @@ func (db *Database) migrateServicesToUUID() error { return rows.Err() } +// migrateRemoveServiceNameUniqueConstraint removes the global UNIQUE constraint on service names +// to allow profile-scoped uniqueness instead of global uniqueness +func (db *Database) migrateRemoveServiceNameUniqueConstraint() error { + // First, check if the services table has the UNIQUE constraint on name + var hasUniqueConstraint bool + + // Query the table schema to check for UNIQUE constraint + var sql string + err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='services'").Scan(&sql) + if err != nil { + return fmt.Errorf("failed to get services table schema: %w", err) + } + + // Check if the schema contains "name TEXT UNIQUE" + hasUniqueConstraint = strings.Contains(sql, "name TEXT UNIQUE") + + if !hasUniqueConstraint { + // Migration already applied or not needed + return nil + } + + log.Println("[INFO] Migrating services table to remove global UNIQUE constraint on name") + + // Begin transaction + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Create new services table without UNIQUE constraint + createNewServicesTable := ` + CREATE TABLE services_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + dir TEXT NOT NULL, + extra_env TEXT, + java_opts TEXT, + status TEXT DEFAULT 'stopped', + health_status TEXT DEFAULT 'unknown', + health_url TEXT, + port INTEGER, + pid INTEGER DEFAULT 0, + service_order INTEGER, + last_started DATETIME, + description TEXT, + is_enabled BOOLEAN DEFAULT TRUE, + build_system TEXT DEFAULT 'auto', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + );` + + if _, err := tx.Exec(createNewServicesTable); err != nil { + return fmt.Errorf("failed to create new services table: %w", err) + } + + // Copy data from old table to new table + copyData := ` + INSERT INTO services_new (id, name, dir, extra_env, java_opts, status, health_status, + health_url, port, pid, service_order, last_started, description, is_enabled, + build_system, created_at, updated_at) + SELECT id, name, dir, extra_env, java_opts, status, health_status, + health_url, port, pid, service_order, last_started, description, is_enabled, + build_system, created_at, updated_at + FROM services;` + + if _, err := tx.Exec(copyData); err != nil { + return fmt.Errorf("failed to copy services data: %w", err) + } + + // Drop the old table + if _, err := tx.Exec("DROP TABLE services"); err != nil { + return fmt.Errorf("failed to drop old services table: %w", err) + } + + // Rename new table to original name + if _, err := tx.Exec("ALTER TABLE services_new RENAME TO services"); err != nil { + return fmt.Errorf("failed to rename new services table: %w", err) + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration transaction: %w", err) + } + + log.Println("[INFO] Successfully migrated services table - service names are now profile-scoped") + return nil +} + // GetGlobalEnvVars retrieves all global environment variables func (db *Database) GetGlobalEnvVars() (map[string]string, error) { query := `SELECT var_name, var_value FROM global_env_vars ORDER BY var_name` diff --git a/internal/services/manager.go b/internal/services/manager.go index 5b3edf7..943b586 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -464,11 +464,9 @@ func (sm *Manager) RenameService(serviceUUID, newName string) error { return fmt.Errorf("service UUID %s not found", serviceUUID) } - // Check if new name is already taken - for _, s := range sm.services { - if s.Name == newName && s.ID != serviceUUID { - return fmt.Errorf("service name %s already exists", newName) - } + // Check if new name is already taken within profiles that contain this service + if err := sm.ValidateServiceNameUniquenessInProfile(serviceUUID, newName); err != nil { + return err } // If names are the same, no rename needed @@ -504,6 +502,54 @@ func (sm *Manager) CleanupPort(port int) *PortCleanupResult { return KillProcessesOnPort(port) } +// ValidateServiceNameUniquenessInProfile checks if a service name is unique within the profiles it belongs to +// This replaces the global service name uniqueness validation with profile-scoped validation +func (sm *Manager) ValidateServiceNameUniquenessInProfile(serviceUUID, serviceName string) error { + // Get all profiles that contain services + profiles, err := sm.db.GetAllServiceProfiles() + if err != nil { + return fmt.Errorf("failed to get profiles for validation: %w", err) + } + + // Check each profile for name conflicts + for _, profile := range profiles { + // Parse services JSON to get list of service UUIDs in this profile + var serviceUUIDs []string + if err := json.Unmarshal([]byte(profile.ServicesJSON), &serviceUUIDs); err != nil { + log.Printf("[WARN] Failed to parse services JSON for profile %s: %v", profile.ID, err) + continue + } + + // Check if the service we're validating is in this profile + serviceInProfile := false + for _, uuid := range serviceUUIDs { + if uuid == serviceUUID { + serviceInProfile = true + break + } + } + + // If service is in this profile, check for name conflicts with other services in the same profile + if serviceInProfile { + for _, uuid := range serviceUUIDs { + if uuid == serviceUUID { + continue // Skip self + } + + // Get the other service + if otherService, exists := sm.services[uuid]; exists { + if otherService.Name == serviceName { + return fmt.Errorf("service name '%s' already exists in profile '%s' (service UUID: %s)", + serviceName, profile.Name, uuid) + } + } + } + } + } + + return nil +} + // ValidateServiceUniqueness checks if a service would conflict with existing services // based on the combination of profile root directory and service directory // Note: This method assumes the caller already holds the appropriate mutex lock diff --git a/internal/services/profile.go b/internal/services/profile.go index a5d08c3..da95356 100644 --- a/internal/services/profile.go +++ b/internal/services/profile.go @@ -610,11 +610,23 @@ func (ps *ProfileService) AddServiceToProfile(userID, profileID, serviceUUID str } // Verify that the service exists globally - if _, exists := ps.sm.GetServiceByUUID(serviceUUID); !exists { + service, exists := ps.sm.GetServiceByUUID(serviceUUID) + if !exists { ps.mutex.Unlock() return fmt.Errorf("service '%s' does not exist", serviceUUID) } + // Check for name conflicts within this profile + for _, existingServiceUUID := range profile.Services { + if existingService, exists := ps.sm.GetServiceByUUID(existingServiceUUID); exists { + if existingService.Name == service.Name { + ps.mutex.Unlock() + return fmt.Errorf("service name '%s' already exists in profile '%s' (existing service UUID: %s)", + service.Name, profile.Name, existingServiceUUID) + } + } + } + // Add the service to the profile's services list updatedServices := append(profile.Services, serviceUUID) From b9f7d919315e0524dfec6a5a2ae918957c7a4802 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 21:11:39 +0300 Subject: [PATCH 08/12] fix: scope uptime statistics to current active profile instead of showing all services Backend changes: - Update getUptimeStatisticsHandler to filter services by active profile - Extract user from JWT token to determine active profile - Filter services list to only include those in the active profile - Maintain backward compatibility by falling back to all services if no active profile Frontend changes: - Add Authorization Bearer token to uptime statistics API call - Include proper authentication headers for profile-scoped data access - Maintain consistent authentication pattern with other API calls Now uptime statistics only show services from the user's currently active profile, providing a focused view relevant to the current working context. --- internal/handlers/uptime_handler.go | 30 +++++++++++++++++-- .../UptimeStatisticsDashboard.tsx | 15 +++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/internal/handlers/uptime_handler.go b/internal/handlers/uptime_handler.go index 40662cc..94f6d33 100644 --- a/internal/handlers/uptime_handler.go +++ b/internal/handlers/uptime_handler.go @@ -15,17 +15,43 @@ func registerUptimeRoutes(h *Handler, r *mux.Router) { r.HandleFunc("/api/uptime/statistics/{id}", h.getServiceUptimeStatisticsHandler).Methods("GET") } -// getUptimeStatisticsHandler returns uptime statistics for all services +// getUptimeStatisticsHandler returns uptime statistics for services in the current active profile func (h *Handler) getUptimeStatisticsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + // Get user from JWT token to determine active profile + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + uptimeTracker := services.GetUptimeTracker() allStats := uptimeTracker.GetAllUptimeStats() + // Get services from active profile, fallback to all services if no active profile + var services []models.Service + activeProfile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil || activeProfile == nil { + // No active profile, show all services (fallback for backward compatibility) + services = h.serviceManager.GetServices() + } else { + // Filter services by active profile + allServices := h.serviceManager.GetServices() + for _, service := range allServices { + // Check if service is in the active profile + for _, serviceUUID := range activeProfile.Services { + if service.ID == serviceUUID { + services = append(services, service) + break + } + } + } + } + // Enhance with service names serviceStats := make(map[string]interface{}) - services := h.serviceManager.GetServices() for _, service := range services { if stats, exists := allStats[service.ID]; exists { diff --git a/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx b/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx index e49f804..93e9420 100644 --- a/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx +++ b/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx @@ -38,7 +38,20 @@ export function UptimeStatisticsDashboard() { try { setIsLoading(true); setError(null); - const response = await fetch("/api/uptime/statistics"); + + // Get auth token for API call + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch("/api/uptime/statistics", { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { throw new Error( `Failed to fetch uptime statistics: ${response.statusText}`, From 04b7c0fec814fe89ba43b8bf8233d2ad87c9e63a Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Wed, 15 Oct 2025 21:21:19 +0300 Subject: [PATCH 09/12] feat: enhance uptime statistics UI with comprehensive visual improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Components: - UptimeProgressBar: Visual progress bars with color-coded uptime percentages and trend indicators - ServiceDetailModal: Detailed drill-down view with comprehensive service metrics and analytics - ServiceUptimeCard: Mobile-optimized card layout with key metrics and visual indicators - UptimeFilters: Advanced filtering and sorting controls with search, status, and uptime filters Enhanced Dashboard Features: - Grid/Table view toggle for better user experience and mobile responsiveness - Real-time filtering by service name, status, and uptime thresholds - Multi-criteria sorting (name, uptime, restarts, downtime) with ascending/descending order - Time range selector (1h, 6h, 24h, 7d, 30d) for flexible data viewing - Interactive service cards and table rows with click-to-detail functionality Visual Enhancements: - Color-coded progress bars for instant uptime assessment (green ≥99%, yellow ≥95%, red <95%) - Trend indicators showing uptime improvements/degradations - Availability ratings (Excellent, Good, Fair, Poor) based on 7-day uptime - Updated summary cards with filtered counts and contextual information - Responsive design optimized for mobile, tablet, and desktop User Experience Improvements: - Modal-based service details with comprehensive metrics breakdown - Advanced filtering with reset functionality - Visual feedback for loading states and empty results - Improved mobile navigation and touch-friendly interfaces - Profile-scoped data display with service count indicators The uptime statistics now provide a comprehensive, visually appealing, and highly interactive interface for monitoring service reliability and availability metrics. --- .../UptimeStatistics/ServiceDetailModal.tsx | 314 ++++++++++++++ .../UptimeStatistics/ServiceUptimeCard.tsx | 164 ++++++++ .../UptimeStatistics/UptimeFilters.tsx | 177 ++++++++ .../UptimeStatistics/UptimeProgressBar.tsx | 80 ++++ .../UptimeStatisticsDashboard.tsx | 385 +++++++++++++----- 5 files changed, 1015 insertions(+), 105 deletions(-) create mode 100644 web/src/components/UptimeStatistics/ServiceDetailModal.tsx create mode 100644 web/src/components/UptimeStatistics/ServiceUptimeCard.tsx create mode 100644 web/src/components/UptimeStatistics/UptimeFilters.tsx create mode 100644 web/src/components/UptimeStatistics/UptimeProgressBar.tsx diff --git a/web/src/components/UptimeStatistics/ServiceDetailModal.tsx b/web/src/components/UptimeStatistics/ServiceDetailModal.tsx new file mode 100644 index 0000000..7cd6bfc --- /dev/null +++ b/web/src/components/UptimeStatistics/ServiceDetailModal.tsx @@ -0,0 +1,314 @@ +import React, { useState, useEffect } from 'react'; +import { X, Clock, Activity, AlertTriangle, TrendingUp, Server, Zap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { UptimeProgressBar } from './UptimeProgressBar'; + +interface ServiceDetailModalProps { + serviceId: string; + serviceName: string; + isOpen: boolean; + onClose: () => void; +} + +interface ServiceDetailData { + serviceName: string; + serviceId: string; + port: number; + status: string; + healthStatus: string; + stats: { + totalRestarts: number; + uptimePercentage24h: number; + uptimePercentage7d: number; + mtbf: number; + lastDowntime: string | null; + totalDowntime24h: number; + totalDowntime7d: number; + }; +} + +export const ServiceDetailModal: React.FC = ({ + serviceId, + serviceName, + isOpen, + onClose +}) => { + const [serviceDetail, setServiceDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchServiceDetail = async () => { + try { + setIsLoading(true); + setError(null); + + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/uptime/statistics/${serviceId}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch service details: ${response.statusText}`); + } + + const data = await response.json(); + setServiceDetail(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch service details"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isOpen && serviceId) { + fetchServiceDetail(); + } + }, [isOpen, serviceId]); + + const formatDuration = (nanoseconds: number): string => { + if (nanoseconds === 0) return "None"; + + const totalSeconds = nanoseconds / 1_000_000_000; + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else if (minutes > 0) { + return `${minutes}m`; + } else { + return `${Math.floor(totalSeconds)}s`; + } + }; + + const getStatusBadgeVariant = (status: string, healthStatus: string) => { + if (status === "running" && healthStatus === "healthy") return "default"; + if (status === "running" && healthStatus === "unhealthy") return "destructive"; + if (status === "running") return "secondary"; + return "outline"; + }; + + const calculateAvailability = (uptime: number) => { + if (uptime >= 99.9) return "Excellent"; + if (uptime >= 99) return "Good"; + if (uptime >= 95) return "Fair"; + return "Poor"; + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ Service Details +

+

+ {serviceName} +

+
+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ + Loading service details... +
+ ) : error ? ( +
+ +

+ Error Loading Service Details +

+

{error}

+ +
+ ) : serviceDetail ? ( +
+ {/* Service Overview */} +
+ + + Status + + + +
+ + {serviceDetail.status === "running" ? serviceDetail.healthStatus : serviceDetail.status} + +
+

+ Port {serviceDetail.port} +

+
+
+ + + + Total Restarts + + + +
{serviceDetail.stats.totalRestarts}
+

+ All time +

+
+
+ + + + MTBF + + + +
+ {formatDuration(serviceDetail.stats.mtbf)} +
+

+ Mean time between failures +

+
+
+ + + + Availability + + + +
+ {calculateAvailability(serviceDetail.stats.uptimePercentage7d)} +
+

+ 7-day rating +

+
+
+
+ + {/* Uptime Statistics */} + + + + + Uptime Statistics + + + +
+
+ 24 Hour Uptime + + {serviceDetail.stats.uptimePercentage24h.toFixed(2)}% + +
+ +
+ +
+
+ 7 Day Uptime + + {serviceDetail.stats.uptimePercentage7d.toFixed(2)}% + +
+ +
+
+
+ + {/* Downtime Analysis */} + + + + + Downtime Analysis + + + +
+
+

24 Hour Downtime

+
+ {formatDuration(serviceDetail.stats.totalDowntime24h)} +
+
+
+

7 Day Downtime

+
+ {formatDuration(serviceDetail.stats.totalDowntime7d)} +
+
+
+ + {serviceDetail.stats.lastDowntime && ( +
+

Last Downtime

+
+ {new Date(serviceDetail.stats.lastDowntime).toLocaleString()} +
+
+ )} +
+
+
+ ) : ( +
+ No service details available +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/UptimeStatistics/ServiceUptimeCard.tsx b/web/src/components/UptimeStatistics/ServiceUptimeCard.tsx new file mode 100644 index 0000000..7309a41 --- /dev/null +++ b/web/src/components/UptimeStatistics/ServiceUptimeCard.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { Clock, Zap, Activity, TrendingUp, Server } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { UptimeProgressBar } from './UptimeProgressBar'; +import { UptimeStatistics } from '@/types'; + +interface ServiceUptimeCardProps { + service: { + serviceName: string; + serviceId: string; + port: number; + status: string; + healthStatus: string; + stats: UptimeStatistics; + }; + onClick?: () => void; +} + +export const ServiceUptimeCard: React.FC = ({ + service, + onClick +}) => { + const formatDuration = (nanoseconds: number): string => { + if (nanoseconds === 0) return "None"; + + const totalSeconds = nanoseconds / 1_000_000_000; + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else if (minutes > 0) { + return `${minutes}m`; + } else { + return `${Math.floor(totalSeconds)}s`; + } + }; + + const getStatusBadgeVariant = (status: string, healthStatus: string) => { + if (status === "running" && healthStatus === "healthy") return "default"; + if (status === "running" && healthStatus === "unhealthy") return "destructive"; + if (status === "running") return "secondary"; + return "outline"; + }; + + const getAvailabilityRating = (uptime: number) => { + if (uptime >= 99.9) return { rating: "Excellent", color: "text-green-600" }; + if (uptime >= 99) return { rating: "Good", color: "text-blue-600" }; + if (uptime >= 95) return { rating: "Fair", color: "text-yellow-600" }; + return { rating: "Poor", color: "text-red-600" }; + }; + + const availabilityRating = getAvailabilityRating(service.stats.uptimePercentage7d); + + return ( + + + +
+ + {service.serviceName} +
+ + {service.status === "running" ? service.healthStatus : service.status} + +
+

+ Port {service.port} • {availabilityRating.rating} +

+
+ + + {/* Uptime Progress Bars */} +
+
+
+ + + 24h Uptime + + + {service.stats.uptimePercentage24h.toFixed(1)}% + +
+ +
+ +
+
+ + + 7d Uptime + + + {service.stats.uptimePercentage7d.toFixed(1)}% + +
+ +
+
+ + {/* Key Metrics */} +
+
+
+ + Restarts +
+
+ {service.stats.totalRestarts} +
+
+ +
+
+ + Downtime +
+
+ {formatDuration(service.stats.totalDowntime24h)} +
+
+
+ + {/* MTBF */} +
+
+ + Mean Time Between Failures + + + {formatDuration(service.stats.mtbf)} + +
+
+ + {/* Availability Rating */} +
+
+ {availabilityRating.rating} Availability +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/UptimeStatistics/UptimeFilters.tsx b/web/src/components/UptimeStatistics/UptimeFilters.tsx new file mode 100644 index 0000000..78611e5 --- /dev/null +++ b/web/src/components/UptimeStatistics/UptimeFilters.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { Filter, Search, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +export interface UptimeFilters { + searchTerm: string; + statusFilter: 'all' | 'running' | 'stopped' | 'unhealthy'; + uptimeFilter: 'all' | 'excellent' | 'good' | 'fair' | 'poor'; + sortBy: 'name' | 'uptime24h' | 'uptime7d' | 'restarts' | 'downtime'; + sortOrder: 'asc' | 'desc'; + timeRange: '1h' | '6h' | '24h' | '7d' | '30d'; +} + +interface UptimeFiltersProps { + filters: UptimeFilters; + onFiltersChange: (filters: UptimeFilters) => void; + onReset: () => void; +} + +export const UptimeFiltersComponent: React.FC = ({ + filters, + onFiltersChange, + onReset +}) => { + const updateFilter = (key: keyof UptimeFilters, value: any) => { + onFiltersChange({ + ...filters, + [key]: value + }); + }; + + const timeRangeOptions = [ + { value: '1h', label: '1 Hour' }, + { value: '6h', label: '6 Hours' }, + { value: '24h', label: '24 Hours' }, + { value: '7d', label: '7 Days' }, + { value: '30d', label: '30 Days' } + ]; + + const statusOptions = [ + { value: 'all', label: 'All Status' }, + { value: 'running', label: 'Running' }, + { value: 'stopped', label: 'Stopped' }, + { value: 'unhealthy', label: 'Unhealthy' } + ]; + + const uptimeOptions = [ + { value: 'all', label: 'All Uptime' }, + { value: 'excellent', label: 'Excellent (≥99.9%)' }, + { value: 'good', label: 'Good (≥99%)' }, + { value: 'fair', label: 'Fair (≥95%)' }, + { value: 'poor', label: 'Poor (<95%)' } + ]; + + const sortOptions = [ + { value: 'name', label: 'Service Name' }, + { value: 'uptime24h', label: '24h Uptime' }, + { value: 'uptime7d', label: '7d Uptime' }, + { value: 'restarts', label: 'Restart Count' }, + { value: 'downtime', label: 'Downtime' } + ]; + + return ( +
+
+
+ + Filters & Options +
+ +
+ +
+ {/* Search */} +
+ +
+ + updateFilter('searchTerm', e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+
+ + {/* Time Range */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+ + {/* Uptime Filter */} +
+ + +
+ + {/* Sort By */} +
+ + +
+ + {/* Sort Order */} +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/UptimeStatistics/UptimeProgressBar.tsx b/web/src/components/UptimeStatistics/UptimeProgressBar.tsx new file mode 100644 index 0000000..63c164e --- /dev/null +++ b/web/src/components/UptimeStatistics/UptimeProgressBar.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; + +interface UptimeProgressBarProps { + percentage: number; + size?: 'sm' | 'md' | 'lg'; + showPercentage?: boolean; + trend?: number; // Previous period comparison + className?: string; +} + +export const UptimeProgressBar: React.FC = ({ + percentage, + size = 'md', + showPercentage = true, + trend, + className = '' +}) => { + const getColorClass = (pct: number) => { + if (pct >= 99) return 'bg-green-500'; + if (pct >= 95) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + const getBackgroundColorClass = (pct: number) => { + if (pct >= 99) return 'bg-green-100 dark:bg-green-900/20'; + if (pct >= 95) return 'bg-yellow-100 dark:bg-yellow-900/20'; + return 'bg-red-100 dark:bg-red-900/20'; + }; + + const getTextColorClass = (pct: number) => { + if (pct >= 99) return 'text-green-700 dark:text-green-300'; + if (pct >= 95) return 'text-yellow-700 dark:text-yellow-300'; + return 'text-red-700 dark:text-red-300'; + }; + + const sizeClasses = { + sm: 'h-1.5', + md: 'h-2', + lg: 'h-3' + }; + + const TrendIcon = ({ trend }: { trend: number }) => { + if (Math.abs(trend) < 0.1) return ; + return trend > 0 ? + : + ; + }; + + return ( +
+
+
+
+
+
+ + {showPercentage && ( +
+ + {percentage.toFixed(2)}% + + {trend !== undefined && ( +
+ + {Math.abs(trend) >= 0.1 && ( + 0 ? 'text-green-600' : 'text-red-600'}`}> + {Math.abs(trend).toFixed(1)}% + + )} +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx b/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx index 93e9420..418dbbb 100644 --- a/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx +++ b/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx @@ -8,8 +8,14 @@ import { AlertTriangle, TrendingUp, Activity, + LayoutGrid, + List, } from "lucide-react"; import { UptimeStatistics } from "@/types"; +import { UptimeProgressBar } from "./UptimeProgressBar"; +import { ServiceDetailModal } from "./ServiceDetailModal"; +import { ServiceUptimeCard } from "./ServiceUptimeCard"; +import { UptimeFiltersComponent, UptimeFilters } from "./UptimeFilters"; interface ServiceUptimeStats { serviceName: string; @@ -33,6 +39,27 @@ export function UptimeStatisticsDashboard() { const [uptimeData, setUptimeData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid'); + const [selectedService, setSelectedService] = useState<{ id: string; name: string } | null>(null); + + // Filter state + const [filters, setFilters] = useState({ + searchTerm: '', + statusFilter: 'all', + uptimeFilter: 'all', + sortBy: 'name', + sortOrder: 'asc', + timeRange: '24h' + }); + + const defaultFilters: UptimeFilters = { + searchTerm: '', + statusFilter: 'all', + uptimeFilter: 'all', + sortBy: 'name', + sortOrder: 'asc', + timeRange: '24h' + }; const fetchUptimeStats = async () => { try { @@ -78,6 +105,94 @@ export function UptimeStatisticsDashboard() { return () => clearInterval(interval); }, []); + // Filter and sort services + const getFilteredAndSortedServices = () => { + if (!uptimeData) return []; + + let services = Object.values(uptimeData.statistics); + + // Apply search filter + if (filters.searchTerm) { + services = services.filter(service => + service.serviceName.toLowerCase().includes(filters.searchTerm.toLowerCase()) + ); + } + + // Apply status filter + if (filters.statusFilter !== 'all') { + services = services.filter(service => { + switch (filters.statusFilter) { + case 'running': + return service.status === 'running'; + case 'stopped': + return service.status === 'stopped'; + case 'unhealthy': + return service.status === 'running' && service.healthStatus === 'unhealthy'; + default: + return true; + } + }); + } + + // Apply uptime filter + if (filters.uptimeFilter !== 'all') { + services = services.filter(service => { + const uptime = service.stats.uptimePercentage7d; + switch (filters.uptimeFilter) { + case 'excellent': + return uptime >= 99.9; + case 'good': + return uptime >= 99 && uptime < 99.9; + case 'fair': + return uptime >= 95 && uptime < 99; + case 'poor': + return uptime < 95; + default: + return true; + } + }); + } + + // Apply sorting + services.sort((a, b) => { + let aValue: any, bValue: any; + + switch (filters.sortBy) { + case 'name': + aValue = a.serviceName.toLowerCase(); + bValue = b.serviceName.toLowerCase(); + break; + case 'uptime24h': + aValue = a.stats.uptimePercentage24h; + bValue = b.stats.uptimePercentage24h; + break; + case 'uptime7d': + aValue = a.stats.uptimePercentage7d; + bValue = b.stats.uptimePercentage7d; + break; + case 'restarts': + aValue = a.stats.totalRestarts; + bValue = b.stats.totalRestarts; + break; + case 'downtime': + aValue = a.stats.totalDowntime24h; + bValue = b.stats.totalDowntime24h; + break; + default: + aValue = a.serviceName.toLowerCase(); + bValue = b.serviceName.toLowerCase(); + } + + if (aValue < bValue) return filters.sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return filters.sortOrder === 'asc' ? 1 : -1; + return 0; + }); + + return services; + }; + + const filteredServices = getFilteredAndSortedServices(); + const formatDuration = (nanoseconds: number): string => { if (nanoseconds === 0) return "None"; @@ -102,11 +217,6 @@ export function UptimeStatisticsDashboard() { return formatDuration(nanoseconds); }; - const getUptimeColor = (percentage: number): string => { - if (percentage >= 99) return "text-green-600"; - if (percentage >= 95) return "text-yellow-600"; - return "text-red-600"; - }; const getStatusBadgeVariant = (status: string, healthStatus: string) => { if (status === "running" && healthStatus === "healthy") return "default"; @@ -145,28 +255,53 @@ export function UptimeStatisticsDashboard() { return
No uptime data available
; } - const services = Object.values(uptimeData.statistics); - return (
{/* Header */} -
+

Service Uptime Statistics

- Monitor service availability and reliability metrics + Monitor service availability and reliability metrics ({filteredServices.length} services)

- +
+
+ + +
+ +
+ {/* Filters */} + setFilters(defaultFilters)} + /> + {/* Summary Cards */}
@@ -178,8 +313,12 @@ export function UptimeStatisticsDashboard() {
- {uptimeData.summary.totalServices} + {filteredServices.length}
+

+ {filteredServices.length !== uptimeData.summary.totalServices && + `${uptimeData.summary.totalServices} total`} +

@@ -192,8 +331,11 @@ export function UptimeStatisticsDashboard() {
- {uptimeData.summary.runningServices} + {filteredServices.filter(s => s.status === 'running').length}
+

+ Active services +

@@ -206,101 +348,134 @@ export function UptimeStatisticsDashboard() {
- {uptimeData.summary.unhealthyServices} + {filteredServices.filter(s => s.status === 'running' && s.healthStatus === 'unhealthy').length}
+

+ Need attention +

- {/* Service Statistics Table */} - - - - - Service Uptime Details - - - -
- - - - - - - - - - - - - - {services.map((service) => ( - - - - - - - - - - ))} - -
ServiceStatus - 24h Uptime - - 7d Uptime - - Restarts - MTBF - 24h Downtime -
-
-
{service.serviceName}
-
- Port {service.port} -
-
-
- - {service.status === "running" - ? service.healthStatus - : service.status} - - - {service.stats.uptimePercentage24h.toFixed(2)}% - - {service.stats.uptimePercentage7d.toFixed(2)}% - - {service.stats.totalRestarts} - - {formatMTBF(service.stats.mtbf)} - - {formatDuration(service.stats.totalDowntime24h)} -
-
+ {/* Service Statistics - Grid View */} + {viewMode === 'grid' && ( +
+ {filteredServices.map((service) => ( + setSelectedService({ id: service.serviceId, name: service.serviceName })} + /> + ))} +
+ )} - {services.length === 0 && ( -
- No services available + {/* Service Statistics - Table View */} + {viewMode === 'table' && ( + + + + + Service Uptime Details + + + +
+ + + + + + + + + + + + + + {filteredServices.map((service) => ( + setSelectedService({ id: service.serviceId, name: service.serviceName })} + > + + + + + + + + + ))} + +
ServiceStatus + 24h Uptime + + 7d Uptime + + Restarts + MTBF + 24h Downtime +
+
+
{service.serviceName}
+
+ Port {service.port} +
+
+
+ + {service.status === "running" + ? service.healthStatus + : service.status} + + + + + + + {service.stats.totalRestarts} + + {formatMTBF(service.stats.mtbf)} + + {formatDuration(service.stats.totalDowntime24h)} +
- )} -
-
+ + {filteredServices.length === 0 && ( +
+ No services match the current filters +
+ )} + + + )} + + {/* Service Detail Modal */} + {selectedService && ( + setSelectedService(null)} + /> + )}
); } From 8e5fdd5ac85e642248bba44216c27d666f735b74 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Thu, 16 Oct 2025 01:24:40 +0300 Subject: [PATCH 10/12] fix: make table/list view the default view for uptime statistics - Change default viewMode from 'grid' to 'table' for better data density - Reorder view toggle buttons to show List icon first (as default) - Reorganize JSX sections to place table view before grid view for consistency - Table view provides more comprehensive data at a glance compared to cards - Users can still switch to grid view for a more visual/mobile-friendly experience --- .../UptimeStatisticsDashboard.tsx | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx b/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx index 418dbbb..883fefe 100644 --- a/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx +++ b/web/src/components/UptimeStatistics/UptimeStatisticsDashboard.tsx @@ -39,7 +39,7 @@ export function UptimeStatisticsDashboard() { const [uptimeData, setUptimeData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid'); + const [viewMode, setViewMode] = useState<'grid' | 'table'>('table'); const [selectedService, setSelectedService] = useState<{ id: string; name: string } | null>(null); // Filter state @@ -270,20 +270,20 @@ export function UptimeStatisticsDashboard() {
- {/* Service Statistics - Grid View */} - {viewMode === 'grid' && ( -
- {filteredServices.map((service) => ( - setSelectedService({ id: service.serviceId, name: service.serviceName })} - /> - ))} -
- )} - {/* Service Statistics - Table View */} {viewMode === 'table' && ( @@ -467,6 +454,19 @@ export function UptimeStatisticsDashboard() { )} + {/* Service Statistics - Grid View */} + {viewMode === 'grid' && ( +
+ {filteredServices.map((service) => ( + setSelectedService({ id: service.serviceId, name: service.serviceName })} + /> + ))} +
+ )} + {/* Service Detail Modal */} {selectedService && ( Date: Thu, 16 Oct 2025 01:49:08 +0300 Subject: [PATCH 11/12] fix: add authentication and profile scoping to all log operations - Add JWT authentication checks to all log-related API endpoints - Scope log operations to current user's active profile services only - Fix frontend API calls to include Authorization headers - Update log search to use serviceIds instead of serviceNames - Fix variable redeclaration errors in utility handlers - Ensure log cleanup, search, export, and clear operations respect profile boundaries --- internal/handlers/service_handler.go | 120 ++++++++++++- internal/handlers/utility_handler.go | 187 +++++++++++++++++++-- web/src/components/LogSearch/LogSearch.tsx | 30 +++- web/src/services/logsOperations.ts | 9 + web/src/services/serviceApi.ts | 25 ++- web/src/services/serviceOperations.ts | 6 + 6 files changed, 353 insertions(+), 24 deletions(-) diff --git a/internal/handlers/service_handler.go b/internal/handlers/service_handler.go index e1b1314..d59a47e 100644 --- a/internal/handlers/service_handler.go +++ b/internal/handlers/service_handler.go @@ -608,6 +608,35 @@ func (h *Handler) getLogsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + // Check authentication + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok || claims == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Get user's active profile + profile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil { + log.Printf("[ERROR] Failed to get active profile for logs: %v", err) + http.Error(w, "Failed to get active profile", http.StatusInternalServerError) + return + } + + // Check if the service belongs to the current profile + serviceInProfile := false + for _, profileServiceID := range profile.Services { + if profileServiceID == serviceUUID { + serviceInProfile = true + break + } + } + + if !serviceInProfile { + http.Error(w, "Service not found in current profile", http.StatusForbidden) + return + } + service, exists := h.serviceManager.GetServiceByUUID(serviceUUID) if !exists { http.Error(w, "Service not found", http.StatusNotFound) @@ -628,6 +657,35 @@ func (h *Handler) clearLogsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + // Check authentication + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok || claims == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Get user's active profile + profile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil { + log.Printf("[ERROR] Failed to get active profile for clear logs: %v", err) + http.Error(w, "Failed to get active profile", http.StatusInternalServerError) + return + } + + // Check if the service belongs to the current profile + serviceInProfile := false + for _, profileServiceID := range profile.Services { + if profileServiceID == serviceUUID { + serviceInProfile = true + break + } + } + + if !serviceInProfile { + http.Error(w, "Service not found in current profile", http.StatusForbidden) + return + } + service, exists := h.serviceManager.GetServiceByUUID(serviceUUID) if !exists { http.Error(w, fmt.Sprintf("Service '%s' not found", serviceUUID), http.StatusNotFound) @@ -649,16 +707,70 @@ func (h *Handler) clearAllLogsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + // Check authentication + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok || claims == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Get user's active profile + profile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil { + log.Printf("[ERROR] Failed to get active profile for clear all logs: %v", err) + http.Error(w, "Failed to get active profile", http.StatusInternalServerError) + return + } + var request struct { ServiceNames []string `json:"serviceNames,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - // If no body or invalid JSON, clear all logs + // If no body or invalid JSON, clear logs for all services in the profile request.ServiceNames = []string{} } - results := h.serviceManager.ClearAllLogs(request.ServiceNames) + // If no specific services requested, use all services from the current profile + // If specific services requested, filter them to only include those in the current profile + var targetServiceIDs []string + + if len(request.ServiceNames) == 0 { + // Clear logs for all services in the current profile + targetServiceIDs = profile.Services + } else { + // Filter requested service names to only include those in the current profile + profileServiceMap := make(map[string]bool) + for _, serviceID := range profile.Services { + profileServiceMap[serviceID] = true + } + + // Convert service names to service IDs and filter by profile + allServices := h.serviceManager.GetServices() + for _, service := range allServices { + // Check if this service name was requested AND is in the current profile + for _, requestedName := range request.ServiceNames { + if service.Name == requestedName && profileServiceMap[service.ID] { + targetServiceIDs = append(targetServiceIDs, service.ID) + break + } + } + } + } + + // Convert service IDs back to names for the ClearAllLogs function + var targetServiceNames []string + allServices := h.serviceManager.GetServices() + for _, serviceID := range targetServiceIDs { + for _, service := range allServices { + if service.ID == serviceID { + targetServiceNames = append(targetServiceNames, service.Name) + break + } + } + } + + results := h.serviceManager.ClearAllLogs(targetServiceNames) successCount := 0 errorCount := 0 @@ -678,9 +790,9 @@ func (h *Handler) clearAllLogsHandler(w http.ResponseWriter, r *http.Request) { } if len(request.ServiceNames) == 0 { - response["message"] = fmt.Sprintf("Cleared logs for all %d services", successCount) + response["message"] = fmt.Sprintf("Cleared logs for all %d services in current profile", successCount) } else { - response["message"] = fmt.Sprintf("Cleared logs for %d of %d specified services", successCount, len(request.ServiceNames)) + response["message"] = fmt.Sprintf("Cleared logs for %d of %d specified services in current profile", successCount, len(request.ServiceNames)) } json.NewEncoder(w).Encode(response) diff --git a/internal/handlers/utility_handler.go b/internal/handlers/utility_handler.go index 523df62..278d2ef 100644 --- a/internal/handlers/utility_handler.go +++ b/internal/handlers/utility_handler.go @@ -133,6 +133,21 @@ func (h *Handler) searchLogsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + // Check authentication + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok || claims == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Get user's active profile + profile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil { + log.Printf("[ERROR] Failed to get active profile for log search: %v", err) + http.Error(w, "Failed to get active profile", http.StatusInternalServerError) + return + } + var criteria struct { ServiceIDs []string `json:"serviceIds"` Levels []string `json:"levels"` @@ -148,22 +163,56 @@ func (h *Handler) searchLogsHandler(w http.ResponseWriter, r *http.Request) { return } + // Filter ServiceIDs to only include services from the current profile + // If no specific services requested, use all services from the profile + if len(criteria.ServiceIDs) == 0 { + // Use all services from the current profile + criteria.ServiceIDs = profile.Services + } else { + // Filter requested services to only include those in the current profile + var filteredServiceIDs []string + profileServiceMap := make(map[string]bool) + for _, serviceID := range profile.Services { + profileServiceMap[serviceID] = true + } + + for _, serviceID := range criteria.ServiceIDs { + if profileServiceMap[serviceID] { + filteredServiceIDs = append(filteredServiceIDs, serviceID) + } + } + criteria.ServiceIDs = filteredServiceIDs + } + + // If no services remain after filtering, return empty results + if len(criteria.ServiceIDs) == 0 { + response := map[string]interface{}{ + "results": []interface{}{}, + "totalCount": 0, + "limit": criteria.Limit, + "offset": criteria.Offset, + } + json.NewEncoder(w).Encode(response) + return + } + // Parse time strings var startTime, endTime time.Time - var err error if criteria.StartTime != "" { - startTime, err = time.Parse(time.RFC3339, criteria.StartTime) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid start time format: %v", err), http.StatusBadRequest) + var parseErr error + startTime, parseErr = time.Parse(time.RFC3339, criteria.StartTime) + if parseErr != nil { + http.Error(w, fmt.Sprintf("Invalid start time format: %v", parseErr), http.StatusBadRequest) return } } if criteria.EndTime != "" { - endTime, err = time.Parse(time.RFC3339, criteria.EndTime) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid end time format: %v", err), http.StatusBadRequest) + var parseErr error + endTime, parseErr = time.Parse(time.RFC3339, criteria.EndTime) + if parseErr != nil { + http.Error(w, fmt.Sprintf("Invalid end time format: %v", parseErr), http.StatusBadRequest) return } } @@ -205,18 +254,79 @@ func (h *Handler) getLogStatisticsHandler(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - stats, err := h.serviceManager.GetDatabase().GetLogStatistics() + // Check authentication + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok || claims == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Get user's active profile + profile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil { + log.Printf("[ERROR] Failed to get active profile for log statistics: %v", err) + http.Error(w, "Failed to get active profile", http.StatusInternalServerError) + return + } + + // Get full statistics first + allStats, err := h.serviceManager.GetDatabase().GetLogStatistics() if err != nil { http.Error(w, fmt.Sprintf("Failed to get log statistics: %v", err), http.StatusInternalServerError) return } + // Filter statistics to only include services from the current profile + profileServiceMap := make(map[string]bool) + for _, serviceID := range profile.Services { + profileServiceMap[serviceID] = true + } + + // Filter logsByService to only include profile services + profileLogsByService := make(map[string]int64) + if logsByService, ok := allStats["logsByService"].(map[string]int64); ok { + for serviceID, logCount := range logsByService { + if profileServiceMap[serviceID] { + profileLogsByService[serviceID] = logCount + } + } + } + + // Calculate profile-specific total logs + var profileTotalLogs int64 + for _, logCount := range profileLogsByService { + profileTotalLogs += logCount + } + + // Create profile-scoped statistics + stats := make(map[string]interface{}) + stats["totalLogs"] = profileTotalLogs + stats["logsByService"] = profileLogsByService + stats["logsByLevel"] = allStats["logsByLevel"] // Keep level stats global as they're informational + stats["oldestLog"] = allStats["oldestLog"] // Keep date range global as they're informational + stats["newestLog"] = allStats["newestLog"] // Keep date range global as they're informational + json.NewEncoder(w).Encode(stats) } func (h *Handler) exportLogsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") + // Check authentication + claims, ok := extractClaimsFromRequest(r, h.authService) + if !ok || claims == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Get user's active profile + profile, err := h.profileService.GetActiveProfile(claims.UserID) + if err != nil { + log.Printf("[ERROR] Failed to get active profile for log export: %v", err) + http.Error(w, "Failed to get active profile", http.StatusInternalServerError) + return + } + var exportRequest struct { ServiceIDs []string `json:"serviceIds"` Levels []string `json:"levels"` @@ -231,22 +341,69 @@ func (h *Handler) exportLogsHandler(w http.ResponseWriter, r *http.Request) { return } + // Filter ServiceIDs to only include services from the current profile + // If no specific services requested, use all services from the profile + if len(exportRequest.ServiceIDs) == 0 { + // Use all services from the current profile + exportRequest.ServiceIDs = profile.Services + } else { + // Filter requested services to only include those in the current profile + var filteredServiceIDs []string + profileServiceMap := make(map[string]bool) + for _, serviceID := range profile.Services { + profileServiceMap[serviceID] = true + } + + for _, serviceID := range exportRequest.ServiceIDs { + if profileServiceMap[serviceID] { + filteredServiceIDs = append(filteredServiceIDs, serviceID) + } + } + exportRequest.ServiceIDs = filteredServiceIDs + } + + // If no services remain after filtering, return empty export + if len(exportRequest.ServiceIDs) == 0 { + // Generate filename + timestamp := time.Now().Format("20060102_150405") + filename := fmt.Sprintf("vertex_logs_%s", timestamp) + + // Return empty export based on format + switch exportRequest.Format { + case "json": + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.json\"", filename)) + json.NewEncoder(w).Encode([]interface{}{}) + case "csv": + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.csv\"", filename)) + w.Write([]byte("Timestamp,Service,Level,Message\n")) + case "txt": + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.txt\"", filename)) + default: + http.Error(w, "Invalid export format. Supported formats: json, csv, txt", http.StatusBadRequest) + } + return + } + // Parse time strings var startTime, endTime time.Time - var err error if exportRequest.StartTime != "" { - startTime, err = time.Parse(time.RFC3339, exportRequest.StartTime) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid start time format: %v", err), http.StatusBadRequest) + var parseErr error + startTime, parseErr = time.Parse(time.RFC3339, exportRequest.StartTime) + if parseErr != nil { + http.Error(w, fmt.Sprintf("Invalid start time format: %v", parseErr), http.StatusBadRequest) return } } if exportRequest.EndTime != "" { - endTime, err = time.Parse(time.RFC3339, exportRequest.EndTime) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid end time format: %v", err), http.StatusBadRequest) + var parseErr error + endTime, parseErr = time.Parse(time.RFC3339, exportRequest.EndTime) + if parseErr != nil { + http.Error(w, fmt.Sprintf("Invalid end time format: %v", parseErr), http.StatusBadRequest) return } } diff --git a/web/src/components/LogSearch/LogSearch.tsx b/web/src/components/LogSearch/LogSearch.tsx index 83ac83d..c56e3e6 100644 --- a/web/src/components/LogSearch/LogSearch.tsx +++ b/web/src/components/LogSearch/LogSearch.tsx @@ -84,8 +84,15 @@ export function LogSearch({ services = [], className = "" }: LogSearchProps) { setIsSearching(true); setError(null); + // Convert service names to service IDs + const selectedServiceIds = selectedServices.length > 0 + ? safeServices + .filter(service => selectedServices.includes(service.name)) + .map(service => service.id) + : []; + const searchCriteria = { - serviceNames: selectedServices, + serviceIds: selectedServiceIds, levels: selectedLevels, searchText: searchText, startTime: startDate ? new Date(startDate).toISOString() : "", @@ -94,10 +101,16 @@ export function LogSearch({ services = [], className = "" }: LogSearchProps) { offset: (currentPage - 1) * resultsPerPage, }; + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + const response = await fetch("/api/logs/search", { method: "POST", headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, body: JSON.stringify(searchCriteria), }); @@ -125,8 +138,15 @@ export function LogSearch({ services = [], className = "" }: LogSearchProps) { try { setIsExporting(true); + // Convert service names to service IDs + const selectedServiceIds = selectedServices.length > 0 + ? safeServices + .filter(service => selectedServices.includes(service.name)) + .map(service => service.id) + : []; + const exportRequest = { - serviceNames: selectedServices, + serviceIds: selectedServiceIds, levels: selectedLevels, searchText: searchText, startTime: startDate ? new Date(startDate).toISOString() : "", @@ -134,10 +154,16 @@ export function LogSearch({ services = [], className = "" }: LogSearchProps) { format: format, }; + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + const response = await fetch("/api/logs/export", { method: "POST", headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, body: JSON.stringify(exportRequest), }); diff --git a/web/src/services/logsOperations.ts b/web/src/services/logsOperations.ts index 3b77a09..d33f3b4 100644 --- a/web/src/services/logsOperations.ts +++ b/web/src/services/logsOperations.ts @@ -34,8 +34,17 @@ export class LogsOperations { static async clearServiceLogs(serviceName: string): Promise<{ success: boolean; error?: string }> { try { + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + const response = await fetch(`/api/services/${serviceName}/logs`, { method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, }); if (!response.ok) { diff --git a/web/src/services/serviceApi.ts b/web/src/services/serviceApi.ts index 15fe876..39843d2 100644 --- a/web/src/services/serviceApi.ts +++ b/web/src/services/serviceApi.ts @@ -84,7 +84,17 @@ export class ServiceApi { * Get service logs */ static async getServiceLogs(serviceName: string): Promise { - const response = await fetch(`/api/services/${serviceName}/logs`); + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/services/${serviceName}/logs`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); if (!response.ok) { throw new Error(`Failed to fetch logs: ${response.status} ${response.statusText}`); } @@ -109,8 +119,17 @@ export class ServiceApi { * Clear service logs */ static async clearServiceLogs(serviceName: string): Promise { - const response = await fetch(`/api/services/${serviceName}/logs/clear`, { - method: 'POST', + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/services/${serviceName}/logs`, { + method: 'DELETE', + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, }); if (!response.ok) { diff --git a/web/src/services/serviceOperations.ts b/web/src/services/serviceOperations.ts index e6e4dbd..7289ab5 100644 --- a/web/src/services/serviceOperations.ts +++ b/web/src/services/serviceOperations.ts @@ -382,10 +382,16 @@ export class ServiceOperations { serviceNames?: string[], ): Promise { try { + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + const response = await fetch("/api/services/logs/clear", { method: "DELETE", headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, body: JSON.stringify({ serviceNames: serviceNames || [], From 4a320692c3f47c4b31fe1ffe71351616d1a2a6d6 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Thu, 16 Oct 2025 01:55:46 +0300 Subject: [PATCH 12/12] fix: close service environment variables modal after successful save - Add onClose() call after successful environment variables save - Improves user experience by automatically closing the modal - Maintains existing success toast notification --- web/src/components/ServiceEnvModal/ServiceEnvModal.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/components/ServiceEnvModal/ServiceEnvModal.tsx b/web/src/components/ServiceEnvModal/ServiceEnvModal.tsx index 45c62ac..8581434 100644 --- a/web/src/components/ServiceEnvModal/ServiceEnvModal.tsx +++ b/web/src/components/ServiceEnvModal/ServiceEnvModal.tsx @@ -129,6 +129,8 @@ export function ServiceEnvModal({ `Successfully updated ${Object.keys(envVarsObject).length} environment variables for ${serviceName}`, ), ); + // Close the modal after successful save + onClose(); } catch (error) { console.error("Failed to save service env vars:", error); addToast(