adeem6 commited on
Commit
1ad4eaa
·
verified ·
1 Parent(s): b2512e2

Upload 17 files

Browse files
.gitattributes CHANGED
@@ -40,3 +40,5 @@ backend/outputs/eval/scene_c_dense.mp4 filter=lfs diff=lfs merge=lfs -text
40
  backend/outputs/eval/scene_d_escalating.mp4 filter=lfs diff=lfs merge=lfs -text
41
  backend/outputs/hajjflow_rt.db filter=lfs diff=lfs merge=lfs -text
42
  backend/outputs/plots/eval_risk.png filter=lfs diff=lfs merge=lfs -text
 
 
 
40
  backend/outputs/eval/scene_d_escalating.mp4 filter=lfs diff=lfs merge=lfs -text
41
  backend/outputs/hajjflow_rt.db filter=lfs diff=lfs merge=lfs -text
42
  backend/outputs/plots/eval_risk.png filter=lfs diff=lfs merge=lfs -text
43
+ frontend/public/hajj_real_video.mp4 filter=lfs diff=lfs merge=lfs -text
44
+ frontend/public/hajj_video_h264.mp4 filter=lfs diff=lfs merge=lfs -text
frontend/STATE_REFERENCE.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HaramGuard Realtime State Reference
2
+
3
+ Frontend expects realtime payload from:
4
+
5
+ - `${VITE_API_BASE_URL}${VITE_STATE_ENDPOINT}`
6
+ - Default: `http://127.0.0.1:8000/api/realtime/state`
7
+
8
+ ## Required Keys
9
+
10
+ | Key | Type | Notes |
11
+ | --- | --- | --- |
12
+ | `frame_id` | `number` | Latest processed frame id |
13
+ | `annotated` | `string \| object \| null` | `data:image/...`, full URL, base64 string, or `{ jpeg_base64 }` |
14
+ | `person_count` | `number` | Current people count |
15
+ | `density_score` | `number` | Density score from perception |
16
+ | `fps` | `number` | Processing FPS |
17
+ | `risk_score` | `number` | Range `0..1` |
18
+ | `risk_level` | `string` | `HIGH`, `MEDIUM`, `LOW` (emoji/text accepted) |
19
+ | `trend` | `string` | `rising`, `stable`, `falling` |
20
+ | `risk_history` | `array` | Array of `{ frame_id, risk_score, risk_level }` |
21
+ | `latest_decision` | `object` | Includes `priority`, `actions`, `timestamp`, `risk_score`, `risk_level` |
22
+ | `decisions_log` | `array` | Last decisions list for table |
23
+ | `arabic_alert` | `string` | Arabic alert line in red panel |
24
+ | `coordinator_plan` | `object` | `threat_level`, `executive_summary`, `immediate_actions`, `confidence_score` |
25
+
26
+ ## Optional Env Vars
27
+
28
+ - `VITE_API_BASE_URL` (default `http://127.0.0.1:8000`)
29
+ - `VITE_STATE_ENDPOINT` (default `/api/realtime/state`)
30
+ - `VITE_DASH_REFRESH_MS` (default `1000`)
31
+ - `VITE_DEMO_VIDEO_URL` (video URL used when `annotated` is unavailable)
32
+
33
+ ---
34
+
35
+ ## New Fields (Agent Stats, Gates, Proposed Actions)
36
+
37
+ | Key | Type | Notes |
38
+ | --- | --- | --- |
39
+ | `agent_stats` | `object` | `{ resolved_alerts: number, pending_decisions: number, urgent_interventions: number }` — shown in AI agent mini-stat cards |
40
+ | `gates` | `array` | Array of `{ id: string, status: "open" \| "partial" \| "closed" }`. Six gates expected. `id` is a display label (e.g. Arabic-Indic numeral like `"١"`). |
41
+ | `proposed_actions` | `array` | Array of `{ id: string, timestamp: string (ISO 8601), priority: "urgent" \| "watch" \| "completed", title: string, description: string }` |
42
+
43
+ ## Action Approval Endpoints
44
+
45
+ | Endpoint | Method | Notes |
46
+ | --- | --- | --- |
47
+ | `/api/actions/{id}/approve` | `POST` | Approve a proposed action. Frontend applies optimistic update instantly. |
48
+ | `/api/actions/{id}/reject` | `POST` | Reject (dismiss) a proposed action. Frontend removes it from the list optimistically. |
frontend/dist/assets/index-DpH2i0wj.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-DwpMV8UQ.css ADDED
@@ -0,0 +1 @@
 
 
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.absolute{position:absolute}.relative{position:relative}.-right-1{right:-.25rem}.-top-1{top:-.25rem}.bottom-4{bottom:1rem}.left-4{left:1rem}.right-4{right:1rem}.col-span-3{grid-column:span 3 / span 3}.col-span-6{grid-column:span 6 / span 6}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mt-2{margin-top:.5rem}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-\[120px\]{height:120px}.h-\[150px\]{height:150px}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[220px\]{max-height:220px}.min-h-0{min-height:0px}.w-10{width:2.5rem}.w-2{width:.5rem}.w-\[120px\]{width:120px}.w-full{width:100%}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r-2{border-right-width:2px}.object-cover{-o-object-fit:cover;object-fit:cover}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-0{padding-bottom:0}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-semibold{font-weight:600}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.tracking-wide{letter-spacing:.025em}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.backdrop-blur{--tw-backdrop-blur: blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}:root{font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif}html,body,#root{height:100%}body{margin:0}.leaflet-container{width:100%;height:100%;z-index:0;background-color:#07130f}.leaflet-popup-content-wrapper{background:#0a1c14f2;color:#e6fff3;border:1px solid rgba(126,245,200,.3);border-radius:12px;box-shadow:0 10px 25px #0006}.leaflet-popup-tip{background:#0a1c14f2}.leaflet-popup-close-button{color:#7ef5c8!important}.leaflet-tooltip{pointer-events:none!important}@media(min-width:768px){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}
frontend/dist/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>HaramGuard Dashboard</title>
7
+ <script type="module" crossorigin src="/assets/index-DpH2i0wj.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DwpMV8UQ.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
frontend/index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>HaramGuard - Dashboard</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700;800;900&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.jsx"></script>
17
+ </body>
18
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "haramguard-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "leaflet": "^1.9.4",
14
+ "lucide-react": "^0.542.0",
15
+ "react": "^18.3.1",
16
+ "react-dom": "^18.3.1",
17
+ "react-leaflet": "^4.2.1",
18
+ "react-router-dom": "^6.30.2"
19
+ },
20
+ "devDependencies": {
21
+ "@vitejs/plugin-react": "^5.0.4",
22
+ "autoprefixer": "^10.4.21",
23
+ "eslint": "^9.36.0",
24
+ "eslint-plugin-react": "^7.37.5",
25
+ "eslint-plugin-react-hooks": "^5.2.0",
26
+ "postcss": "^8.5.6",
27
+ "tailwindcss": "^3.4.17",
28
+ "vite": "^7.1.7"
29
+ }
30
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
frontend/public/hajj_real_video.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ebcb81d8f6790dcb04b8998f479efcdb3177d9d7a180c3f4615bbe61eb9844bc
3
+ size 11125194
frontend/public/hajj_video_h264.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cf2d8997c4564ae2420e668a0b8f492fa1d4a5deb60de7eed01b2cb88986a893
3
+ size 3406067
frontend/src/App.jsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Navigate, Route, Routes } from 'react-router-dom';
2
+ import Dashboard from './pages/Dashboard';
3
+
4
+ function App() {
5
+ return (
6
+ <Routes>
7
+ <Route path="/" element={<Dashboard />} />
8
+ <Route path="*" element={<Navigate to="/" replace />} />
9
+ </Routes>
10
+ );
11
+ }
12
+
13
+ export default App;
frontend/src/Fin.svg ADDED
frontend/src/index.css ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ font-family: 'Cairo', 'Segoe UI', sans-serif;
7
+ -webkit-font-smoothing: antialiased;
8
+ text-rendering: optimizeLegibility;
9
+ }
10
+
11
+ html,
12
+ body,
13
+ #root {
14
+ height: 100%;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ }
20
+
21
+ .leaflet-container {
22
+ width: 100%;
23
+ height: 100%;
24
+ z-index: 0;
25
+ background-color: #07130f;
26
+ }
27
+
28
+ .leaflet-popup-content-wrapper {
29
+ background: rgba(10, 28, 20, 0.95);
30
+ color: #e6fff3;
31
+ border: 1px solid rgba(126, 245, 200, 0.3);
32
+ border-radius: 12px;
33
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
34
+ }
35
+
36
+ .leaflet-popup-tip {
37
+ background: rgba(10, 28, 20, 0.95);
38
+ }
39
+
40
+ .leaflet-popup-close-button {
41
+ color: #7ef5c8 !important;
42
+ }
43
+
44
+ .leaflet-tooltip {
45
+ pointer-events: none !important;
46
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import App from './App';
5
+ import './index.css';
6
+
7
+ ReactDOM.createRoot(document.getElementById('root')).render(
8
+ <React.StrictMode>
9
+ <BrowserRouter>
10
+ <App />
11
+ </BrowserRouter>
12
+ </React.StrictMode>
13
+ );
frontend/src/pages/Dashboard.jsx ADDED
@@ -0,0 +1,1027 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AlertTriangle,
3
+ Bell,
4
+ CheckCircle,
5
+ Clock3,
6
+ Cpu,
7
+ Gauge,
8
+ MessageSquareWarning,
9
+ Pause,
10
+ Play,
11
+ RefreshCw,
12
+ Trash2,
13
+ TrendingUp,
14
+ Users,
15
+ } from 'lucide-react';
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
17
+ import kaabaLogo from '../Fin.svg';
18
+
19
+ const COLORS = {
20
+ deepForest: '#051f18',
21
+ primaryGreen: '#0d3829',
22
+ forestShadow: '#082d21',
23
+ successGreen: '#2a9d6f',
24
+ mint: '#7ef5c8',
25
+ deepEmerald: '#1e7552',
26
+ panelBg: '#101f1a',
27
+ cardDark: '#0d1a14',
28
+ elementBg: '#142520',
29
+ navDark: '#0d251c',
30
+ text: '#ffffff',
31
+ textMuted: '#d0e8dc',
32
+ red: '#ff6b6b',
33
+ orange: '#ff9800',
34
+ yellow: '#ffd54f',
35
+ borderMint: 'rgba(126,245,200,0.16)',
36
+ };
37
+
38
+ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000';
39
+ const STATE_ENDPOINT = import.meta.env.VITE_STATE_ENDPOINT || '/api/realtime/state';
40
+ const REFRESH_MS = Number(import.meta.env.VITE_DASH_REFRESH_MS || 1000);
41
+ const SMOOTH_WINDOW = 3; // rolling average over last N frames
42
+
43
+ // ── Demo mode constants (PRESENTATION SAFETY — DO NOT CHANGE) ──────────
44
+ const DEMO_FRAME_COUNT = 17; // hard limit: only frames 0–16 are valid
45
+ const DEMO_MAX_INDEX = 16; // = DEMO_FRAME_COUNT - 1
46
+ const DEMO_INTERVAL_MS = 1500; // auto-play speed (ms per frame)
47
+
48
+ // ── Unique-ID window constant ────────────────────────────────────────────
49
+ const ID_WINDOW_SIZE = 6; // frames per unique-ID window (real YOLO track_ids)
50
+
51
+
52
+ const RISK_LEVEL_META = {
53
+ HIGH: { label: 'عالي', ar: 'عالي', color: COLORS.red },
54
+ MEDIUM: { label: 'متوسط', ar: 'متوسط', color: COLORS.orange },
55
+ LOW: { label: 'منخفض', ar: 'منخفض', color: COLORS.successGreen },
56
+ };
57
+
58
+ const AGENT_TEXTS = [
59
+ 'يحسب أفضل نشر لوحدات الأمن...',
60
+ 'يحلل كثافة الحشود في المناطق الحرجة...',
61
+ 'يقيّم مستوى المخاطر الراهن...',
62
+ 'يقترح مسارات بديلة لتخفيف الضغط...',
63
+ 'يراقب تدفق الحشود عند البوابات...',
64
+ ];
65
+
66
+ const ACTION_PRIORITY_META = {
67
+ urgent: { label: '⚡ عاجل', color: COLORS.orange },
68
+ watch: { label: '⏳ مراقبة', color: COLORS.yellow },
69
+ completed: { label: '✓ منفذ', color: COLORS.successGreen },
70
+ };
71
+
72
+ const ACTIONS_APPROVE_ENDPOINT = (id) => `/api/actions/${id}/approve`;
73
+ const ACTIONS_REJECT_ENDPOINT = (id) => `/api/actions/${id}/reject`;
74
+
75
+ const normalizeRiskLevel = (value) => {
76
+ const text = (value || '').toString().toUpperCase();
77
+ if (text.includes('HIGH')) return 'HIGH';
78
+ if (text.includes('LOW')) return 'LOW';
79
+ return 'MEDIUM';
80
+ };
81
+
82
+ const normalizeAnnotatedSrc = (annotated) => {
83
+ if (!annotated) return null;
84
+ if (typeof annotated === 'string') {
85
+ if (annotated.startsWith('data:image') || annotated.startsWith('http')) return annotated;
86
+ return `data:image/jpeg;base64,${annotated}`;
87
+ }
88
+ if (typeof annotated === 'object') {
89
+ if (annotated.jpeg_base64) return `data:image/jpeg;base64,${annotated.jpeg_base64}`;
90
+ if (annotated.base64) return `data:image/jpeg;base64,${annotated.base64}`;
91
+ if (annotated.url) return annotated.url;
92
+ }
93
+ return null;
94
+ };
95
+
96
+ // ── KPI card ────────────────────────────────────────────────────────────
97
+ function KpiCard({ icon: Icon, title, value, note, accent }) {
98
+ return (
99
+ <div
100
+ className="rounded-xl p-3 shadow-lg"
101
+ style={{
102
+ background: `linear-gradient(145deg, ${COLORS.cardDark}, ${COLORS.deepForest})`,
103
+ borderRight: `3px solid ${accent}`,
104
+ border: `1px solid ${COLORS.borderMint}`,
105
+ }}
106
+ >
107
+ <div className="flex items-center justify-between gap-2">
108
+ <div>
109
+ <p className="text-[11px] font-bold" style={{ color: COLORS.textMuted }}>{title}</p>
110
+ <p className="text-xl font-black" style={{ color: COLORS.text }}>{value}</p>
111
+ <p className="text-[10px]" style={{ color: COLORS.textMuted }}>{note}</p>
112
+ </div>
113
+ <div className="h-10 w-10 rounded-full flex items-center justify-center"
114
+ style={{ backgroundColor: `${accent}22` }}>
115
+ <Icon size={18} style={{ color: accent }} />
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ // ── Summary card (replaces FPS) ─────────────────────────────────────────
123
+ function SummaryCard({ summary, accent }) {
124
+ return (
125
+ <div
126
+ className="rounded-xl p-3 shadow-lg"
127
+ style={{
128
+ background: `linear-gradient(145deg, ${COLORS.cardDark}, ${COLORS.deepForest})`,
129
+ borderRight: `3px solid ${accent}`,
130
+ border: `1px solid ${COLORS.borderMint}`,
131
+ }}
132
+ >
133
+ <p className="text-[11px] font-bold mb-1" style={{ color: COLORS.textMuted }}>ملخص الموقف</p>
134
+ <p className="text-[11px] leading-relaxed font-medium" style={{ color: COLORS.text }}>
135
+ {summary || '---'}
136
+ </p>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ // ── Risk gauge ──────────────────────────────────────────────────────────
142
+ // IMPORTANT: color/level driven by SCORE THRESHOLDS (0.35 / 0.65),
143
+ // NOT by the risk_level string — ensures indicator changes exactly at thresholds.
144
+ function RiskGauge({ riskScore = 0, densityPct = 0 }) {
145
+ const clamped = Math.max(0, Math.min(1, Number(riskScore) || 0));
146
+ const percentage = Math.round(clamped * 100);
147
+ const strokeLen = 2 * Math.PI * 54;
148
+ const strokeOff = strokeLen - (strokeLen * percentage) / 100;
149
+
150
+ // Derive level purely from score thresholds
151
+ const levelKey = clamped >= 0.65 ? 'HIGH' : clamped >= 0.35 ? 'MEDIUM' : 'LOW';
152
+ const meta = RISK_LEVEL_META[levelKey];
153
+
154
+ return (
155
+ <div className="rounded-xl p-4"
156
+ style={{ background: `linear-gradient(145deg, ${COLORS.cardDark}, ${COLORS.deepForest})`, border: `1px solid ${COLORS.borderMint}` }}>
157
+ <div className="flex items-center justify-between mb-2">
158
+ <h3 className="text-sm font-bold" style={{ color: COLORS.mint }}>مؤشر المخاطر</h3>
159
+ <span className="text-[11px] px-2 py-0.5 rounded font-bold"
160
+ style={{ color: meta.color, backgroundColor: `${meta.color}22` }}>
161
+ {meta.ar}
162
+ </span>
163
+ </div>
164
+ <div className="flex items-center gap-4">
165
+ <svg viewBox="0 0 130 130" className="w-[120px] h-[120px] shrink-0">
166
+ <circle cx="65" cy="65" r="54" fill="none" stroke="rgba(255,255,255,0.11)" strokeWidth="11" />
167
+ <circle cx="65" cy="65" r="54" fill="none" stroke={meta.color} strokeWidth="11"
168
+ strokeLinecap="round" strokeDasharray={strokeLen} strokeDashoffset={strokeOff}
169
+ transform="rotate(-90 65 65)" />
170
+ <text x="65" y="58" textAnchor="middle" fill={COLORS.textMuted} fontSize="11">خطر</text>
171
+ <text x="65" y="79" textAnchor="middle" fill={COLORS.text} fontSize="24" fontWeight="700">
172
+ {percentage}%
173
+ </text>
174
+ </svg>
175
+ <div className="text-xs space-y-1">
176
+ <p style={{ color: COLORS.textMuted }}>القيمة: {clamped.toFixed(2)}</p>
177
+ <p style={{ color: COLORS.textMuted }}>المستوى: {meta.label}</p>
178
+ <p style={{ color: COLORS.textMuted }}>
179
+ الكثافة: <span style={{ color: COLORS.mint }}>{densityPct}%</span>
180
+ </p>
181
+ <p style={{ color: meta.color }} className="font-semibold">
182
+ {percentage >= 65 ? 'يتطلب تدخل فوري' : percentage >= 35 ? 'يتطلب مراقبة' : 'مستقر حاليا'}
183
+ </p>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ // ── Agent status ────────────────────────────────────────────────────────
191
+ function AgentStatus({ stats = {} }) {
192
+ const [textIdx, setTextIdx] = useState(0);
193
+ useEffect(() => {
194
+ const t = setInterval(() => setTextIdx((i) => (i + 1) % AGENT_TEXTS.length), 4000);
195
+ return () => clearInterval(t);
196
+ }, []);
197
+
198
+ const miniCards = [
199
+ { value: stats.resolved_alerts ?? 0, label: 'تنبيه محلول', color: COLORS.successGreen },
200
+ { value: stats.pending_decisions ?? 0, label: 'قرار معلق', color: COLORS.orange },
201
+ { value: stats.urgent_interventions ?? 0, label: 'تدخل عاجل', color: COLORS.red },
202
+ ];
203
+
204
+ return (
205
+ <div className="rounded-xl p-3"
206
+ style={{ background: `linear-gradient(145deg, ${COLORS.cardDark}, ${COLORS.deepForest})`, border: `1px solid ${COLORS.borderMint}` }}>
207
+ <div className="flex items-center gap-2 mb-3">
208
+ <span className="text-lg" role="img" aria-label="AI agent">🤖</span>
209
+ <p className="text-xs font-semibold leading-snug" style={{ color: COLORS.mint }}>
210
+ {AGENT_TEXTS[textIdx]}
211
+ </p>
212
+ </div>
213
+ <div className="grid grid-cols-3 gap-2">
214
+ {miniCards.map((card) => (
215
+ <div key={card.label} className="rounded-lg p-2 text-center"
216
+ style={{ backgroundColor: `${card.color}18`, border: `1px solid ${card.color}44` }}>
217
+ <p className="text-xl font-black leading-none" style={{ color: card.color }}>{card.value}</p>
218
+ <p className="text-[10px] mt-1" style={{ color: COLORS.textMuted }}>{card.label}</p>
219
+ </div>
220
+ ))}
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ // ── Proposed actions — full detail card ──────────────────────────────────
227
+ // resolved: Map<id, 'approved'|'rejected'> — resolved actions stay visible at bottom
228
+ function ProposedActions({ actions = [], resolved = new Map(), onApprove, onReject, onDelete }) {
229
+ // pending first, resolved last
230
+ const pending = actions.filter((a) => !resolved.has(a.id));
231
+ const resolvedList = actions.filter((a) => resolved.has(a.id));
232
+ const ordered = [...pending, ...resolvedList];
233
+
234
+ const renderAction = (action) => {
235
+ const resolvedStatus = resolved.get(action.id); // 'approved' | 'rejected' | undefined
236
+ const isResolved = !!resolvedStatus;
237
+ const meta = ACTION_PRIORITY_META[action.priority] || ACTION_PRIORITY_META.watch;
238
+ const timeStr = (action.timestamp || '').toString().slice(11, 16);
239
+ const isDone = isResolved;
240
+ const gates = Array.isArray(action.selected_gates) ? action.selected_gates : [];
241
+ const allActs = Array.isArray(action.all_actions) ? action.all_actions : [];
242
+
243
+ // Badge for resolved state
244
+ const resolvedBadge = resolvedStatus === 'approved'
245
+ ? { label: '✓ تم التنفيذ', color: COLORS.successGreen }
246
+ : resolvedStatus === 'rejected'
247
+ ? { label: '✕ مرفوض', color: COLORS.red }
248
+ : null;
249
+
250
+ return (
251
+ <div key={action.id} className="rounded-lg p-2.5 space-y-1.5"
252
+ style={{
253
+ backgroundColor: isResolved ? `${COLORS.elementBg}88` : COLORS.elementBg,
254
+ border: `1px solid ${isResolved ? 'rgba(255,255,255,0.08)' : COLORS.borderMint}`,
255
+ opacity: isResolved ? 0.75 : 1,
256
+ }}>
257
+
258
+ {/* Header row */}
259
+ <div className="flex items-center justify-between">
260
+ <div className="flex items-center gap-1.5">
261
+ <span className="text-[10px] font-bold px-1.5 py-0.5 rounded"
262
+ style={{ color: meta.color, backgroundColor: `${meta.color}22` }}>
263
+ {meta.label}
264
+ </span>
265
+ {/* Resolved status badge */}
266
+ {resolvedBadge && (
267
+ <span className="text-[10px] font-bold px-1.5 py-0.5 rounded"
268
+ style={{ color: resolvedBadge.color, backgroundColor: `${resolvedBadge.color}22`, border: `1px solid ${resolvedBadge.color}55` }}>
269
+ {resolvedBadge.label}
270
+ </span>
271
+ )}
272
+ </div>
273
+ <div className="flex items-center gap-2">
274
+ {action.threat_level ? (() => {
275
+ const tlMeta = RISK_LEVEL_META[normalizeRiskLevel(action.threat_level)];
276
+ return (
277
+ <span className="text-[9px] font-bold px-1 py-0.5 rounded"
278
+ style={{ color: tlMeta.color, backgroundColor: `${tlMeta.color}22` }}>
279
+ {tlMeta.label}
280
+ </span>
281
+ );
282
+ })() : null}
283
+ <span className="text-[10px] font-mono" style={{ color: COLORS.textMuted }}>{timeStr}</span>
284
+ </div>
285
+ </div>
286
+
287
+ {/* Actions list */}
288
+ {allActs.length > 0 ? (
289
+ <div className="space-y-0.5">
290
+ {allActs.map((act, i) => (
291
+ <p key={i} className="text-[11px] font-semibold leading-snug flex gap-1"
292
+ style={{ color: isResolved ? COLORS.textMuted : COLORS.text }}>
293
+ <span style={{ color: COLORS.mint }}>•</span> {act}
294
+ </p>
295
+ ))}
296
+ </div>
297
+ ) : (
298
+ <p className="text-[11px] font-semibold" style={{ color: isResolved ? COLORS.textMuted : COLORS.text }}>{action.title}</p>
299
+ )}
300
+
301
+ {/* Gates — show only selected_gates, one chip per gate, no emoji */}
302
+ {gates.length > 0 && (
303
+ <div className="flex flex-wrap gap-1 pt-0.5">
304
+ {gates.map((g, i) => (
305
+ <span key={i} className="text-[9px] font-bold px-1.5 py-0.5 rounded"
306
+ style={{ backgroundColor: `${COLORS.mint}18`, color: COLORS.mint, border: `1px solid ${COLORS.mint}44` }}>
307
+ {g}
308
+ </span>
309
+ ))}
310
+ </div>
311
+ )}
312
+
313
+ {/* Justification */}
314
+ {action.description ? (
315
+ <p className="text-[10px] leading-relaxed" style={{ color: COLORS.textMuted }}>
316
+ {action.description}
317
+ </p>
318
+ ) : null}
319
+
320
+ {/* Arabic alert */}
321
+ {action.arabic_alert && !isResolved ? (
322
+ <p className="text-[10px] font-semibold leading-relaxed"
323
+ style={{ color: COLORS.red }}>
324
+ 📢 {action.arabic_alert}
325
+ </p>
326
+ ) : null}
327
+
328
+ {/* Confidence */}
329
+ {action.confidence > 0 ? (
330
+ <div className="flex items-center gap-1.5">
331
+ <span className="text-[9px]" style={{ color: COLORS.textMuted }}>ثقة:</span>
332
+ <div className="flex-1 h-1 rounded" style={{ backgroundColor: 'rgba(255,255,255,0.1)' }}>
333
+ <div className="h-1 rounded"
334
+ style={{ width: `${Math.round(action.confidence * 100)}%`, backgroundColor: COLORS.mint }} />
335
+ </div>
336
+ <span className="text-[9px] font-mono" style={{ color: COLORS.mint }}>
337
+ {Math.round(action.confidence * 100)}%
338
+ </span>
339
+ </div>
340
+ ) : null}
341
+
342
+ {/* Approve / Reject — only for unresolved actions */}
343
+ {!isDone && (
344
+ <div className="flex justify-end gap-1.5 pt-0.5">
345
+ <button onClick={() => onReject(action.id)}
346
+ className="text-[10px] font-bold px-2 py-1 rounded"
347
+ style={{ backgroundColor: `${COLORS.red}22`, color: COLORS.red, border: `1px solid ${COLORS.red}55` }}>
348
+ ✕ رفض
349
+ </button>
350
+ <button onClick={() => onApprove(action.id)}
351
+ className="flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded"
352
+ style={{ backgroundColor: `${COLORS.mint}22`, color: COLORS.mint, border: `1px solid ${COLORS.mint}55` }}>
353
+ <CheckCircle size={11} /> تنفيذ
354
+ </button>
355
+ </div>
356
+ )}
357
+ {/* Delete — only for resolved actions */}
358
+ {isDone && (
359
+ <div className="flex justify-end pt-0.5">
360
+ <button onClick={() => onDelete(action.id)}
361
+ className="flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded"
362
+ style={{ backgroundColor: `${COLORS.textMuted}15`, color: COLORS.textMuted, border: `1px solid ${COLORS.textMuted}33` }}>
363
+ <Trash2 size={11} /> حذف
364
+ </button>
365
+ </div>
366
+ )}
367
+ </div>
368
+ );
369
+ };
370
+
371
+ return (
372
+ <div className="rounded-xl p-3"
373
+ style={{ background: `linear-gradient(145deg, ${COLORS.cardDark}, ${COLORS.deepForest})`, border: `1px solid ${COLORS.borderMint}` }}>
374
+ <h4 className="text-xs font-bold mb-3" style={{ color: COLORS.mint }}>الإجراءات المقترحة</h4>
375
+ {ordered.length === 0 && (
376
+ <p className="text-xs" style={{ color: COLORS.textMuted }}>لا يوجد إجراءات مقترحة حالياً.</p>
377
+ )}
378
+ <div className="space-y-3">
379
+ {ordered.map(renderAction)}
380
+ </div>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ // ── HaramGuard Logo — pure JSX/CSS, no external SVG ────────────────────
386
+ function HaramGuardLogo() {
387
+ return (
388
+ <div className="flex items-center gap-2 shrink-0">
389
+ {/* Kaaba SVG — loaded as image, transparent background */}
390
+ <img
391
+ src={kaabaLogo}
392
+ alt="شعار حارس الحرم"
393
+ style={{ width: 100, height: 100, objectFit: 'contain', marginTop: 1 }}
394
+ />
395
+ {/* Text block */}
396
+ <div className="flex flex-col justify-center" style={{ gap: 2 }}>
397
+ <p
398
+ style={{
399
+ fontFamily: "'Tajawal', 'Segoe UI', Tahoma, sans-serif",
400
+ fontSize: 22,
401
+ fontWeight: 800,
402
+ color: '#ffffff',
403
+ lineHeight: 1.1,
404
+ letterSpacing: 0.3,
405
+ margin: 0,
406
+ }}
407
+ >
408
+ HaramGuard
409
+ </p>
410
+ <p
411
+ style={{
412
+ fontFamily: "'Tajawal', 'Segoe UI', Tahoma, sans-serif",
413
+ fontSize: 11,
414
+ fontWeight: 500,
415
+ color: '#4A8C6F',
416
+ lineHeight: 1.2,
417
+ margin: 0,
418
+ letterSpacing: 0.5,
419
+ }}
420
+ >
421
+ نظام إدارة الحشود الذكي
422
+ </p>
423
+ </div>
424
+ </div>
425
+ );
426
+ }
427
+
428
+ // ── Dashboard ───────────────────────────────────────────────────────────
429
+ // Zero state — shown when backend is disconnected
430
+ const ZERO_STATE = {
431
+ frame_id: 0,
432
+ annotated: null,
433
+ person_count: 0,
434
+ density_score: 0,
435
+ fps: 0,
436
+ risk_score: 0,
437
+ risk_level: 'LOW',
438
+ trend: 'stable',
439
+ risk_history: [],
440
+ latest_decision: null,
441
+ decisions_log: [],
442
+ arabic_alert: '',
443
+ coordinator_plan: null,
444
+ agent_stats: { resolved_alerts: 0, pending_decisions: 0, urgent_interventions: 0 },
445
+ proposed_actions: [],
446
+ };
447
+
448
+ function Dashboard() {
449
+ const [currentTime, setCurrentTime] = useState(new Date());
450
+ const [state, setState] = useState(ZERO_STATE);
451
+ const [apiConnected, setApiConnected] = useState(false);
452
+
453
+ // Smoothing buffer for risk score
454
+ const riskBuf = useRef([]);
455
+
456
+ // ── Demo mode state ───────────────────────────────────────────────
457
+ // frameBuffer holds exactly 17 frames (indices 0–16).
458
+ // demoIndex advances automatically; live stream is always frozen in demo mode.
459
+ const [frameBuffer, setFrameBuffer] = useState([]); // [{frame_id, annotated, person_count}]
460
+ const [demoIndex, setDemoIndex] = useState(0); // current frame in demo loop
461
+ const [demoPaused, setDemoPaused] = useState(false); // user paused the loop
462
+ const demoPausedRef = useRef(false);
463
+
464
+ // Zoom level for the annotated frame
465
+ const [zoomLevel, setZoomLevel] = useState(1.0);
466
+
467
+ // Resolved actions: Map<id, 'approved'|'rejected'> — stay visible with badge
468
+ const [resolvedActions, setResolvedActions] = useState(new Map());
469
+
470
+ // Reset counter — incrementing forces the polling effect to restart
471
+ const [resetCount, setResetCount] = useState(0);
472
+
473
+ // ── Crowd density state (6-frame unique-ID window + EMA) ─────────
474
+ const personWindowRef = useRef([]); // fallback: person_counts when no track_ids
475
+ const uniqueIdsWindow = useRef([]); // array of Sets — one Set per frame in window
476
+ const lastDecisionTime = useRef(0);
477
+ const [uniqueCount, setUniqueCount] = useState(0); // unique persons in 6-frame window
478
+
479
+ // ── Clock ─────────────────────────────────────────────────────────
480
+ useEffect(() => {
481
+ const t = setInterval(() => setCurrentTime(new Date()), 1000);
482
+ return () => clearInterval(t);
483
+ }, []);
484
+
485
+ // Keep demo refs in sync
486
+ useEffect(() => { demoPausedRef.current = demoPaused; }, [demoPaused]);
487
+
488
+ // ── Last seen backend frame_id — used to detect new frames ───────────
489
+ const lastBackendFrameId = useRef(-1);
490
+
491
+ // ── Single polling loop — drives EVERYTHING from backend frame_id ─────
492
+ // No independent timer. The demo only advances when the backend
493
+ // produces a new frame. Frontend is fully slave to backend speed.
494
+ // resetCount in deps: incrementing it restarts the loop (reset button).
495
+ useEffect(() => {
496
+ let isMounted = true;
497
+
498
+ const tick = async () => {
499
+ // 1. Fetch state (risk, decisions, alerts)
500
+ try {
501
+ const res = await fetch(`${API_BASE_URL}${STATE_ENDPOINT}`);
502
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
503
+ const payload = await res.json();
504
+ if (!isMounted || !payload || typeof payload !== 'object') return;
505
+
506
+ riskBuf.current = [...riskBuf.current, payload.risk_score ?? 0].slice(-SMOOTH_WINDOW);
507
+ const smoothRisk = parseFloat(
508
+ (riskBuf.current.reduce((a, b) => a + b, 0) / riskBuf.current.length).toFixed(4)
509
+ );
510
+
511
+ setState((prev) => {
512
+ // Accumulate proposed_actions: merge new ones on top, keep old below
513
+ const prevActions = prev.proposed_actions || [];
514
+ const newActions = payload.proposed_actions || [];
515
+ const seenIds = new Set(newActions.map((a) => a.id));
516
+ const merged = [...newActions, ...prevActions.filter((a) => !seenIds.has(a.id))];
517
+
518
+ return {
519
+ ...prev,
520
+ ...payload,
521
+ person_count: prev.person_count, // overwritten below from buffer
522
+ risk_score: smoothRisk,
523
+ proposed_actions: merged,
524
+ };
525
+ });
526
+ setApiConnected(true);
527
+
528
+ const backendFrameId = payload.frame_id ?? -1;
529
+
530
+ // 2. Only advance demo when backend has a NEW frame
531
+ if (backendFrameId !== lastBackendFrameId.current && backendFrameId > 0) {
532
+ lastBackendFrameId.current = backendFrameId;
533
+
534
+ // 3. Fetch buffer only when backend moved (no wasted calls)
535
+ try {
536
+ const bufRes = await fetch(`${API_BASE_URL}/api/frames/buffer`);
537
+ if (!bufRes.ok) return;
538
+ const bufData = await bufRes.json();
539
+ if (!isMounted || !bufData.frames?.length) return;
540
+
541
+ // Hard-cap to DEMO_FRAME_COUNT — never render beyond index 16
542
+ const newBuffer = bufData.frames.slice(0, DEMO_FRAME_COUNT);
543
+ setFrameBuffer(newBuffer);
544
+
545
+ // Advance demo index (only if not manually paused)
546
+ if (!demoPausedRef.current) {
547
+ setDemoIndex((prev) => {
548
+ const next = prev + 1;
549
+ const newIdx = (next > DEMO_MAX_INDEX || next >= newBuffer.length) ? 0 : next;
550
+
551
+ // ── 6-frame unique-ID window (unique persons only) ───
552
+ const frame = newBuffer[newIdx];
553
+ const frameIds = frame?.track_ids;
554
+ const hasRealIds = Array.isArray(frameIds) && frameIds.length > 0;
555
+
556
+ if (hasRealIds) {
557
+ uniqueIdsWindow.current.push(new Set(frameIds));
558
+ if (uniqueIdsWindow.current.length > ID_WINDOW_SIZE)
559
+ uniqueIdsWindow.current.shift();
560
+ } else {
561
+ const pc = frame?.person_count ?? 0;
562
+ personWindowRef.current.push(pc);
563
+ if (personWindowRef.current.length > ID_WINDOW_SIZE)
564
+ personWindowRef.current.shift();
565
+ }
566
+
567
+ // Update unique count every frame (union across window)
568
+ if (uniqueIdsWindow.current.length > 0) {
569
+ const unionSet = new Set();
570
+ uniqueIdsWindow.current.forEach((s) => s.forEach((id) => unionSet.add(id)));
571
+ setUniqueCount(unionSet.size);
572
+ } else if (personWindowRef.current.length > 0) {
573
+ setUniqueCount(Math.max(...personWindowRef.current));
574
+ }
575
+
576
+ return newIdx;
577
+ });
578
+ }
579
+ } catch (_) {}
580
+ }
581
+
582
+ } catch {
583
+ if (isMounted) {
584
+ // Backend disconnected — zero out everything
585
+ setApiConnected(false);
586
+ setState(ZERO_STATE);
587
+ riskBuf.current = [];
588
+ }
589
+ }
590
+ };
591
+
592
+ tick();
593
+ const interval = setInterval(tick, REFRESH_MS);
594
+ return () => { isMounted = false; clearInterval(interval); };
595
+ }, [resetCount]); // eslint-disable-line react-hooks/exhaustive-deps
596
+
597
+ // Seed index when buffer first loads
598
+ useEffect(() => {
599
+ if (frameBuffer.length === 0) return;
600
+ setDemoIndex(0);
601
+ }, [frameBuffer.length === 0]); // eslint-disable-line react-hooks/exhaustive-deps
602
+
603
+ // ── Derived values ────────────────────────────────────────────────
604
+ // risk_score → level (used for "مستوى المخاطر" KPI + RiskGauge)
605
+ const riskScore = Number(state.risk_score || 0);
606
+ const riskLevelKey = riskScore >= 0.65 ? 'HIGH' : riskScore >= 0.35 ? 'MEDIUM' : 'LOW';
607
+ const kpiRiskMeta = RISK_LEVEL_META[riskLevelKey] ?? RISK_LEVEL_META['LOW'];
608
+
609
+ // Density % — computed by backend (density_pct = peak_ema / 50 * 100)
610
+ const densityPct = Math.round(state.density_pct ?? 0);
611
+
612
+ // Current demo frame (clamped strictly to 0–16)
613
+ const safeIndex = Math.min(Math.max(demoIndex, 0), DEMO_MAX_INDEX);
614
+ const currentFrame = frameBuffer[safeIndex] ?? null;
615
+
616
+ // The frame image to display — always from demo buffer, never live stream
617
+ const displayedSrc = currentFrame
618
+ ? `data:image/jpeg;base64,${currentFrame.annotated}`
619
+ : null;
620
+
621
+ // Per-frame person count (for the KPI card "عدد الأشخاص")
622
+ const demoPersonCount = currentFrame?.person_count ?? 0;
623
+
624
+ const computedActions = useMemo(() => state.proposed_actions || [], [state.proposed_actions]);
625
+
626
+ // ── Handlers ──────────────────────────────────────────────────────
627
+ const handlePause = () => setDemoPaused(true);
628
+ const handlePlay = () => setDemoPaused(false);
629
+
630
+ const handleApprove = async (id) => {
631
+ setResolvedActions((prev) => new Map([...prev, [id, 'approved']]));
632
+ try { await fetch(`${API_BASE_URL}${ACTIONS_APPROVE_ENDPOINT(id)}`, { method: 'POST' }); } catch (_) {}
633
+ };
634
+
635
+ const handleReject = async (id) => {
636
+ setResolvedActions((prev) => new Map([...prev, [id, 'rejected']]));
637
+ try { await fetch(`${API_BASE_URL}${ACTIONS_REJECT_ENDPOINT(id)}`, { method: 'POST' }); } catch (_) {}
638
+ };
639
+
640
+ const handleDelete = (id) => {
641
+ setResolvedActions((prev) => { const m = new Map(prev); m.delete(id); return m; });
642
+ setState((prev) => ({
643
+ ...prev,
644
+ proposed_actions: (prev.proposed_actions || []).filter((a) => a.id !== id),
645
+ }));
646
+ };
647
+
648
+ const handleZoomIn = () => setZoomLevel((z) => Math.min(z + 0.25, 3.0));
649
+ const handleZoomOut = () => setZoomLevel((z) => Math.max(z - 0.25, 0.5));
650
+
651
+ // Reset: clears ALL local state and restarts the polling loop from scratch.
652
+ // resetCount increment kills the old interval and starts a fresh one,
653
+ // which prevents stale EMA / risk score from bleeding into the new run.
654
+ const handleReset = useCallback(async () => {
655
+ // ── 1. Tell backend to reset pipeline ─────────────────────────────
656
+ try {
657
+ await fetch(`${API_BASE_URL}/api/reset`, { method: 'POST' });
658
+ } catch (_) {}
659
+
660
+ // ── 2. Clear all frontend state ───────────────────────────────────
661
+ setDemoIndex(0);
662
+ setFrameBuffer([]);
663
+ setDemoPaused(false);
664
+ demoPausedRef.current = false;
665
+ setResolvedActions(new Map());
666
+ setZoomLevel(1.0);
667
+
668
+ // ── Risk smoothing buffer — must clear before new poll reads it ───
669
+ riskBuf.current = [];
670
+
671
+ // ── Reset state to zero so gauge/KPIs don't show stale HIGH ──────
672
+ setState(ZERO_STATE);
673
+
674
+ // ── Unique-ID window ─────────────────────────────────────────────
675
+ personWindowRef.current = [];
676
+ uniqueIdsWindow.current = [];
677
+ setUniqueCount(0);
678
+ lastDecisionTime.current = 0;
679
+
680
+ // ── 3. Restart polling loop (kills old interval, creates fresh one)
681
+ lastBackendFrameId.current = -1;
682
+ setResetCount((c) => c + 1);
683
+ }, []);
684
+
685
+ // Keyboard: ← / → step through demo frames (pauses loop), +/- zoom
686
+ // Clamp: never exceed DEMO_MAX_INDEX (16)
687
+ useEffect(() => {
688
+ const onKey = (e) => {
689
+ if (e.key === 'ArrowRight') {
690
+ setDemoPaused(true);
691
+ setDemoIndex((prev) => Math.max(prev - 1, 0));
692
+ } else if (e.key === 'ArrowLeft') {
693
+ setDemoPaused(true);
694
+ setDemoIndex((prev) => Math.min(prev + 1, DEMO_MAX_INDEX));
695
+ } else if (e.key === '+' || e.key === '=') {
696
+ handleZoomIn();
697
+ } else if (e.key === '-') {
698
+ handleZoomOut();
699
+ }
700
+ };
701
+ window.addEventListener('keydown', onKey);
702
+ return () => window.removeEventListener('keydown', onKey);
703
+ }, []);
704
+
705
+ // ── Situation summary: prefer coordinator plan, fallback to arabic alert
706
+ const situationSummary =
707
+ state.coordinator_plan?.executive_summary ||
708
+ state.arabic_alert ||
709
+ '---';
710
+
711
+ // ── Render ────────────────────────────────────────────────────────
712
+ return (
713
+ <div
714
+ dir="rtl"
715
+ className="h-screen w-full overflow-hidden flex flex-col"
716
+ style={{ background: `linear-gradient(135deg, ${COLORS.deepForest} 0%, ${COLORS.primaryGreen} 50%, ${COLORS.forestShadow} 100%)` }}
717
+ >
718
+ {/* ── Header ── */}
719
+ <header
720
+ className="h-16 px-6 border-b flex items-center justify-between shrink-0"
721
+ style={{ backgroundColor: COLORS.navDark, borderColor: COLORS.borderMint }}
722
+ >
723
+ {/* ── HaramGuard Logo — pure JSX ── */}
724
+ <HaramGuardLogo />
725
+ <div className="flex items-center gap-3">
726
+ {/* API connection indicator */}
727
+ <div className="hidden md:flex items-center gap-1.5 text-xs" style={{ color: COLORS.textMuted }}>
728
+ <span className="w-2 h-2 rounded-full" style={{ backgroundColor: apiConnected ? COLORS.successGreen : COLORS.red }} />
729
+ {apiConnected ? 'متصل' : 'غير متصل'}
730
+ </div>
731
+ {/* Reset button */}
732
+ <button
733
+ onClick={handleReset}
734
+ title="إعادة تشغيل من البداية"
735
+ className="flex items-center gap-1.5 text-[11px] font-bold px-3 py-1.5 rounded-lg border"
736
+ style={{
737
+ borderColor: COLORS.borderMint,
738
+ color: COLORS.textMuted,
739
+ backgroundColor: COLORS.elementBg,
740
+ }}
741
+ >
742
+ <RefreshCw size={13} />
743
+
744
+ </button>
745
+ <div className="text-left hidden md:block">
746
+ <p className="font-mono font-bold text-lg leading-none" style={{ color: COLORS.mint }}>
747
+ {currentTime.toLocaleTimeString('en-GB', { hour12: false })}
748
+ </p>
749
+ <p className="text-[11px]" style={{ color: COLORS.text }}>
750
+ {currentTime.toLocaleDateString('ar-SA')}
751
+ </p>
752
+ </div>
753
+ <div className="relative">
754
+ <Bell size={18} style={{ color: COLORS.text }} />
755
+ <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full" style={{ backgroundColor: COLORS.red }} />
756
+ </div>
757
+ </div>
758
+ </header>
759
+
760
+ {/* ── KPI bar ── */}
761
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-3 p-4 pb-0 shrink-0">
762
+ <KpiCard
763
+ icon={Users}
764
+ title="عدد المعتمرين"
765
+ value={demoPersonCount}
766
+ accent={COLORS.red}
767
+ />
768
+ <KpiCard
769
+ icon={Gauge}
770
+ title="مستوى المخاطر"
771
+ value={kpiRiskMeta.ar}
772
+ accent={kpiRiskMeta.color}
773
+ />
774
+ {/* الحشود الفريدة — unique IDs across 6-frame window */}
775
+ <KpiCard
776
+ icon={Users}
777
+ title="إجمالي المعتمرين"
778
+ value={uniqueCount}
779
+ accent={COLORS.mint}
780
+ />
781
+ {/* الكثافة = density_ema / 50 * 100 % */}
782
+ <KpiCard
783
+ icon={TrendingUp}
784
+ title="الكثافة"
785
+ value={`${densityPct}%`}
786
+ accent={COLORS.orange}
787
+ />
788
+ <KpiCard
789
+ icon={AlertTriangle}
790
+ title="الأولوية الحالية"
791
+ value={state.latest_decision?.priority || '-'}
792
+ accent={COLORS.mint}
793
+ />
794
+ </div>
795
+
796
+ {/* ── Main grid ── */}
797
+ <div className="flex-1 min-h-0 p-4 grid grid-cols-12 gap-4 overflow-hidden">
798
+
799
+ {/* ── Left sidebar ── */}
800
+ <aside
801
+ className="col-span-3 rounded-xl border overflow-hidden flex flex-col shadow-2xl"
802
+ style={{ backgroundColor: `${COLORS.panelBg}f2`, borderColor: COLORS.borderMint }}
803
+ >
804
+ <div className="p-3 border-b" style={{ borderColor: COLORS.borderMint }}>
805
+ <h3 className="font-bold text-sm flex items-center gap-2" style={{ color: COLORS.text }}>
806
+ <Cpu size={14} style={{ color: COLORS.mint }} />
807
+ نظام التوصيات التشغيلية
808
+ </h3>
809
+ </div>
810
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
811
+ <AgentStatus stats={state.agent_stats} />
812
+ <ProposedActions
813
+ actions={computedActions}
814
+ resolved={resolvedActions}
815
+ onApprove={handleApprove}
816
+ onReject={handleReject}
817
+ onDelete={handleDelete}
818
+ />
819
+ {/* Confidence score — sourced from coordinator_plan (backend AI only) */}
820
+ {state.coordinator_plan?.confidence_score != null && (
821
+ <div className="rounded-lg border p-3"
822
+ style={{ borderColor: COLORS.borderMint, backgroundColor: COLORS.elementBg }}>
823
+ <div className="flex items-center justify-between text-xs mb-2">
824
+ <span style={{ color: COLORS.textMuted }}>ثقة نظام الذكاء الاصطناعي</span>
825
+ <span style={{ color: COLORS.mint }} className="font-bold">
826
+ {Number(state.coordinator_plan.confidence_score).toFixed(2)}
827
+ </span>
828
+ </div>
829
+ <div className="w-full h-2 rounded" style={{ backgroundColor: 'rgba(255,255,255,0.1)' }}>
830
+ <div
831
+ className="h-2 rounded"
832
+ style={{
833
+ width: `${Math.round(Number(state.coordinator_plan.confidence_score) * 100)}%`,
834
+ background: `linear-gradient(90deg, ${COLORS.deepEmerald}, ${COLORS.mint})`,
835
+ }}
836
+ />
837
+ </div>
838
+ {state.coordinator_plan.actions_justification && (
839
+ <p className="text-[10px] mt-2 leading-relaxed" style={{ color: COLORS.textMuted }}>
840
+ {state.coordinator_plan.actions_justification}
841
+ </p>
842
+ )}
843
+ </div>
844
+ )}
845
+ </div>
846
+ </aside>
847
+
848
+ {/* ── Center — annotated frame display ── */}
849
+ <main
850
+ className="col-span-6 rounded-xl border overflow-hidden relative flex flex-col"
851
+ style={{ borderColor: COLORS.borderMint, backgroundColor: '#060f0b' }}
852
+ >
853
+ {/* Title bar */}
854
+ <div
855
+ className="px-4 py-2 border-b flex items-center justify-between shrink-0"
856
+ style={{ borderColor: COLORS.borderMint, backgroundColor: COLORS.navDark }}
857
+ >
858
+ <h3 className="text-sm font-bold" style={{ color: COLORS.mint }}>
859
+ تحليل مقطع الحشود
860
+ </h3>
861
+ <div className="flex items-center gap-2">
862
+ {/* Zoom controls */}
863
+ <button onClick={handleZoomOut}
864
+ className="text-[11px] px-2 py-1 rounded border font-bold"
865
+ style={{ borderColor: COLORS.borderMint, color: COLORS.textMuted }}>−</button>
866
+ <span className="text-[11px] font-mono w-10 text-center" style={{ color: COLORS.textMuted }}>
867
+ {Math.round(zoomLevel * 100)}%
868
+ </span>
869
+ <button onClick={handleZoomIn}
870
+ className="text-[11px] px-2 py-1 rounded border font-bold"
871
+ style={{ borderColor: COLORS.borderMint, color: COLORS.textMuted }}>+</button>
872
+ {/* Pause/Play */}
873
+ <button
874
+ onClick={demoPaused ? handlePlay : handlePause}
875
+ className="flex items-center gap-1 text-[11px] px-2 py-1 rounded border"
876
+ style={{ borderColor: COLORS.borderMint, color: COLORS.textMuted }}
877
+ >
878
+ {demoPaused ? <Play size={12} /> : <Pause size={12} />}
879
+ {demoPaused ? 'تشغيل' : 'إيقاف'}
880
+ </button>
881
+ </div>
882
+ </div>
883
+
884
+ {/* Frame display area */}
885
+ <div className="flex-1 min-h-0 relative bg-black flex items-center justify-center overflow-auto">
886
+ {displayedSrc ? (
887
+ <img
888
+ src={displayedSrc}
889
+ alt="إطار محلل"
890
+ style={{
891
+ objectFit: 'contain',
892
+ width: zoomLevel === 1.0 ? '100%' : `${zoomLevel * 100}%`,
893
+ height: zoomLevel === 1.0 ? '100%' : 'auto',
894
+ maxWidth: 'none',
895
+ transition: 'width 0.15s, height 0.15s',
896
+ }}
897
+ />
898
+ ) : (
899
+ <div className="flex flex-col items-center gap-2" style={{ color: COLORS.textMuted }}>
900
+ <div className="text-3xl opacity-30">🕌</div>
901
+ <p className="text-sm">في انتظار البيانات...</p>
902
+ </div>
903
+ )}
904
+ {demoPaused && displayedSrc && (
905
+ <div
906
+ className="absolute top-2 left-2 text-[10px] px-2 py-1 rounded font-bold"
907
+ style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: COLORS.orange }}
908
+ >
909
+ ⏸ متوقف
910
+ </div>
911
+ )}
912
+ </div>
913
+
914
+ {/* ── Frame scrubber — demo mode, locked 0–16 ── */}
915
+ <div
916
+ className="shrink-0 px-3 py-2 flex flex-col gap-1"
917
+ style={{ backgroundColor: COLORS.navDark, borderTop: `1px solid ${COLORS.borderMint}` }}
918
+ >
919
+ {frameBuffer.length > 0 ? (
920
+ <>
921
+ {/* Nav buttons + slider */}
922
+ <div className="flex items-center gap-2">
923
+ <button
924
+ onClick={() => { setDemoPaused(true); setDemoIndex((i) => Math.max(i - 1, 0)); }}
925
+ className="text-[11px] px-2 py-0.5 rounded border font-bold shrink-0"
926
+ style={{ borderColor: COLORS.borderMint, color: COLORS.textMuted }}
927
+ >→</button>
928
+ <input
929
+ type="range"
930
+ min={0}
931
+ max={DEMO_MAX_INDEX}
932
+ step={1}
933
+ value={safeIndex}
934
+ onChange={(e) => { setDemoPaused(true); setDemoIndex(Number(e.target.value)); }}
935
+ className="flex-1 cursor-pointer"
936
+ style={{ accentColor: COLORS.mint }}
937
+ />
938
+ <button
939
+ onClick={() => { setDemoPaused(true); setDemoIndex((i) => Math.min(i + 1, DEMO_MAX_INDEX)); }}
940
+ className="text-[11px] px-2 py-0.5 rounded border font-bold shrink-0"
941
+ style={{ borderColor: COLORS.borderMint, color: COLORS.textMuted }}
942
+ >←</button>
943
+ </div>
944
+ </>
945
+ ) : null}
946
+ </div>
947
+ </main>
948
+
949
+ {/* ── Right sidebar ── */}
950
+ <aside
951
+ className="col-span-3 rounded-xl border overflow-hidden flex flex-col shadow-2xl"
952
+ style={{ backgroundColor: `${COLORS.panelBg}f2`, borderColor: COLORS.borderMint }}
953
+ >
954
+ <div className="p-3 border-b" style={{ borderColor: COLORS.borderMint }}>
955
+ <h3 className="font-bold text-sm flex items-center gap-2" style={{ color: COLORS.text }}>
956
+ <Clock3 size={14} style={{ color: COLORS.mint }} />
957
+ التنبيهات والقرارات
958
+ </h3>
959
+ </div>
960
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
961
+ {/* Arabic alert */}
962
+ {(() => {
963
+ const alertColor = kpiRiskMeta.color;
964
+ return (
965
+ <div className="rounded-lg border p-3"
966
+ style={{ borderColor: `${alertColor}66`, backgroundColor: `${alertColor}12` }}>
967
+ <p className="text-[11px] mb-1 flex items-center gap-1" style={{ color: alertColor }}>
968
+ <MessageSquareWarning size={13} /> تنبيه عاجل
969
+ </p>
970
+ <p className="text-xs font-semibold leading-relaxed" style={{ color: COLORS.text }}>
971
+ {state.arabic_alert || 'لا يوجد تنبيه حالي'}
972
+ </p>
973
+ </div>
974
+ );
975
+ })()}
976
+
977
+ <RiskGauge
978
+ riskScore={state.risk_score}
979
+ densityPct={densityPct}
980
+ />
981
+
982
+ {/* Decisions log */}
983
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: COLORS.borderMint }}>
984
+ <div className="px-3 py-2 text-xs font-bold"
985
+ style={{ backgroundColor: COLORS.elementBg, color: COLORS.mint }}>
986
+ سجل القرارات (آخر 10)
987
+ </div>
988
+ <div className="max-h-[220px] overflow-auto">
989
+ <table className="w-full text-xs">
990
+ <thead style={{ backgroundColor: 'rgba(255,255,255,0.03)' }}>
991
+ <tr>
992
+ <th className="px-2 py-2 text-right" style={{ color: COLORS.textMuted }}>الوقت</th>
993
+ <th className="px-2 py-2 text-right" style={{ color: COLORS.textMuted }}>الأولوية</th>
994
+ <th className="px-2 py-2 text-right" style={{ color: COLORS.textMuted }}>المستوى</th>
995
+ </tr>
996
+ </thead>
997
+ <tbody>
998
+ {(state.decisions_log || []).slice(0, 10).map((decision, idx) => {
999
+ const decMeta = RISK_LEVEL_META[normalizeRiskLevel(decision.risk_level)];
1000
+ return (
1001
+ <tr key={`${decision.frame_id || 'f'}-${idx}`}
1002
+ style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
1003
+ <td className="px-2 py-2" style={{ color: COLORS.text }}>
1004
+ {(decision.timestamp || '').toString().slice(11, 19) || '--:--:--'}
1005
+ </td>
1006
+ <td className="px-2 py-2" style={{ color: COLORS.text }}>
1007
+ {decision.priority || '-'}
1008
+ </td>
1009
+ <td className="px-2 py-2" style={{ color: decMeta.color }}>
1010
+ {decMeta.label}
1011
+ </td>
1012
+ </tr>
1013
+ );
1014
+ })}
1015
+ </tbody>
1016
+ </table>
1017
+ </div>
1018
+ </div>
1019
+ </div>
1020
+ </aside>
1021
+
1022
+ </div>
1023
+ </div>
1024
+ );
1025
+ }
1026
+
1027
+ export default Dashboard;
frontend/tailwind.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,jsx}'],
4
+ theme: {
5
+ extend: {},
6
+ },
7
+ plugins: [],
8
+ };
frontend/vite.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ });