Spaces:
Running
Running
Upload 17 files
Browse files- .gitattributes +2 -0
- frontend/STATE_REFERENCE.md +48 -0
- frontend/dist/assets/index-DpH2i0wj.js +0 -0
- frontend/dist/assets/index-DwpMV8UQ.css +1 -0
- frontend/dist/index.html +13 -0
- frontend/index.html +18 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +30 -0
- frontend/postcss.config.js +6 -0
- frontend/public/hajj_real_video.mp4 +3 -0
- frontend/public/hajj_video_h264.mp4 +3 -0
- frontend/src/App.jsx +13 -0
- frontend/src/Fin.svg +0 -0
- frontend/src/index.css +46 -0
- frontend/src/main.jsx +13 -0
- frontend/src/pages/Dashboard.jsx +1027 -0
- frontend/tailwind.config.js +8 -0
- frontend/vite.config.js +6 -0
.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 |
+
});
|