diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 84e4c11..d9b9bbf 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -11,22 +11,38 @@ interface Product { } function App() { + // view state: 'dashboard' | 'metrics' + const [view, setView] = useState('dashboard'); + + // Dashboard State const [loading, setLoading] = useState(false); const [products, setProducts] = useState([]); const [selectedProduct, setSelectedProduct] = useState(''); const [logs, setLogs] = useState([]); const [latency, setLatency] = useState(null); - const [health, setHealth] = useState<{ order: string }>({ order: 'CHECKING' }); + const [health, setHealth] = useState<{ order: string, inventory: string }>({ order: 'CHECKING', inventory: 'CHECKING' }); + const [avgLatency, setAvgLatency] = useState(0); const addLog = (msg: string) => setLogs(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]); + // Data Fetching const checkHealth = async () => { try { await axios.get(`${API_GATEWAY_URL}/health/orders`); - setHealth({ order: 'UP' }); - } catch (e) { - setHealth({ order: 'DOWN' }); - } + setHealth(prev => ({ ...prev, order: 'UP' })); + } catch { setHealth(prev => ({ ...prev, order: 'DOWN' })); } + + try { + await axios.get(`${API_GATEWAY_URL}/health/inventory`); + setHealth(prev => ({ ...prev, inventory: 'UP' })); + } catch { setHealth(prev => ({ ...prev, inventory: 'DOWN' })); } + }; + + const fetchStats = async () => { + try { + const res = await axios.get(`${API_GATEWAY_URL}/stats/orders`); + setAvgLatency(res.data.averageLatency); + } catch { console.error("Stats fetch failed"); } }; const fetchProducts = async (initializeSelection = false) => { @@ -36,129 +52,80 @@ function App() { if (initializeSelection && res.data.length > 0 && !selectedProduct) { setSelectedProduct(res.data[0].id); } - } catch (e) { - addLog(`⚠️ Failed to fetch products: ${e}`); - } + } catch (e) { addLog(`⚠️ Failed to fetch products: ${e}`); } }; + // Initial Load & Polling useEffect(() => { - checkHealth(); - fetchProducts(true); + const init = async () => { + await checkHealth(); + await fetchProducts(true); + fetchStats(); + }; + init(); + const interval = setInterval(() => { checkHealth(); - fetchProducts(false); // Refresh stock levels without resetting selection - }, 5000); - return () => clearInterval(interval); - }, []); + fetchProducts(false); + fetchStats(); + }, 5000); // 5s poll for health/stock - const [avgLatency, setAvgLatency] = useState(0); + const statsInterval = setInterval(fetchStats, 2000); // 2s poll for smooth stats - useEffect(() => { - const statsInterval = setInterval(async () => { - try { - const res = await axios.get(`${API_GATEWAY_URL}/stats/orders`); - setAvgLatency(res.data.averageLatency); - } catch (e) { - console.error("Failed to fetch stats"); - } - }, 2000); - return () => clearInterval(statsInterval); + return () => { clearInterval(interval); clearInterval(statsInterval); }; }, []); + // Async Order Polling const [queuedOrderIds, setQueuedOrderIds] = useState([]); - useEffect(() => { if (queuedOrderIds.length === 0) return; - const pollInterval = setInterval(async () => { try { - const res = await axios.get(`${API_GATEWAY_URL}/orders`); + const res = await axios.get(`${API_GATEWAY_URL}/orders`); // In prod, use specific ID endpoint const orders = res.data; - - // Check status of all queued orders - const remainingQueuedIds = queuedOrderIds.filter(id => { + const remaining = queuedOrderIds.filter(id => { const order = orders.find((o: any) => o.id === id); - if (order && order.status === 'COMPLETED') { - addLog(`✅ Async Order Completed! ID: ${id}`); - fetchProducts(); // Refresh stock - return false; // Remove from queued list + if (order?.status === 'COMPLETED') { + addLog(`✅ Async Order Completed! ID: ${id.substring(0, 8)}...`); + fetchProducts(); + return false; } - if (order && order.status === 'FAILED') { - addLog(`❌ Async Order Failed! ID: ${id}`); - return false; // Remove from queued list - } - return true; // Keep polling + if (order?.status === 'FAILED') return false; + return true; }); - - if (remainingQueuedIds.length !== queuedOrderIds.length) { - setQueuedOrderIds(remainingQueuedIds); - } - - } catch (e) { - console.error("Polling error", e); - } + if (remaining.length !== queuedOrderIds.length) setQueuedOrderIds(remaining); + } catch (e) { console.error("Polling error", e); } }, 2000); - return () => clearInterval(pollInterval); }, [queuedOrderIds]); + // Action const placeOrder = async (isGremlin: boolean) => { - if (!selectedProduct) { - addLog("⚠️ No product selected!"); - return; - } - + if (!selectedProduct) return; setLoading(true); const start = performance.now(); - addLog(`Initiating Order... (Product: ${products.find(p => p.id === selectedProduct)?.name}, Gremlin: ${isGremlin ? 'ON' : 'OFF'})`); + addLog(`Initiating Order... (Gremlin: ${isGremlin ? 'ON' : 'OFF'})`); try { - // Send 'gremlin' flag to trigger latency in Inventory Service - const quantity = 1; - const response = await axios.post(`${API_GATEWAY_URL}/orders`, { + const res = await axios.post(`${API_GATEWAY_URL}/orders`, { productId: selectedProduct, - quantity, + quantity: 1, gremlin: isGremlin }); - - const end = performance.now(); - const dur = Math.round(end - start); + const dur = Math.round(performance.now() - start); setLatency(dur); - if (response.status === 202) { - // QUEUED - addLog(`⚠️ Order Queued: ${response.data.message}. Duration: ${dur}ms`); - - // Poll for completion - const orderId = response.data.id; - const pollInterval = setInterval(async () => { - try { - const pollRes = await axios.get(`${API_GATEWAY_URL}/orders`); - // Ideally we'd have a specific GET /orders/:id endpoint, but filtering list works for demo - const myOrder = pollRes.data.find((o: any) => o.id === orderId); - if (myOrder && myOrder.status === 'COMPLETED') { - addLog(`✅ Async Order Completed! ID: ${orderId}`); - clearInterval(pollInterval); - fetchProducts(); - } - } catch (e) { - console.error("Polling error", e); - } - }, 2000); - + if (res.status === 202) { + addLog(`⚠️ Order Queued (Timeout). Duration: ${dur}ms`); + setQueuedOrderIds(prev => [...prev, res.data.id]); } else { - // SUCCESS - addLog(`✅ Order Success! ID: ${response.data.id}. Duration: ${dur}ms`); - fetchProducts(); // Update stock immediately + addLog(`✅ Order Success! Duration: ${dur}ms`); + fetchProducts(); } - } catch (error: any) { - const end = performance.now(); - const dur = Math.round(end - start); + const dur = Math.round(performance.now() - start); setLatency(dur); - - const errMsg = error.response?.data?.error || error.message; - addLog(`❌ Order Failed: ${errMsg}. Duration: ${dur}ms`); + addLog(`❌ Failed: ${error.response?.data?.error || error.message}`); } finally { setLoading(false); } @@ -166,78 +133,178 @@ function App() { return ( <> -

Valerix

- -
-
- Order Service: {health.order} -
-
0.1 ? 'var(--danger)' : 'var(--success)', - color: 'white', - transition: 'background-color 0.3s' - }}> - Avg Latency (30s): {avgLatency.toFixed(2)}s +
+

Valerix

+
+ + + + +
-
-

Order Simulation

-

- Select a product and test resilience patterns. -

- -
- - -
+ {view === 'dashboard' ? ( + <> +
+
+ Order Service +
+ + {health.order} +
+
+
+ Inventory Service +
+ + {health.inventory} +
+
+
0.1 ? 'var(--danger)' : 'var(--card-border)' }}> + Avg Latency (30s) + 0.1 ? 'var(--danger)' : 'var(--success)' }}> + {avgLatency.toFixed(3)}s + +
+
-
2000 ? '#e3b341' : latency > 1500 ? 'var(--danger)' : 'var(--success)') : 'inherit' - }}> - {latency !== null ? (latency > 2000 ? 'QUEUED' : `${latency}ms`) : '---'} -
- {latency !== null && latency > 2000 ? 'Processed in Background' : 'Last Request Latency'} +
+

Order Simulation

+
+ + +
+ +
+
2000 ? '#eab308' : latency > 1000 ? '#ef4444' : '#22c55e') : '#2f2f35' + }}> + {latency !== null ? `${latency}ms` : '---'} +
+
Request Latency
+
+ +
+ + +
-
-
- - +
+

System Activity

+ {logs.map((log, i) =>
{log}
)} +
+ + ) : ( + + )} + + ); +} + +const MetricsPage = () => { + const [orderMetrics, setOrderMetrics] = useState(''); + const [inventoryMetrics, setInventoryMetrics] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetch = async () => { + try { + const [res1, res2] = await Promise.all([ + axios.get(`${API_GATEWAY_URL}/metrics/orders`), + axios.get(`${API_GATEWAY_URL}/metrics/inventory`) + ]); + setOrderMetrics(res1.data); + setInventoryMetrics(res2.data); + } catch (e: any) { + console.error("Metrics fetch failed", e); + } finally { + setLoading(false); + } + }; + fetch(); + const interval = setInterval(fetch, 5000); + return () => clearInterval(interval); + }, []); + + return ( +
+

Service Health & Metrics

+ {loading &&

Loading metrics...

} + + + +
+ ); +}; + +const ServiceMetricsViewer = ({ name, rawData }: { name: string, rawData: string }) => { + const [showRaw, setShowRaw] = useState(false); + + // Simple parsing helpers + const getValue = (key: string) => { + const match = rawData.match(new RegExp(`${key} ([0-9.]+)`)); + return match ? parseFloat(match[1]) : 0; + }; + + const cpu = getValue('process_cpu_user_seconds_total'); + const memory = getValue('process_resident_memory_bytes'); + const heap = getValue('nodejs_heap_size_used_bytes'); + const uptime = getValue('process_uptime_seconds'); + const handles = getValue('nodejs_active_handles'); + const lag = getValue('nodejs_eventloop_lag_seconds'); + + const formatBytes = (bytes: number) => (bytes / 1024 / 1024).toFixed(1); + + return ( +
+
+

{name}

+
+ + UP {Math.floor(uptime / 60)}m
-
-

System Logs

- {logs.map((log, i) =>
{log}
)} +
+
+ Memory (RSS) +
{formatBytes(memory)}MB
+
+
+ Heap Used +
{formatBytes(heap)}MB
+
+
+ CPU Used +
{cpu.toFixed(2)}s
+
+
+ Active Handles +
{handles}
+
+
+ Event Loop Lag +
{lag.toFixed(4)}s
+
- - ) -} + + + + {showRaw &&
{rawData}
} +
+ ); +}; export default App diff --git a/services/frontend/src/index.css b/services/frontend/src/index.css index 8ea1683..c8edcda 100644 --- a/services/frontend/src/index.css +++ b/services/frontend/src/index.css @@ -1,94 +1,346 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + :root { - --bg-color: #0d1117; - --text-color: #e6edf3; - --card-bg: #161b22; - --primary: #238636; - --danger: #da3633; - --warning: #d29922; - --success: #238636; - --border: #30363d; - font-family: 'Inter', system-ui, sans-serif; + --bg-color: #09090b; + --card-bg: #18181b; + --card-border: #27272a; + --primary: #3b82f6; + --primary-hover: #2563eb; + --danger: #ef4444; + --danger-hover: #dc2626; + --success: #22c55e; + --warning: #eab308; + --text-primary: #f4f4f5; + --text-secondary: #a1a1aa; + --font-family: 'Inter', sans-serif; +} + +* { + box-sizing: border-box; } body { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; background-color: var(--bg-color); - color: var(--text-color); - line-height: 1.6; + color: var(--text-primary); + font-family: var(--font-family); + -webkit-font-smoothing: antialiased; + min-height: 100vh; + display: flex; + flex-direction: column; } #root { - max-width: 1280px; + width: 100%; + max-width: 1200px; margin: 0 auto; padding: 2rem; - text-align: center; - width: 100%; + flex: 1; +} + +/* Typography */ +h1, +h2, +h3 { + margin: 0; + font-weight: 600; + letter-spacing: -0.025em; } h1 { - font-size: 3.2em; - line-height: 1.1; - background: linear-gradient(90deg, #58a6ff, #a371f7); + font-size: 2rem; + background: linear-gradient(to right, #fff, #a1a1aa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} + +h2 { + font-size: 1.25rem; + margin-bottom: 1rem; +} + +h3 { + font-size: 1rem; + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 0.5rem; +} + +/* Layout & Components */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 3rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--card-border); +} + +.nav-buttons { + display: flex; + gap: 1rem; +} + +.nav-btn { + background: transparent; + border: 1px solid var(--card-border); + color: var(--text-secondary); + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.nav-btn.active { + background: var(--card-bg); + color: var(--text-primary); + border-color: var(--primary); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; margin-bottom: 2rem; } -.card { - background-color: var(--card-bg); - padding: 2em; +.stat-card { + background: var(--card-bg); + border: 1px solid var(--card-border); border-radius: 12px; - border: 1px solid var(--border); - margin-top: 2rem; - box-shadow: 0 4px 6px rgba(0,0,0,0.3); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.main-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 16px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); } +/* Form Elements */ button { + cursor: pointer; border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; + border: none; + padding: 0.75rem 1.5rem; font-weight: 500; + transition: all 0.2s; font-family: inherit; +} + +.btn-primary { background-color: var(--primary); color: white; - cursor: pointer; - transition: border-color 0.25s; - margin: 0.5rem; } -button:hover { - filter: brightness(1.1); +.btn-primary:hover { + background-color: var(--primary-hover); } -button.danger { +.btn-danger { + background-color: var(--card-border); + /* Subtle by default */ + color: var(--danger); + border: 1px solid var(--card-border); +} + +.btn-danger:hover { background-color: var(--danger); + color: white; + border-color: var(--danger); } -.status-badge { - display: inline-block; - padding: 0.2em 0.8em; - border-radius: 4px; - font-weight: bold; - font-size: 0.8em; +select { + width: 100%; + background-color: var(--bg-color); + border: 1px solid var(--card-border); + color: var(--text-primary); + padding: 0.75rem; + border-radius: 8px; + font-family: inherit; + font-size: 1rem; + outline: none; +} + +select:focus { + border-color: var(--primary); +} + +/* Logs */ +.logs-panel { + background: #000; + border-radius: 12px; + padding: 1.5rem; + border: 1px solid var(--card-border); + font-family: 'JetBrains Mono', monospace; + font-size: 0.875rem; + height: 300px; + overflow-y: auto; +} + +.log-entry { + padding: 4px 0; + border-bottom: 1px solid #1a1a1a; + color: var(--text-secondary); +} + +/* Badges */ +.status-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.875rem; + font-weight: 500; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.dot.green { + background-color: var(--success); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); +} + +.dot.red { + background-color: var(--danger); + box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); +} + +.dot.yellow { + background-color: var(--warning); +} + +/* Latency Display */ +.latency-display { + text-align: center; + margin: 2rem 0; +} + +.latency-value { + font-size: 3rem; + font-weight: 800; + line-height: 1; +} + +.latency-label { + color: var(--text-secondary); + font-size: 0.875rem; + margin-top: 0.5rem; +} + +/* Metrics Dashboard Enhancements */ +.metrics-section { + margin-bottom: 3rem; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 12px; + padding: 1.5rem; +} + +.metrics-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--card-border); +} + +.key-metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.metric-item { + background: linear-gradient(145deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid var(--card-border); + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + flex-direction: column; + justify-content: center; +} + +.metric-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + border-color: var(--primary); +} + +.metric-item .label { + font-size: 0.75rem; + color: var(--text-secondary); text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; + display: block; + font-weight: 600; } -.PENDING { background-color: var(--warning); color: black; } -.CONFIRMED { background-color: var(--success); color: white; } -.FAILED { background-color: var(--danger); color: white; } +.metric-item .value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + letter-spacing: -0.02em; +} -.log-container { - text-align: left; - max-height: 300px; - overflow-y: auto; - background: #000; - padding: 1rem; - border-radius: 8px; - font-family: monospace; - margin-top: 2rem; +.metric-item .unit { + font-size: 0.875rem; + color: var(--text-secondary); + margin-left: 0.25rem; + font-weight: 400; } + +.raw-toggle { + background: transparent; + color: var(--primary); + border: 1px dashed var(--card-border); + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + display: inline-block; + margin-top: 1rem; +} + +.raw-toggle:hover { + background: rgba(59, 130, 246, 0.1); + border-color: var(--primary); +} + +.metric-box { + background: #09090b; + padding: 1.5rem; + border-radius: 8px; + border: 1px solid var(--card-border); + font-family: 'JetBrains Mono', monospace; + white-space: pre-wrap; + font-size: 0.8rem; + color: #a1a1aa; + max-height: 400px; + overflow: auto; + margin-top: 1.5rem; + line-height: 1.5; +} \ No newline at end of file