From c6471c2b5d8b0708494d97897251436f058df11e Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 22 Dec 2025 16:31:02 +0100 Subject: [PATCH 1/5] feat(traffic): Add traffic dashboard and monitoring - Add TrafficDashboard component with stats, charts, and logs - Add DeploymentTrafficStats for per-deployment traffic metrics - Add traffic store for state management - Add traffic API types and endpoints - Integrate Traffic tab into SecurityView --- src/components/DeploymentTrafficStats.vue | 411 ++++++++++ src/components/TrafficDashboard.vue | 912 ++++++++++++++++++++++ src/services/api.ts | 109 ++- src/stores/traffic.ts | 86 ++ src/views/SecurityView.vue | 53 +- 5 files changed, 1567 insertions(+), 4 deletions(-) create mode 100644 src/components/DeploymentTrafficStats.vue create mode 100644 src/components/TrafficDashboard.vue create mode 100644 src/stores/traffic.ts diff --git a/src/components/DeploymentTrafficStats.vue b/src/components/DeploymentTrafficStats.vue new file mode 100644 index 0000000..0e54773 --- /dev/null +++ b/src/components/DeploymentTrafficStats.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/src/components/TrafficDashboard.vue b/src/components/TrafficDashboard.vue new file mode 100644 index 0000000..2b0d4c4 --- /dev/null +++ b/src/components/TrafficDashboard.vue @@ -0,0 +1,912 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index 6cd9f8f..a875188 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -408,6 +408,11 @@ export const databasesApi = { database, }), listUsers: (config: DatabaseConnectionConfig) => apiClient.post<{ users: UserInfo[] }>("/databases/users", config), + listUsersByDatabase: (config: DatabaseConnectionConfig, database: string) => + apiClient.post<{ users: UserInfo[] }>("/databases/users/by-database", { + ...config, + database, + }), createDatabase: (config: DatabaseConnectionConfig, dbName: string) => apiClient.post("/databases/create", { ...config, db_name: dbName }), createUser: (config: DatabaseConnectionConfig, username: string, password: string, host?: string) => @@ -601,7 +606,14 @@ export const securityApi = { getDeploymentSecurity: (name: string) => apiClient.get<{ security: DeploymentSecurityConfig }>(`/deployments/${name}/security`), updateDeploymentSecurity: (name: string, config: DeploymentSecurityConfig) => - apiClient.put<{ security: DeploymentSecurityConfig }>(`/deployments/${name}/security`, config), + apiClient.put<{ + security: DeploymentSecurityConfig; + hook_status?: { + has_hook: boolean; + hook_in_locations: boolean; + vhost_regenerated: boolean; + }; + }>(`/deployments/${name}/security`, config), getDeploymentEvents: (name: string, limit?: number) => apiClient.get<{ events: SecurityEvent[]; total: number; deployment: string }>( `/deployments/${name}/security/events`, @@ -616,8 +628,18 @@ export const securityApi = { apiClient.put<{ realtime_capture: boolean; message: string }>("/security/realtime-capture", { enabled }), getHealth: () => apiClient.get("/security/health"), + + refreshScripts: () => apiClient.post("/security/refresh"), }; +export interface SecurityRefreshResponse { + success: boolean; + agent_ip: string; + vhosts_updated: string[]; + nginx_reloaded: boolean; + error?: string; +} + export interface SecurityHealthCheck { status: "healthy" | "degraded" | "broken" | "disabled"; error?: string; @@ -626,3 +648,88 @@ export interface SecurityHealthCheck { recommendations: string[]; details?: Record; } + +export interface TrafficLog { + id: number; + deployment_name: string; + request_path: string; + request_method: string; + status_code: number; + source_ip: string; + response_time_ms: number; + bytes_sent: number; + request_length: number; + upstream_time_ms?: number; + created_at: string; +} + +export interface TrafficFilter { + deployment?: string; + method?: string; + status_code?: number; + status_group?: string; + source_ip?: string; + path?: string; + start_time?: string; + end_time?: string; + limit?: number; + offset?: number; +} + +export interface PathStats { + path: string; + request_count: number; + avg_time_ms: number; + error_count: number; +} + +export interface IPTrafficStats { + ip: string; + request_count: number; + bytes_sent: number; + last_seen: string; +} + +export interface HourlyStats { + hour: string; + request_count: number; +} + +export interface DeploymentTrafficStats { + name: string; + total_requests: number; + avg_response_time: number; + status_2xx: number; + status_3xx: number; + status_4xx: number; + status_5xx: number; + error_rate: number; +} + +export interface TrafficStats { + total_requests: number; + total_bytes: number; + avg_response_time_ms: number; + by_status_group: Record; + by_deployment: Record; + by_method: Record; + top_paths: PathStats[]; + top_ips: IPTrafficStats[]; + requests_per_hour: HourlyStats[]; + deployment_stats: DeploymentTrafficStats[]; +} + +export const trafficApi = { + getLogs: (params?: TrafficFilter) => + apiClient.get<{ logs: TrafficLog[]; total: number; limit: number; offset: number }>("/traffic/logs", { params }), + + getStats: (params?: { deployment?: string; since?: string }) => + apiClient.get<{ stats: TrafficStats }>("/traffic/stats", { params }), + + cleanup: (days?: number) => apiClient.post<{ deleted: number }>("/traffic/cleanup", { days }), + + getDeploymentStats: (name: string, since?: string) => + apiClient.get<{ deployment: string; stats: TrafficStats }>(`/deployments/${name}/traffic/stats`, { + params: since ? { since } : undefined, + }), +}; diff --git a/src/stores/traffic.ts b/src/stores/traffic.ts new file mode 100644 index 0000000..0606968 --- /dev/null +++ b/src/stores/traffic.ts @@ -0,0 +1,86 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { trafficApi, type TrafficLog, type TrafficStats, type TrafficFilter } from "@/services/api"; + +export const useTrafficStore = defineStore("traffic", () => { + const logs = ref([]); + const logsTotal = ref(0); + const stats = ref(null); + const loading = ref(false); + const error = ref(null); + + async function fetchLogs(params?: TrafficFilter) { + loading.value = true; + error.value = null; + try { + const response = await trafficApi.getLogs(params); + logs.value = response.data.logs || []; + logsTotal.value = response.data.total || 0; + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + } finally { + loading.value = false; + } + } + + async function fetchStats(params?: { deployment?: string; since?: string }) { + loading.value = true; + error.value = null; + try { + const response = await trafficApi.getStats(params); + stats.value = response.data.stats; + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + } finally { + loading.value = false; + } + } + + async function fetchDeploymentStats(name: string, since?: string) { + loading.value = true; + error.value = null; + try { + const response = await trafficApi.getDeploymentStats(name, since); + stats.value = response.data.stats; + return response.data; + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + throw e; + } finally { + loading.value = false; + } + } + + async function cleanup(days?: number) { + try { + const response = await trafficApi.cleanup(days); + return response.data; + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + throw e; + } + } + + function clearLogs() { + logs.value = []; + logsTotal.value = 0; + } + + function clearStats() { + stats.value = null; + } + + return { + logs, + logsTotal, + stats, + loading, + error, + fetchLogs, + fetchStats, + fetchDeploymentStats, + cleanup, + clearLogs, + clearStats, + }; +}); diff --git a/src/views/SecurityView.vue b/src/views/SecurityView.vue index 4ff1d32..1834995 100644 --- a/src/views/SecurityView.vue +++ b/src/views/SecurityView.vue @@ -214,6 +214,11 @@ + +
+ +
+
@@ -425,6 +430,13 @@
+
+ + Regenerates Lua scripts with current agent IP and reloads nginx +
@@ -610,6 +622,7 @@ import { useSecurityStore } from "@/stores/security"; import { useNotificationsStore } from "@/stores/notifications"; import type { ProtectedRoute } from "@/types"; import SecurityHealthCard from "@/components/SecurityHealthCard.vue"; +import TrafficDashboard from "@/components/TrafficDashboard.vue"; const securityStore = useSecurityStore(); const notifications = useNotificationsStore(); @@ -618,10 +631,11 @@ const loading = ref(false); const activeTab = ref("stats"); const tabs = [ - { id: "stats", label: "Dashboard", icon: "pi pi-chart-bar" }, - { id: "events", label: "Events", icon: "pi pi-list" }, + { id: "stats", label: "Overview", icon: "pi pi-chart-bar" }, + { id: "traffic", label: "Traffic & Performance", icon: "pi pi-chart-line" }, + { id: "events", label: "Security Events", icon: "pi pi-list" }, { id: "blocked", label: "Blocked IPs", icon: "pi pi-ban" }, - { id: "routes", label: "Protected Routes", icon: "pi pi-lock" }, + { id: "routes", label: "Rate Limits", icon: "pi pi-lock" }, { id: "health", label: "Health", icon: "pi pi-heart" }, { id: "settings", label: "Settings", icon: "pi pi-cog" }, ]; @@ -629,6 +643,7 @@ const tabs = [ const { stats, events, eventsTotal, blockedIPs, protectedRoutes, securityEnabled, realtimeCapture } = storeToRefs(securityStore); const togglingRealtimeCapture = ref(false); +const refreshingScripts = ref(false); const filters = reactive({ severity: "", @@ -719,6 +734,23 @@ const toggleRealtimeCapture = async () => { } }; +const refreshSecurityScripts = async () => { + refreshingScripts.value = true; + try { + const result = await securityStore.refreshScripts(); + const vhostCount = result.vhosts_updated?.length || 0; + notifications.success( + "Security Scripts Regenerated", + `Agent IP: ${result.agent_ip}, ${vhostCount} vhost(s) updated`, + ); + } catch (e: any) { + const errorMsg = e.response?.data?.error || "Failed to refresh security scripts"; + notifications.error("Failed", errorMsg); + } finally { + refreshingScripts.value = false; + } +}; + const clearFilters = () => { filters.severity = ""; filters.event_type = ""; @@ -989,6 +1021,21 @@ onMounted(() => { gap: 1.25rem; } +.health-actions { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: white; + border-radius: 12px; + border: 1px solid #e5e7eb; +} + +.health-actions-hint { + font-size: 0.8125rem; + color: #6b7280; +} + .stats-row { display: grid; grid-template-columns: repeat(4, 1fr); From b73acb3bd71a17bd7f1b6fe247a052fa92802b79 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 22 Dec 2025 16:31:36 +0100 Subject: [PATCH 2/5] feat(ui): Add resilient request handling for service restarts - Add resilientRequest utility with exponential backoff - Add executeWithServiceRestart for critical service operations - Update InfrastructureView to handle nginx restart gracefully - Replace custom logs modal with LogsModal component --- src/utils/resilientRequest.ts | 84 ++++++++++++++++ src/views/InfrastructureView.vue | 162 +++++++++++++------------------ 2 files changed, 152 insertions(+), 94 deletions(-) create mode 100644 src/utils/resilientRequest.ts diff --git a/src/utils/resilientRequest.ts b/src/utils/resilientRequest.ts new file mode 100644 index 0000000..3ace6da --- /dev/null +++ b/src/utils/resilientRequest.ts @@ -0,0 +1,84 @@ +import type { AxiosError } from "axios"; + +export interface ResilientRequestOptions { + maxRetries?: number; + initialDelay?: number; + maxDelay?: number; + onRetry?: (attempt: number, delay: number) => void; + onWaiting?: (isWaiting: boolean) => void; +} + +function isNetworkError(error: AxiosError): boolean { + return ( + !error.response && + (error.code === "ERR_NETWORK" || + error.code === "ECONNREFUSED" || + error.code === "ECONNABORTED" || + error.message?.includes("Network Error")) + ); +} + +export async function resilientRequest( + requestFn: () => Promise, + options: ResilientRequestOptions = {}, +): Promise { + const { maxRetries = 10, initialDelay = 1000, maxDelay = 5000, onRetry, onWaiting } = options; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + onWaiting?.(false); + } + return await requestFn(); + } catch (error) { + lastError = error as Error; + const axiosError = error as AxiosError; + + if (!isNetworkError(axiosError) || attempt >= maxRetries) { + onWaiting?.(false); + throw error; + } + + const delay = Math.min(initialDelay * Math.pow(1.5, attempt), maxDelay); + onWaiting?.(true); + onRetry?.(attempt + 1, delay); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +export async function executeWithServiceRestart( + actionFn: () => Promise, + verifyFn: () => Promise, + options: ResilientRequestOptions & { actionTimeout?: number } = {}, +): Promise<{ result: T | null; serviceRestarted: boolean }> { + const { actionTimeout = 30000, ...retryOptions } = options; + + let result: T | null = null; + let serviceRestarted = false; + + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("Action timeout")), actionTimeout), + ); + result = await Promise.race([actionFn(), timeoutPromise]); + } catch (error) { + const axiosError = error as AxiosError; + if (isNetworkError(axiosError)) { + serviceRestarted = true; + } else { + throw error; + } + } + + if (serviceRestarted) { + await resilientRequest(verifyFn, retryOptions); + } + + return { result, serviceRestarted }; +} diff --git a/src/views/InfrastructureView.vue b/src/views/InfrastructureView.vue index 900ee06..3f9928f 100644 --- a/src/views/InfrastructureView.vue +++ b/src/views/InfrastructureView.vue @@ -97,24 +97,15 @@
- - +
@@ -123,7 +114,9 @@ import { ref, onMounted } from "vue"; import { useRouter } from "vue-router"; import { infrastructureApi, type InfraService } from "@/services/api"; import { useNotificationsStore } from "@/stores/notifications"; +import { executeWithServiceRestart } from "@/utils/resilientRequest"; import DeploymentCard from "@/components/DeploymentCard.vue"; +import LogsModal from "@/components/LogsModal.vue"; const router = useRouter(); const notifications = useNotificationsStore(); @@ -150,6 +143,10 @@ const fetchServices = async () => { } }; +const isCriticalService = (name: string) => { + return name.toLowerCase().includes("nginx") || name.toLowerCase().includes("proxy"); +}; + const startService = async (name: string) => { actionLoading.value = name; try { @@ -166,8 +163,27 @@ const startService = async (name: string) => { const stopService = async (name: string) => { actionLoading.value = name; try { - await infrastructureApi.stop(name); - notifications.success("Service Stopped", `${name} has been stopped`); + if (isCriticalService(name)) { + const { serviceRestarted } = await executeWithServiceRestart( + () => infrastructureApi.stop(name), + () => infrastructureApi.list(), + { + onWaiting: (waiting) => { + if (waiting) { + notifications.info("Waiting", `Waiting for API to become available...`); + } + }, + }, + ); + if (serviceRestarted) { + notifications.success("Service Stopped", `${name} has been stopped (reconnected to API)`); + } else { + notifications.success("Service Stopped", `${name} has been stopped`); + } + } else { + await infrastructureApi.stop(name); + notifications.success("Service Stopped", `${name} has been stopped`); + } await fetchServices(); } catch (e: any) { notifications.error("Error", `Failed to stop ${name}`); @@ -179,8 +195,27 @@ const stopService = async (name: string) => { const restartService = async (name: string) => { actionLoading.value = name; try { - await infrastructureApi.restart(name); - notifications.success("Service Restarted", `${name} has been restarted`); + if (isCriticalService(name)) { + const { serviceRestarted } = await executeWithServiceRestart( + () => infrastructureApi.restart(name), + () => infrastructureApi.list(), + { + onWaiting: (waiting) => { + if (waiting) { + notifications.info("Reconnecting", `Service restarting, waiting for API...`); + } + }, + }, + ); + if (serviceRestarted) { + notifications.success("Service Restarted", `${name} restarted successfully`); + } else { + notifications.success("Service Restarted", `${name} has been restarted`); + } + } else { + await infrastructureApi.restart(name); + notifications.success("Service Restarted", `${name} has been restarted`); + } await fetchServices(); } catch (e: any) { notifications.error("Error", `Failed to restart ${name}`); @@ -196,6 +231,11 @@ const showLogs = async (name: string) => { content: "", loading: true, }; + await fetchLogs(name); +}; + +const fetchLogs = async (name: string) => { + logsModal.value.loading = true; try { const response = await infrastructureApi.logs(name, 200); logsModal.value.content = response.data.logs || "No logs available"; @@ -206,6 +246,12 @@ const showLogs = async (name: string) => { } }; +const refreshLogs = () => { + if (logsModal.value.serviceName) { + fetchLogs(logsModal.value.serviceName); + } +}; + const closeLogs = () => { logsModal.value.visible = false; }; @@ -455,78 +501,6 @@ onMounted(() => { cursor: not-allowed; } -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-content { - background: white; - border-radius: 16px; - max-width: 800px; - width: 90%; - max-height: 80vh; - display: flex; - flex-direction: column; -} - -.logs-modal { - max-width: 900px; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 1.25rem; - border-bottom: 1px solid #e5e7eb; -} - -.modal-header h3 { - font-size: 1rem; - font-weight: 600; - color: #1f2937; - margin: 0; -} - -.modal-body { - flex: 1; - overflow: auto; - padding: 1.25rem; -} - -.logs-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - padding: 2rem; - color: #6b7280; -} - -.logs-content { - background: #1f2937; - color: #d1d5db; - padding: 1rem; - border-radius: 8px; - font-family: "SF Mono", "Fira Code", monospace; - font-size: 0.75rem; - line-height: 1.6; - white-space: pre-wrap; - word-wrap: break-word; - margin: 0; - max-height: 60vh; - overflow: auto; -} - @media (max-width: 768px) { .services-grid { grid-template-columns: 1fr; From 5df62363c6a2efe9a67f68d9d19edbac6559763c Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 22 Dec 2025 16:32:14 +0100 Subject: [PATCH 3/5] fix(ui): Improve modal styling for log viewer - Add noPadding prop to BaseModal for full-bleed content - Remove border-radius from LogViewer (parent controls styling) - Update LogsModal to use noPadding for seamless log display - Fix output section styling in OperationModal --- src/components/LogViewer.vue | 1 - src/components/LogsModal.vue | 11 ++++++++++- src/components/OperationModal.vue | 2 ++ src/components/base/BaseModal.vue | 14 ++++++++++++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/components/LogViewer.vue b/src/components/LogViewer.vue index aeefd1f..3f19ba1 100644 --- a/src/components/LogViewer.vue +++ b/src/components/LogViewer.vue @@ -322,7 +322,6 @@ onUnmounted(() => { flex-direction: column; height: 100%; background: var(--color-gray-950); - border-radius: var(--radius-md); overflow: hidden; position: relative; } diff --git a/src/components/LogsModal.vue b/src/components/LogsModal.vue index 955c32a..99788e9 100644 --- a/src/components/LogsModal.vue +++ b/src/components/LogsModal.vue @@ -1,5 +1,13 @@