From 59f5a11a45b026e5f3fc405925f6cecffe880cff Mon Sep 17 00:00:00 2001 From: maheshbhatiya73 Date: Tue, 3 Jun 2025 02:46:08 +0530 Subject: [PATCH 1/2] Initial commit on frontend-services --- snaptrack-frontend/app/components/Sidebar.tsx | 2 +- .../app/context/SocketContext.tsx | 81 +++++-- snaptrack-frontend/app/main/Firewall/page.tsx | 10 + snaptrack-frontend/app/main/backups/page.tsx | 2 - .../app/main/deployments/page.tsx | 7 - snaptrack-frontend/app/main/services/page.tsx | 201 ++++++++++++++++++ snaptrack-frontend/package-lock.json | 36 ++++ snaptrack-frontend/package.json | 2 + 8 files changed, 318 insertions(+), 23 deletions(-) create mode 100644 snaptrack-frontend/app/main/Firewall/page.tsx delete mode 100644 snaptrack-frontend/app/main/deployments/page.tsx create mode 100644 snaptrack-frontend/app/main/services/page.tsx diff --git a/snaptrack-frontend/app/components/Sidebar.tsx b/snaptrack-frontend/app/components/Sidebar.tsx index 1a48a43..5fd12fc 100644 --- a/snaptrack-frontend/app/components/Sidebar.tsx +++ b/snaptrack-frontend/app/components/Sidebar.tsx @@ -9,7 +9,7 @@ import { const navItems = [ { icon: , label: 'Dashboard', path: '/' }, - { icon: , label: 'Service', path: '/main/Service' }, + { icon: , label: 'Service', path: '/main/services' }, { icon: , label: 'Backups', path: '/main/backups' }, { icon: , label: 'Firewall', path: '/main/Firewall' }, { icon: , label: 'Logs', path: '/main/logs' }, diff --git a/snaptrack-frontend/app/context/SocketContext.tsx b/snaptrack-frontend/app/context/SocketContext.tsx index f131e30..5327f91 100644 --- a/snaptrack-frontend/app/context/SocketContext.tsx +++ b/snaptrack-frontend/app/context/SocketContext.tsx @@ -1,12 +1,22 @@ "use client"; import React, { createContext, useContext, useEffect, useState } from "react"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +interface ServiceInfo { + name: string; + status: string; + uptime: string; + memory: string; + version: string; +} interface Metrics { diskTotal: number; uptimeSeconds: number; - netOutBytes: any; - netInBytes: any; + netOutBytes: number; + netInBytes: number; ramUsedBytes: number; ramTotalBytes: number; diskTotalBytes: number; @@ -16,14 +26,26 @@ interface Metrics { diskPercent: number; } +interface LogMessage { + type: string; + service: string; + log: string; +} + interface SocketContextType { socket: WebSocket | null; metrics: Metrics | null; + services: ServiceInfo[] | null; + logs: { [service: string]: string[] }; + sendAction: (type: "start" | "stop" | "restart" | "logs", service: string) => void; } const SocketContext = createContext({ socket: null, metrics: null, + services: null, + logs: {}, + sendAction: () => {}, }); export const useSocket = () => useContext(SocketContext); @@ -31,42 +53,75 @@ export const useSocket = () => useContext(SocketContext); export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [socket, setSocket] = useState(null); const [metrics, setMetrics] = useState(null); + const [services, setServices] = useState(null); + const [logs, setLogs] = useState<{ [service: string]: string[] }>({}); + + const sendAction = (type: "start" | "stop" | "restart" | "logs", service: string) => { + if (socket && socket.readyState === WebSocket.OPEN) { + const action = { type, service }; + socket.send(JSON.stringify(action)); + console.log(`Sent action: ${JSON.stringify(action)}`); + } else { + console.error("WebSocket is not open"); + toast.error("WebSocket connection not established"); + } + }; useEffect(() => { const socket = new WebSocket("ws://localhost:8000/ws"); socket.onopen = () => { - console.log("✅ Connected"); + console.log("✅ WebSocket connected"); + toast.success("Connected to server"); }; - socket.onmessage = (event) => { + socket.onmessage = (event: MessageEvent) => { try { - const data: Metrics = JSON.parse(event.data); - console.log(data) - setMetrics(data); + const data = JSON.parse(event.data); + console.log("Received:", data); + + if (data.type === "services") { + setServices(data.services); + } else if (data.type === "metrics") { + setMetrics(data.stats); + } else if (data.type === "log") { + setLogs((prev) => ({ + ...prev, + [data.service]: data.log.split("\n").filter((line: string) => line.trim()), + })); + } else if (data.type === "action_response") { + toast[data.success ? "success" : "error"](data.message, { + position: "top-right", + autoClose: 3000, + }); + } } catch (e) { - console.error("Failed to parse message", e); + console.error("Failed to parse message:", e); + toast.error("Error processing server message"); } }; socket.onclose = () => { - console.log("❌ Disconnected"); + console.log("❌ WebSocket disconnected"); + toast.error("Disconnected from server"); }; - socket.onerror = (err) => { - console.error("❗ Error:", err); + socket.onerror = (err: Event) => { + console.error("❗ WebSocket error:", err); + toast.error("WebSocket error occurred"); }; setSocket(socket); return () => { socket.close(); + console.log("WebSocket cleanup"); }; }, []); return ( - + {children} ); -}; +}; \ No newline at end of file diff --git a/snaptrack-frontend/app/main/Firewall/page.tsx b/snaptrack-frontend/app/main/Firewall/page.tsx new file mode 100644 index 0000000..7b92a05 --- /dev/null +++ b/snaptrack-frontend/app/main/Firewall/page.tsx @@ -0,0 +1,10 @@ +const FirewallPage = () => { + return ( +
+

Firewall Settings

+

Configure your firewall settings here.

+ {/* Add your firewall configuration components here */} +
+ ); +} +export default FirewallPage; \ No newline at end of file diff --git a/snaptrack-frontend/app/main/backups/page.tsx b/snaptrack-frontend/app/main/backups/page.tsx index d9bd7ab..22dcd0e 100644 --- a/snaptrack-frontend/app/main/backups/page.tsx +++ b/snaptrack-frontend/app/main/backups/page.tsx @@ -14,7 +14,6 @@ type BackupWithLogs = Backup & { }[]; }; -// Ensure Backup type status property includes all possible values type BackupType = { id: string; app: string; @@ -54,7 +53,6 @@ const Home = () => { useEffect(() => { const fetchBackups = async () => { if (!token) { - addToast('No authentication token found', 'error'); return; } try { diff --git a/snaptrack-frontend/app/main/deployments/page.tsx b/snaptrack-frontend/app/main/deployments/page.tsx deleted file mode 100644 index 48bd996..0000000 --- a/snaptrack-frontend/app/main/deployments/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -const app = () => { - return( -
page
- ) -} - -export default app \ No newline at end of file diff --git a/snaptrack-frontend/app/main/services/page.tsx b/snaptrack-frontend/app/main/services/page.tsx new file mode 100644 index 0000000..ba06ef4 --- /dev/null +++ b/snaptrack-frontend/app/main/services/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import React, { useState } from "react"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { motion, AnimatePresence } from "framer-motion"; +import { FaPlay, FaStop, FaSync, FaFileAlt, FaTimes } from "react-icons/fa"; +import { useSocket } from "@/app/context/SocketContext"; + +interface ServiceInfo { + name: string; + status: string; + uptime: string; + memory: string; + version: string; +} + +const ServiceRow: React.FC<{ service: ServiceInfo }> = ({ service }) => { + const { sendAction, logs } = useSocket(); + const [showLogs, setShowLogs] = useState(false); + + const handleAction = (action: string) => { + sendAction(action, service.name); + toast.success(`${action.charAt(0).toUpperCase() + action.slice(1)} initiated for ${service.name}`, { + position: "top-right", + autoClose: 2000, + }); + }; + + return ( + <> + + {service.name} + + + {service.status} + + + {service.version} + {service.uptime} + {service.memory} + + handleAction("start")} + className="p-2 bg-green-500 text-white rounded-full hover:bg-green-600 transition-colors" + title="Start Service" + > + + + handleAction("stop")} + className="p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors" + title="Stop Service" + > + + + handleAction("restart")} + className="p-2 bg-blue-500 text-white rounded-full hover:bg-blue-600 transition-colors" + title="Restart Service" + > + + + { + handleAction("logs"); + setShowLogs(true); + }} + className="p-2 bg-sky-500 text-white rounded-full hover:bg-sky-600 transition-colors" + title="View Logs" + > + + + + + + + {showLogs && logs[service.name] && ( + + +
+

Logs for {service.name}

+ setShowLogs(false)} + className="text-sky-600 hover:text-sky-800" + > + + +
+
+ {logs[service.name].map((log: string, index: number) => ( + + {log} + + ))} +
+
+
+ )} +
+ + ); +}; + +const ServicesPage: React.FC = () => { + const { services } = useSocket(); + + return ( +
+ + System Services Dashboard + +
+ {services ? ( + services.length > 0 ? ( +
+ + + + + + + + + + + + + {services.map((service: ServiceInfo) => ( + + ))} + +
ServiceStatusVersionUptimeMemoryActions
+
+ ) : ( + + No services found. + + ) + ) : ( + + Loading services... + + )} +
+ +
+ ); +}; + +export default ServicesPage; \ No newline at end of file diff --git a/snaptrack-frontend/package-lock.json b/snaptrack-frontend/package-lock.json index 4b16dcb..57f91a0 100644 --- a/snaptrack-frontend/package-lock.json +++ b/snaptrack-frontend/package-lock.json @@ -14,6 +14,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", + "react-toastify": "^11.0.5", "react-tooltip": "^5.28.1", "socket.io-client": "^2.4.0", "uuid": "^11.1.0" @@ -23,6 +24,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-toastify": "^4.0.2", "@types/socket.io-client": "^1.4.36", "eslint": "^9", "eslint-config-next": "15.1.8", @@ -691,6 +693,27 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-toastify": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/react-toastify/-/react-toastify-4.0.2.tgz", + "integrity": "sha512-pHjCstnN0ZgopIWQ9UiWsD9n+HsXs1PnMQC4hIZuSzpDO0lRjigpTuqsUtnBkMbLIg+mGFSAsBjL49SspzoLKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@types/react-transition-group": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/socket.io-client": { "version": "1.4.36", "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.36.tgz", @@ -4114,6 +4137,19 @@ "dev": true, "license": "MIT" }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/react-tooltip": { "version": "5.28.1", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.1.tgz", diff --git a/snaptrack-frontend/package.json b/snaptrack-frontend/package.json index d12ae1a..a8dbbbd 100644 --- a/snaptrack-frontend/package.json +++ b/snaptrack-frontend/package.json @@ -15,6 +15,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", + "react-toastify": "^11.0.5", "react-tooltip": "^5.28.1", "socket.io-client": "^2.4.0", "uuid": "^11.1.0" @@ -24,6 +25,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-toastify": "^4.0.2", "@types/socket.io-client": "^1.4.36", "eslint": "^9", "eslint-config-next": "15.1.8", From e1d26f6ad4035fa417f30f80c53d672b47ffeaad Mon Sep 17 00:00:00 2001 From: maheshbhatiya73 Date: Tue, 3 Jun 2025 02:49:28 +0530 Subject: [PATCH 2/2] backend: add system services monitor and WebSocket handling --- snaptrack-backend/internal/socket/monitor.go | 14 +- snaptrack-backend/internal/socket/services.go | 485 ++++++++++++++++++ snaptrack-backend/internal/socket/socket.go | 66 ++- snaptrack-backend/main.go | 36 +- 4 files changed, 555 insertions(+), 46 deletions(-) create mode 100644 snaptrack-backend/internal/socket/services.go diff --git a/snaptrack-backend/internal/socket/monitor.go b/snaptrack-backend/internal/socket/monitor.go index 39e8b85..72baea1 100644 --- a/snaptrack-backend/internal/socket/monitor.go +++ b/snaptrack-backend/internal/socket/monitor.go @@ -141,7 +141,7 @@ func FetchStats() (*SystemStats, error) { } -func MonitorAndBroadcast(broadcast chan []byte, interval time.Duration) { +func MonitorAndBroadcastSystemStats(broadcast chan []byte, interval time.Duration) { for { stats, err := FetchStats() if err != nil { @@ -150,7 +150,15 @@ func MonitorAndBroadcast(broadcast chan []byte, interval time.Duration) { continue } - data, err := json.Marshal(stats) + payload := struct { + Type string `json:"type"` + Stats *SystemStats `json:"stats"` + }{ + Type: "metrics", + Stats: stats, + } + + data, err := json.Marshal(payload) if err != nil { log.Printf("Error marshalling stats: %v", err) time.Sleep(interval) @@ -160,4 +168,4 @@ func MonitorAndBroadcast(broadcast chan []byte, interval time.Duration) { broadcast <- data time.Sleep(interval) } -} +} \ No newline at end of file diff --git a/snaptrack-backend/internal/socket/services.go b/snaptrack-backend/internal/socket/services.go new file mode 100644 index 0000000..dd3e4b1 --- /dev/null +++ b/snaptrack-backend/internal/socket/services.go @@ -0,0 +1,485 @@ +package socket + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +type ServiceInfo struct { + Name string `json:"name"` + Status string `json:"status"` + Uptime string `json:"uptime"` + Memory string `json:"memory"` + Version string `json:"version"` +} +func MonitorAndBroadcastSystemServices(broadcast chan<- []byte) { + services, err := GetAllSystemServices() + if err != nil { + log.Printf("Error getting services: %v", err) + return + } + + var serviceInfos []ServiceInfo + for _, service := range services { + status, err := GetServiceStatus(service) + if err != nil { + status = "unknown" + } + + uptime, err := GetServiceUptime(service) + if err != nil { + log.Printf("Error getting uptime for %s: %v", service, err) + uptime = "unknown" + } + + memory, err := GetServiceMemoryUsage(service) + if err != nil { + log.Printf("Error getting memory for %s: %v", service, err) + memory = "unknown" + } + + version, err := GetServiceVersion(service) + if err != nil { + log.Printf("Error getting version for %s: %v", service, err) + version = "n/a" // Use "n/a" instead of empty string for clarity + } + + serviceInfos = append(serviceInfos, ServiceInfo{ + Name: service, + Status: status, + Uptime: uptime, + Memory: memory, + Version: version, + }) + } + + data, err := json.Marshal(struct { + Type string `json:"type"` + Services []ServiceInfo `json:"services"` + }{ + Type: "services", + Services: serviceInfos, + }) + if err != nil { + log.Printf("Error marshaling services: %v", err) + return + } + + select { + case broadcast <- data: + log.Println("Sent services data to broadcast channel") + default: + log.Println("Broadcast channel full, skipping services update") + } +} + +func GetAllSystemServices() ([]string, error) { + services := []string{ + // Web servers + "apache2.service", + "httpd.service", // CentOS/RedHat apache service name + "nginx.service", + + // Databases + "mysql.service", + "mariadb.service", + "postgresql.service", + "mongodb.service", + "redis.service", + + // Container runtimes + "docker.service", + "containerd.service", + + // SSH & remote access + "ssh.service", + "sshd.service", // Some distros use sshd.service + + // System utilities + "cron.service", + "crond.service", // CentOS/RedHat cron + + // Mail servers + "postfix.service", + "exim.service", + "dovecot.service", + + // Caches / Message brokers + "memcached.service", + "rabbitmq-server.service", + + // Firewall & security + "firewalld.service", + "ufw.service", + + // Monitoring + "prometheus.service", + "node_exporter.service", + + // Logging + "rsyslog.service", + "syslog.service", + + // Network + "NetworkManager.service", + "network.service", // Older RedHat style + + // Time sync + "ntpd.service", + "chronyd.service", + + // Misc + "bluetooth.service", + "avahi-daemon.service", + } + return services, nil +} + +func GetServiceStatus(serviceName string) (string, error) { + cmd := exec.Command("systemctl", "is-active", serviceName) + output, err := cmd.Output() + if err != nil { + // Instead of returning error, interpret exit code or output and send a friendly message + // Use CombinedOutput to capture stderr (sometimes useful) + combinedOutput, _ := exec.Command("systemctl", "is-active", serviceName).CombinedOutput() + outStr := strings.TrimSpace(string(combinedOutput)) + + // Common outputs for inactive or missing services + switch outStr { + case "inactive", "failed", "unknown", "activating", "deactivating": + return outStr, nil + case "": + return "not available", nil + default: + // If unknown output or error, log but return friendly string + return "not available", nil + } + } + status := strings.TrimSpace(string(output)) + return status, nil +} +func GetServiceUptime(serviceName string) (string, error) { + cmd := exec.Command("systemctl", "show", serviceName, "--property=ActiveEnterTimestamp") + output, err := cmd.Output() + if err != nil { + return "unknown", err + } + timestamp := strings.TrimSpace(strings.Replace(string(output), "ActiveEnterTimestamp=", "", 1)) + + // If empty or n/a, treat as "not running" + if timestamp == "" || timestamp == "n/a" { + return "not running", nil + } + + layouts := []string{ + "Mon 2006-01-02 15:04:05 MST", + "2006-01-02T15:04:05Z07:00", + } + + var startTime time.Time + for _, layout := range layouts { + if t, err := time.Parse(layout, timestamp); err == nil { + startTime = t + break + } + } + + if startTime.IsZero() { + // Instead of returning error, return a default string + return "unknown", nil + } + + duration := time.Since(startTime).Round(time.Second) + return duration.String(), nil +} + +func GetServiceMemoryUsage(serviceName string) (string, error) { + cmd := exec.Command("systemctl", "show", serviceName, "--property=MemoryCurrent") + output, err := cmd.Output() + if err != nil { + return "unknown", err + } + memory := strings.TrimSpace(strings.Replace(string(output), "MemoryCurrent=", "", 1)) + if memory == "[not set]" || memory == "" { + return "0B", nil + } + bytes, err := strconv.ParseUint(memory, 10, 64) + if err != nil { + return "unknown", err + } + units := []string{"B", "KB", "MB", "GB", "TB"} + size := float64(bytes) + unitIndex := 0 + for size >= 1024 && unitIndex < len(units)-1 { + size /= 1024 + unitIndex++ + } + return fmt.Sprintf("%.2f%s", size, units[unitIndex]), nil +} +func GetServiceVersion(serviceName string) (string, error) { + var cmdName string + switch { + case strings.Contains(serviceName, "apache2") || strings.Contains(serviceName, "httpd"): + cmdName = "apache2ctl" // or "httpd" on some distros, but apache2ctl is more universal for version + case strings.Contains(serviceName, "nginx"): + cmdName = "nginx" + case strings.Contains(serviceName, "mysql") || strings.Contains(serviceName, "mariadb"): + cmdName = "mysql" + case strings.Contains(serviceName, "postgresql"): + cmdName = "psql" + case strings.Contains(serviceName, "mongodb"): + cmdName = "mongod" + case strings.Contains(serviceName, "redis"): + cmdName = "redis-server" + case strings.Contains(serviceName, "ssh") || strings.Contains(serviceName, "sshd"): + cmdName = "ssh" + case strings.Contains(serviceName, "docker"): + cmdName = "docker" + case strings.Contains(serviceName, "containerd"): + cmdName = "containerd" + case strings.Contains(serviceName, "cron") || strings.Contains(serviceName, "crond"): + cmdName = "crond" // or "cron" depending on system + case strings.Contains(serviceName, "postfix"): + cmdName = "postfix" + case strings.Contains(serviceName, "exim"): + cmdName = "exim" + case strings.Contains(serviceName, "dovecot"): + cmdName = "dovecot" + case strings.Contains(serviceName, "memcached"): + cmdName = "memcached" + case strings.Contains(serviceName, "rabbitmq"): + cmdName = "rabbitmqctl" + case strings.Contains(serviceName, "firewalld"): + cmdName = "firewalld" + case strings.Contains(serviceName, "ufw"): + cmdName = "ufw" + case strings.Contains(serviceName, "prometheus"): + cmdName = "prometheus" + case strings.Contains(serviceName, "node_exporter"): + cmdName = "node_exporter" + case strings.Contains(serviceName, "rsyslog") || strings.Contains(serviceName, "syslog"): + cmdName = "rsyslogd" + case strings.Contains(serviceName, "NetworkManager"): + cmdName = "nmcli" + case strings.Contains(serviceName, "ntpd"): + cmdName = "ntpd" + case strings.Contains(serviceName, "chronyd"): + cmdName = "chronyd" + case strings.Contains(serviceName, "bluetooth"): + cmdName = "bluetoothctl" + case strings.Contains(serviceName, "avahi"): + cmdName = "avahi-daemon" + default: + return "", errors.New("unsupported service") + } + + // Try to find full path of the command + fullPath, err := exec.LookPath(cmdName) + if err != nil { + // Binary not found in PATH, return empty version but no error + return "", nil + } + + var args []string + switch cmdName { + case "apache2ctl": + args = []string{"-v"} + case "httpd": + args = []string{"-v"} + case "nginx": + args = []string{"-v"} + case "mysql": + args = []string{"--version"} + case "psql": + args = []string{"--version"} + case "mongod": + args = []string{"--version"} + case "redis-server": + args = []string{"--version"} + case "ssh": + args = []string{"-V"} + case "docker": + args = []string{"--version"} + case "containerd": + args = []string{"--version"} + case "crond", "cron": + args = []string{"--version"} + case "postfix": + args = []string{"--version"} + case "exim": + args = []string{"-bV"} + case "dovecot": + args = []string{"--version"} + case "memcached": + args = []string{"-h"} // memcached doesn't have a version flag, -h outputs version info + case "rabbitmqctl": + args = []string{"status"} // version is inside status output + case "firewalld": + args = []string{"--version"} + case "ufw": + args = []string{"version"} + case "prometheus": + args = []string{"--version"} + case "node_exporter": + args = []string{"--version"} + case "rsyslogd": + args = []string{"-v"} + case "nmcli": + args = []string{"--version"} + case "ntpd": + args = []string{"--version"} + case "chronyd": + args = []string{"--version"} + case "bluetoothctl": + args = []string{"--version"} + case "avahi-daemon": + args = []string{"--version"} + default: + // fallback no args + args = []string{} + } + + cmd := exec.Command(fullPath, args...) + + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + + if err != nil { + // Some commands print version info to stderr (e.g., nginx -v, ssh -V) + if len(strings.TrimSpace(output)) == 0 { + return "", err + } + } + + // For rabbitmqctl status, version is inside a longer output, extract line containing "RabbitMQ" + if cmdName == "rabbitmqctl" { + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "RabbitMQ") { + re := regexp.MustCompile(`\d+(\.\d+)+`) + version := re.FindString(line) + if version != "" { + return version, nil + } + } + } + return "", nil + } + + re := regexp.MustCompile(`\d+(\.\d+)+`) + version := re.FindString(output) + if version == "" { + return "", nil + } + + return version, nil +} + + +func StartService(serviceName string) (string, error) { + cmd := exec.Command("systemctl", "start", serviceName) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func StopService(serviceName string) (string, error) { + cmd := exec.Command("systemctl", "stop", serviceName) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func RestartService(serviceName string) (string, error) { + cmd := exec.Command("systemctl", "restart", serviceName) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func GetServiceLogs(serviceName string) (string, error) { + cmd := exec.Command("journalctl", "-u", serviceName, "-n", "50", "--no-pager") + output, err := cmd.CombinedOutput() + return string(output), err +} + +func ServicesActions(conn *websocket.Conn, action, serviceName string) { + var response struct { + Type string `json:"type"` + Success bool `json:"success"` + Message string `json:"message"` + Service string `json:"service"` + } + + response.Type = "action_response" + response.Service = serviceName + + switch action { + case "start": + output, err := StartService(serviceName) + if err != nil { + response.Success = false + response.Message = "Failed to start service: " + err.Error() + log.Printf("Error starting service %s: %v", serviceName, err) + } else { + response.Success = true + response.Message = "Service started successfully: " + output + } + case "stop": + output, err := StopService(serviceName) + if err != nil { + response.Success = false + response.Message = "Failed to stop service: " + err.Error() + log.Printf("Error stopping service %s: %v", serviceName, err) + } else { + response.Success = true + response.Message = "Service stopped successfully: " + output + } + case "restart": + output, err := RestartService(serviceName) + if err != nil { + response.Success = false + response.Message = "Failed to restart service: " + err.Error() + log.Printf("Error restarting service %s: %v", serviceName, err) + } else { + response.Success = true + response.Message = "Service restarted successfully: " + output + } + case "logs": + logs, err := GetServiceLogs(serviceName) + if err != nil { + response.Success = false + response.Message = "Error getting logs: " + err.Error() + log.Printf("Error getting logs for service %s: %v", serviceName, err) + } else { + logResponse := struct { + Type string `json:"type"` + Service string `json:"service"` + Log string `json:"log"` + }{ + Type: "log", + Service: serviceName, + Log: logs, + } + data, _ := json.Marshal(logResponse) + conn.WriteMessage(websocket.TextMessage, data) + return + } + default: + response.Success = false + response.Message = "Invalid action" + } + + data, err := json.Marshal(response) + if err != nil { + log.Printf("Error marshaling response: %v", err) + return + } + conn.WriteMessage(websocket.TextMessage, data) +} \ No newline at end of file diff --git a/snaptrack-backend/internal/socket/socket.go b/snaptrack-backend/internal/socket/socket.go index c6b752a..3b756cf 100644 --- a/snaptrack-backend/internal/socket/socket.go +++ b/snaptrack-backend/internal/socket/socket.go @@ -5,6 +5,8 @@ import ( "net/http" "sync" "time" + "encoding/json" + "github.com/gorilla/websocket" ) @@ -61,29 +63,45 @@ var upgrader = websocket.Upgrader{ }, } - func StartWebSocketServer() http.Handler { - manager := NewClientManager() - go manager.Start() - - go MonitorAndBroadcast(manager.broadcast, 2*time.Second) + manager := NewClientManager() + go manager.Start() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("WebSocket upgrade error: %v", err) - http.Error(w, "Could not upgrade to WebSocket", http.StatusBadRequest) - return - } - manager.AddClient(conn) - log.Printf("WebSocket connected: %s", conn.RemoteAddr().String()) - - for { - if _, _, err := conn.ReadMessage(); err != nil { - log.Printf("WebSocket disconnected: %s, reason: %v", conn.RemoteAddr().String(), err) - manager.RemoveClient(conn) - break - } - } - }) -} + // Note: Removed the goroutine for MonitorAndBroadcastSystemServices + // It will be called per client connection + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade error: %v", err) + http.Error(w, "Could not upgrade to WebSocket", http.StatusBadRequest) + return + } + manager.AddClient(conn) + log.Printf("WebSocket connected: %s", conn.RemoteAddr().String()) + + // Fetch and send services data once upon connection + go MonitorAndBroadcastSystemStats(manager.broadcast, 2*time.Second) + + go MonitorAndBroadcastSystemServices(manager.broadcast) + + for { + messageType, message, err := conn.ReadMessage() + if err != nil { + log.Printf("WebSocket disconnected: %s, reason: %v", conn.RemoteAddr().String(), err) + manager.RemoveClient(conn) + break + } + // Handle incoming messages (e.g., actions) + if messageType == websocket.TextMessage { + var action struct { + Type string `json:"type"` + Service string `json:"service"` + } + if err := json.Unmarshal(message, &action); err == nil { + ServicesActions(conn, action.Type, action.Service) + } + } + } + }) +} \ No newline at end of file diff --git a/snaptrack-backend/main.go b/snaptrack-backend/main.go index fe0b4c7..fe48388 100644 --- a/snaptrack-backend/main.go +++ b/snaptrack-backend/main.go @@ -4,45 +4,44 @@ import ( "fmt" "log" "net/http" - "snaptrackserver/internal/config" - "snaptrackserver/internal/route" "snaptrackserver/internal/api" - "snaptrackserver/internal/socket" + "snaptrackserver/internal/config" "snaptrackserver/internal/controller" + "snaptrackserver/internal/route" "snaptrackserver/internal/services" - + "snaptrackserver/internal/socket" ) func corsMiddleware(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("Request: %s %s from %s", r.Method, r.URL.Path, r.Header.Get("Origin")) origin := r.Header.Get("Origin") - if origin == "http://localhost:3000" { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE , OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") - w.Header().Set("Content-Type", "application/json") - } else { - log.Printf("CORS rejected: invalid origin %s", origin) + allowedOrigins := []string{"http://localhost:3000", "http://127.0.0.1:3000"} + for _, allowed := range allowedOrigins { + if origin == allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") + w.Header().Set("Content-Type", "application/json") + break + } } - if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } - h.ServeHTTP(w, r) }) } func Start(port int) error { db, err := config.ConnectDB() -if err != nil { - log.Fatalf("Database connection failed: %v", err) -} + if err != nil { + log.Fatalf("Database connection failed: %v", err) + } - controller.SetCollection(db.Collection("backups")) + controller.SetCollection(db.Collection("backups")) addr := fmt.Sprintf(":%d", port) webSocketServer := socket.StartWebSocketServer() @@ -57,7 +56,6 @@ if err != nil { return http.ListenAndServe(addr, corsMiddleware(mux)) } - func main() { if err := Start(8000); err != nil { log.Fatalf("Failed to start server: %v", err)