Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- .github/workflows/deploy-backend.yaml +63 -0
- .github/workflows/deploy-frontend.yaml +68 -0
- .gitignore +21 -0
- .langgraphignore +0 -0
- .python-version +1 -0
- Dockerfile +21 -0
- ModelX Final Problem.pdf +3 -0
- QUICKSTART.md +140 -0
- README.md +860 -7
- app.py +361 -0
- debug_path.py +30 -0
- debug_runner.py +250 -0
- docker-compose.prod.yml +65 -0
- docker-compose.yml +38 -0
- frontend/.gitignore +41 -0
- frontend/README.md +36 -0
- frontend/app/components/App.tsx +19 -0
- frontend/app/components/ClientWrapper.tsx +28 -0
- frontend/app/components/FloatingChatBox.tsx +310 -0
- frontend/app/components/LoadingScreen.tsx +115 -0
- frontend/app/components/NavLink.tsx +28 -0
- frontend/app/components/Roger.css +210 -0
- frontend/app/components/dashboard/AnomalyDetection.tsx +217 -0
- frontend/app/components/dashboard/CurrencyPrediction.tsx +242 -0
- frontend/app/components/dashboard/DashboardOverview.tsx +220 -0
- frontend/app/components/dashboard/HistoricalIntel.tsx +235 -0
- frontend/app/components/dashboard/NationalThreatCard.tsx +202 -0
- frontend/app/components/dashboard/RiverNetStatus.tsx +235 -0
- frontend/app/components/dashboard/StockPredictions.tsx +189 -0
- frontend/app/components/dashboard/WeatherPredictions.tsx +238 -0
- frontend/app/components/intelligence/IntelligenceFeed.tsx +242 -0
- frontend/app/components/intelligence/IntelligenceSettings.tsx +429 -0
- frontend/app/components/map/DistrictInfoPanel.tsx +199 -0
- frontend/app/components/map/MapView.tsx +84 -0
- frontend/app/components/map/SatelliteView.tsx +197 -0
- frontend/app/components/map/SriLankaMap.tsx +226 -0
- frontend/app/components/ui/accordion.tsx +52 -0
- frontend/app/components/ui/alert-dialog.tsx +104 -0
- frontend/app/components/ui/alert.tsx +43 -0
- frontend/app/components/ui/aspect-ratio.tsx +5 -0
- frontend/app/components/ui/avatar.tsx +38 -0
- frontend/app/components/ui/badge.tsx +29 -0
- frontend/app/components/ui/breadcrumb.tsx +90 -0
- frontend/app/components/ui/button.tsx +47 -0
- frontend/app/components/ui/calendar.tsx +81 -0
- frontend/app/components/ui/card.tsx +43 -0
- frontend/app/components/ui/carousel.tsx +224 -0
- frontend/app/components/ui/chart.tsx +363 -0
- frontend/app/components/ui/checkbox.tsx +26 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
ModelX[[:space:]]Final[[:space:]]Problem.pdf filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/deploy-backend.yaml
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy Backend to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
- master
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
sync-to-hub:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
|
| 14 |
+
steps:
|
| 15 |
+
- name: Checkout repository
|
| 16 |
+
uses: actions/checkout@v4
|
| 17 |
+
with:
|
| 18 |
+
fetch-depth: 0
|
| 19 |
+
lfs: true
|
| 20 |
+
|
| 21 |
+
- name: Setup Git LFS
|
| 22 |
+
run: |
|
| 23 |
+
git lfs install
|
| 24 |
+
git lfs pull
|
| 25 |
+
git lfs checkout
|
| 26 |
+
|
| 27 |
+
- name: Setup Python
|
| 28 |
+
uses: actions/setup-python@v5
|
| 29 |
+
with:
|
| 30 |
+
python-version: '3.11'
|
| 31 |
+
|
| 32 |
+
- name: Install huggingface_hub
|
| 33 |
+
run: pip install huggingface_hub
|
| 34 |
+
|
| 35 |
+
- name: Push to Hugging Face Space
|
| 36 |
+
env:
|
| 37 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 38 |
+
run: |
|
| 39 |
+
python -c "
|
| 40 |
+
from huggingface_hub import HfApi, login
|
| 41 |
+
import os
|
| 42 |
+
|
| 43 |
+
token = os.environ['HF_TOKEN']
|
| 44 |
+
login(token=token)
|
| 45 |
+
|
| 46 |
+
api = HfApi()
|
| 47 |
+
api.upload_folder(
|
| 48 |
+
folder_path='.',
|
| 49 |
+
repo_id='nivakaran/modelx',
|
| 50 |
+
repo_type='space',
|
| 51 |
+
token=token,
|
| 52 |
+
ignore_patterns=['*.pyc', '__pycache__', '.git', 'node_modules', '*.log', '.env']
|
| 53 |
+
)
|
| 54 |
+
print('✅ Successfully pushed to Hugging Face Space!')
|
| 55 |
+
"
|
| 56 |
+
|
| 57 |
+
- name: Verify Sync
|
| 58 |
+
if: success()
|
| 59 |
+
run: echo "✅ Successfully synced to Hugging Face Space!"
|
| 60 |
+
|
| 61 |
+
- name: Sync Failed
|
| 62 |
+
if: failure()
|
| 63 |
+
run: echo "❌ Failed to sync. Check if the Space exists at https://huggingface.co/spaces/nivakaran/modelx"
|
.github/workflows/deploy-frontend.yaml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy Frontend to GitHub (for Vercel)
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
- master
|
| 8 |
+
paths:
|
| 9 |
+
# Trigger only when frontend files change
|
| 10 |
+
- 'frontend/**'
|
| 11 |
+
workflow_dispatch:
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
deploy-frontend:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- name: Checkout repository
|
| 19 |
+
uses: actions/checkout@v4
|
| 20 |
+
with:
|
| 21 |
+
fetch-depth: 0
|
| 22 |
+
|
| 23 |
+
- name: Configure Git
|
| 24 |
+
run: |
|
| 25 |
+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
| 26 |
+
git config --global user.name "github-actions[bot]"
|
| 27 |
+
|
| 28 |
+
- name: Extract and Push Frontend
|
| 29 |
+
env:
|
| 30 |
+
# Set your frontend repository here (format: username/repo)
|
| 31 |
+
FRONTEND_REPO: ${{ secrets.FRONTEND_REPO }}
|
| 32 |
+
# GitHub PAT with repo permissions for cross-repo push
|
| 33 |
+
DEPLOY_TOKEN: ${{ secrets.FRONTEND_DEPLOY_TOKEN }}
|
| 34 |
+
run: |
|
| 35 |
+
# Default repo if secret not set
|
| 36 |
+
REPO=${FRONTEND_REPO:-"Nivakaran-S/modelx-frontend"}
|
| 37 |
+
|
| 38 |
+
# Create a new directory for frontend-only repo
|
| 39 |
+
mkdir -p /tmp/frontend-deploy
|
| 40 |
+
|
| 41 |
+
# Copy frontend contents (not the folder itself)
|
| 42 |
+
cp -r frontend/* /tmp/frontend-deploy/
|
| 43 |
+
|
| 44 |
+
# Copy root configs needed for Next.js if they exist
|
| 45 |
+
[ -f "frontend/.gitignore" ] && cp frontend/.gitignore /tmp/frontend-deploy/ || true
|
| 46 |
+
|
| 47 |
+
# Create .env.production with API URL placeholder
|
| 48 |
+
echo "NEXT_PUBLIC_API_URL=\${NEXT_PUBLIC_API_URL:-https://nivakaran-modelx.hf.space}" > /tmp/frontend-deploy/.env.production
|
| 49 |
+
|
| 50 |
+
cd /tmp/frontend-deploy
|
| 51 |
+
|
| 52 |
+
# Initialize git repo
|
| 53 |
+
git init
|
| 54 |
+
git add .
|
| 55 |
+
git commit -m "Deploy frontend from main repo - $(date +'%Y-%m-%d %H:%M:%S')"
|
| 56 |
+
|
| 57 |
+
# Push to frontend repo
|
| 58 |
+
git remote add origin https://x-access-token:${DEPLOY_TOKEN}@github.com/${REPO}.git
|
| 59 |
+
git branch -M main
|
| 60 |
+
git push origin main --force
|
| 61 |
+
|
| 62 |
+
- name: Verify Deployment
|
| 63 |
+
if: success()
|
| 64 |
+
run: echo "✅ Successfully pushed frontend to GitHub repository for Vercel deployment!"
|
| 65 |
+
|
| 66 |
+
- name: Deployment Failed
|
| 67 |
+
if: failure()
|
| 68 |
+
run: echo "❌ Failed to push frontend. Check if FRONTEND_DEPLOY_TOKEN and FRONTEND_REPO secrets are configured."
|
.gitignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
.env
|
| 13 |
+
.env.template
|
| 14 |
+
|
| 15 |
+
models/
|
| 16 |
+
models
|
| 17 |
+
.langgraph_api
|
| 18 |
+
data/
|
| 19 |
+
datasets/
|
| 20 |
+
datasets
|
| 21 |
+
data
|
.langgraphignore
ADDED
|
File without changes
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.11
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
curl \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Copy requirements
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy application code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Expose port
|
| 18 |
+
EXPOSE 8000
|
| 19 |
+
|
| 20 |
+
# Run API server
|
| 21 |
+
CMD ["python", "main.py"]
|
ModelX Final Problem.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8666a7e263a028de6eb37864f48d8bfcfa78bf8303c72778c68396eade8b2300
|
| 3 |
+
size 4413994
|
QUICKSTART.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 Roger Quick Start Guide
|
| 2 |
+
|
| 3 |
+
## Prerequisites
|
| 4 |
+
- Python 3.11+
|
| 5 |
+
- Node.js 18+
|
| 6 |
+
- Groq API Key ([Get Free Key](https://console.groq.com))
|
| 7 |
+
|
| 8 |
+
## Installation & Setup
|
| 9 |
+
|
| 10 |
+
### 1. Install Python Dependencies
|
| 11 |
+
```bash
|
| 12 |
+
pip install -r requirements.txt
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
### 2. Configure Environment
|
| 16 |
+
```bash
|
| 17 |
+
# Copy template
|
| 18 |
+
cp .env.template .env
|
| 19 |
+
|
| 20 |
+
# Edit .env and add your GROQ_API_KEY
|
| 21 |
+
# GROQ_API_KEY=your_key_here
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### 3. Start Backend
|
| 25 |
+
```bash
|
| 26 |
+
python main.py
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Wait for initialization logs:
|
| 30 |
+
```
|
| 31 |
+
[StorageManager] Initializing multi-database storage system
|
| 32 |
+
[SQLiteCache] Initialized at data/cache/feeds.db
|
| 33 |
+
[ChromaDB] Initialized collection: Roger_feeds
|
| 34 |
+
[CombinedAgentNode] Initialized with production storage layer
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
### 4. Start Frontend (New Terminal)
|
| 38 |
+
```bash
|
| 39 |
+
cd frontend
|
| 40 |
+
npm install
|
| 41 |
+
npm run dev
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### 5. Access Dashboard
|
| 45 |
+
Open: http://localhost:3000
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 🎯 What to Expect
|
| 50 |
+
|
| 51 |
+
### First 60 Seconds
|
| 52 |
+
- System initializes 6 domain agents
|
| 53 |
+
- Begins scraping 47+ data sources
|
| 54 |
+
- Deduplication pipeline activates
|
| 55 |
+
|
| 56 |
+
### After 60-120 Seconds
|
| 57 |
+
- First batch of events appears on dashboard
|
| 58 |
+
- Risk metrics start calculating
|
| 59 |
+
- Real-time WebSocket connects
|
| 60 |
+
|
| 61 |
+
### Live Features
|
| 62 |
+
- ✅ Real-time intelligence feed
|
| 63 |
+
- ✅ Risk vs Opportunity classification
|
| 64 |
+
- ✅ 3-tier deduplication (SQLite + ChromaDB + Neo4j\*)
|
| 65 |
+
- ✅ CSV exports in `data/feeds/`
|
| 66 |
+
- ✅ Operational Risk Radar metrics
|
| 67 |
+
|
| 68 |
+
\*Neo4j optional - requires Docker
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## 🐛 Troubleshooting
|
| 73 |
+
|
| 74 |
+
### "ChromaDB not found"
|
| 75 |
+
```bash
|
| 76 |
+
pip install chromadb sentence-transformers
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### "No events appearing"
|
| 80 |
+
- Wait 60-120 seconds for first batch
|
| 81 |
+
- Check backend logs for errors
|
| 82 |
+
- Verify GROQ_API_KEY is set correctly
|
| 83 |
+
|
| 84 |
+
### Frontend can't connect
|
| 85 |
+
```bash
|
| 86 |
+
# Verify backend running
|
| 87 |
+
curl http://localhost:8000/api/status
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## 📊 Production Features
|
| 93 |
+
|
| 94 |
+
### Storage Stats
|
| 95 |
+
```bash
|
| 96 |
+
curl http://localhost:8000/api/storage/stats
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### CSV Exports
|
| 100 |
+
```bash
|
| 101 |
+
ls -lh data/feeds/
|
| 102 |
+
cat data/feeds/feed_$(date +%Y-%m-%d).csv
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Enable Neo4j (Optional)
|
| 106 |
+
```bash
|
| 107 |
+
# Start Neo4j with Docker
|
| 108 |
+
docker-compose -f docker-compose.prod.yml up -d neo4j
|
| 109 |
+
|
| 110 |
+
# Update .env
|
| 111 |
+
NEO4J_ENABLED=true
|
| 112 |
+
|
| 113 |
+
# Restart backend
|
| 114 |
+
python main.py
|
| 115 |
+
|
| 116 |
+
# Access Neo4j Browser
|
| 117 |
+
open http://localhost:7474
|
| 118 |
+
# Login: neo4j / Roger2024
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 🏆 Demo for Judges
|
| 124 |
+
|
| 125 |
+
**Show in this order**:
|
| 126 |
+
1. Live dashboard (http://localhost:3000)
|
| 127 |
+
2. Terminal logs showing deduplication stats
|
| 128 |
+
3. Neo4j graph visualization (if enabled)
|
| 129 |
+
4. CSV exports in data/feeds/
|
| 130 |
+
5. Storage API: http://localhost:8000/api/storage/stats
|
| 131 |
+
|
| 132 |
+
**Key talking points**:
|
| 133 |
+
- "47+ data sources, 6 domain agents running in parallel"
|
| 134 |
+
- "3-tier deduplication: SQLite for speed, ChromaDB for intelligence"
|
| 135 |
+
- "90%+ duplicate reduction vs 60% with basic hashing"
|
| 136 |
+
- "Production-ready with persistent storage and knowledge graphs"
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
**Ready to win! 🏆**
|
README.md
CHANGED
|
@@ -1,10 +1,863 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🇱🇰 Roger Intelligence Platform
|
| 2 |
+
|
| 3 |
+
**Real-Time Situational Awareness for Sri Lanka**
|
| 4 |
+
|
| 5 |
+
A multi-agent AI system that aggregates intelligence from 47+ data sources to provide risk analysis and opportunity detection for businesses operating in Sri Lanka.
|
| 6 |
+
|
| 7 |
+
## 🌐 Live Demo
|
| 8 |
+
|
| 9 |
+
| Component | URL |
|
| 10 |
+
|-----------|-----|
|
| 11 |
+
| **Frontend Dashboard** | [https://model-x-frontend-snowy.vercel.app/](https://model-x-frontend-snowy.vercel.app/) |
|
| 12 |
+
| **Backend API** | [https://nivakaran-Roger.hf.space](https://nivakaran-Roger.hf.space) |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 🎯 Key Features
|
| 17 |
+
|
| 18 |
+
✅ **8 Domain Agents** running in parallel:
|
| 19 |
+
- Social Media Monitor (Reddit, Twitter, Facebook, Threads, BlueSky)
|
| 20 |
+
- Political Intelligence (Gazette, Parliament, District Social Media)
|
| 21 |
+
- Economic Analysis (CSE Stock Market + Technical Indicators)
|
| 22 |
+
- Meteorological Alerts (DMC Weather + RiverNet + **FloodWatch Integration** 🆕)
|
| 23 |
+
- Intelligence Agent (Brand Monitoring + Threat Detection + **User-Configurable Targets**)
|
| 24 |
+
- Data Retrieval Orchestrator (Web Scraping)
|
| 25 |
+
- Vectorization Agent (Multilingual BERT Embeddings + Anomaly Detection)
|
| 26 |
+
|
| 27 |
+
✅ **ML Anomaly Detection Pipeline** (Integrated into Graph):
|
| 28 |
+
- Language-specific BERT models (Sinhala, Tamil, English)
|
| 29 |
+
- Real-time anomaly inference on every graph cycle
|
| 30 |
+
- Clustering (DBSCAN, KMeans, HDBSCAN)
|
| 31 |
+
- Anomaly Detection (Isolation Forest, LOF)
|
| 32 |
+
- MLflow + DagsHub tracking
|
| 33 |
+
|
| 34 |
+
✅ **Weather Prediction ML Pipeline** 🆕:
|
| 35 |
+
- LSTM Neural Network (30-day sequences)
|
| 36 |
+
- Predicts: Temperature, Rainfall, Flood Risk, Severity
|
| 37 |
+
- 21 weather stations → 25 districts
|
| 38 |
+
- Airflow DAG runs daily at 4 AM
|
| 39 |
+
|
| 40 |
+
✅ **Currency Prediction ML Pipeline** 🆕:
|
| 41 |
+
- GRU Neural Network (optimized for 8GB RAM)
|
| 42 |
+
- Predicts: USD/LKR exchange rate
|
| 43 |
+
- Features: Technical indicators + CSE + Gold + Oil + USD Index
|
| 44 |
+
- MLflow tracking + Airflow DAG at 4 AM
|
| 45 |
+
|
| 46 |
+
✅ **Stock Price Prediction ML Pipeline** 🆕:
|
| 47 |
+
- Multi-Architecture: LSTM, GRU, BiLSTM, BiGRU
|
| 48 |
+
- Optuna hyperparameter tuning (30 trials per stock)
|
| 49 |
+
- Per-stock best model selection
|
| 50 |
+
- 10 top CSE stocks (JKH, COMB, DIAL, HNB, etc.)
|
| 51 |
+
|
| 52 |
+
✅ **RAG-Powered Chatbot** 🆕:
|
| 53 |
+
- Chat-history aware Q&A
|
| 54 |
+
- Queries all ChromaDB intelligence collections
|
| 55 |
+
- Domain filtering (political, economic, weather, social)
|
| 56 |
+
- Floating chat UI in dashboard
|
| 57 |
+
|
| 58 |
+
✅ **Real-Time Dashboard** with:
|
| 59 |
+
- Live Intelligence Feed
|
| 60 |
+
- Floating AI Chatbox
|
| 61 |
+
- Weather Predictions Tab
|
| 62 |
+
- **Live Satellite/Weather Map** (Windy.com) 🆕
|
| 63 |
+
- **National Flood Threat Score** 🆕
|
| 64 |
+
- **30-Year Historical Climate Analysis** 🆕
|
| 65 |
+
- Operational Risk Radar
|
| 66 |
+
- ML Anomaly Detection Display
|
| 67 |
+
- Market Predictions with Moving Averages
|
| 68 |
+
- Risk & Opportunity Classification
|
| 69 |
+
|
| 70 |
+
✅ **Weather Data Scraper for ML Training** 🆕:
|
| 71 |
+
- Open-Meteo API (free historical data)
|
| 72 |
+
- NASA FIRMS (fire/heat detection)
|
| 73 |
+
- All 25 districts coverage
|
| 74 |
+
- Year-wise CSV export for model training
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 🏗️ System Architecture
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
┌─────────────────────────────────────────────────────────────────────────┐
|
| 82 |
+
│ Roger Combined Graph │
|
| 83 |
+
│ ┌────────────────────────────────────────────────────────────────┐ │
|
| 84 |
+
│ │ Graph Initiator (Reset) │ │
|
| 85 |
+
│ └────────────────────────────────────────────────────────────────┘ │
|
| 86 |
+
│ │ Fan-Out │
|
| 87 |
+
│ ┌────────────┬────────────┼────────────┬────────────┬────────────┐ │
|
| 88 |
+
│ ▼ ▼ ▼ ▼ ▼ ▼ │
|
| 89 |
+
│ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ ┌────┐│
|
| 90 |
+
│ │Social│ │Econ │ │Political │ │Meteo │ │Intellig- │ │Data││
|
| 91 |
+
│ │Agent │ │Agent │ │Agent │ │Agent │ │ence Agent│ │Retr││
|
| 92 |
+
│ └──────┘ └──────┘ └──────────┘ └──────┘ └──────────┘ └────┘│
|
| 93 |
+
│ │ │ │ │ │ │ │
|
| 94 |
+
│ └────────────┴────────────┴────────────┴────────────┴────────────┘ │
|
| 95 |
+
│ │ Fan-In │
|
| 96 |
+
│ ┌─────────▼──────────┐ │
|
| 97 |
+
│ │ Feed Aggregator │ │
|
| 98 |
+
│ │ (Rank & Dedupe) │ │
|
| 99 |
+
│ └─────────┬──────────┘ │
|
| 100 |
+
│ ┌─────────▼──────────┐ │
|
| 101 |
+
│ │ Vectorization │ ← NEW │
|
| 102 |
+
│ │ Agent (Optional) │ │
|
| 103 |
+
│ └─────────┬──────────┘ │
|
| 104 |
+
│ ┌─────────▼──────────┐ │
|
| 105 |
+
│ │ Router (Loop/End) │ │
|
| 106 |
+
│ └────────────────────┘ │
|
| 107 |
+
└─────────────────────────────────────────────────────────────────────────┘
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 📊 Graph Implementations
|
| 113 |
+
|
| 114 |
+
### 1. Combined Agent Graph (`combinedAgentGraph.py`)
|
| 115 |
+
**The Mother Graph** - Orchestrates all domain agents in parallel.
|
| 116 |
+
|
| 117 |
+
```mermaid
|
| 118 |
+
graph TD
|
| 119 |
+
A[Graph Initiator] -->|Fan-Out| B[Social Agent]
|
| 120 |
+
A -->|Fan-Out| C[Economic Agent]
|
| 121 |
+
A -->|Fan-Out| D[Political Agent]
|
| 122 |
+
A -->|Fan-Out| E[Meteorological Agent]
|
| 123 |
+
A -->|Fan-Out| F[Intelligence Agent]
|
| 124 |
+
A -->|Fan-Out| G[Data Retrieval Agent]
|
| 125 |
+
B -->|Fan-In| H[Feed Aggregator]
|
| 126 |
+
C --> H
|
| 127 |
+
D --> H
|
| 128 |
+
E --> H
|
| 129 |
+
F --> H
|
| 130 |
+
G --> H
|
| 131 |
+
H --> I[Data Refresher]
|
| 132 |
+
I --> J{Router}
|
| 133 |
+
J -->|Loop| A
|
| 134 |
+
J -->|End| K[END]
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
**Key Features:**
|
| 138 |
+
- Custom state reducers for parallel execution
|
| 139 |
+
- Feed deduplication with content hashing
|
| 140 |
+
- Loop control with configurable intervals
|
| 141 |
+
- Real-time WebSocket broadcasting
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### 2. Political Agent Graph (`politicalAgentGraph.py`)
|
| 146 |
+
**3-Module Hybrid Architecture**
|
| 147 |
+
|
| 148 |
+
| Module | Description | Sources |
|
| 149 |
+
|--------|-------------|---------|
|
| 150 |
+
| **Official Sources** | Government data | Gazette, Parliament Minutes |
|
| 151 |
+
| **Social Media** | Political sentiment | Twitter, Facebook, Reddit (National + 25 Districts) |
|
| 152 |
+
| **Feed Generation** | LLM Processing | Categorize → Summarize → Format |
|
| 153 |
+
|
| 154 |
+
```
|
| 155 |
+
┌─────────────────────────────────────────────┐
|
| 156 |
+
│ Module 1: Official │ Module 2: Social │
|
| 157 |
+
│ ┌─────────────────┐ │ ┌───────────────┐ │
|
| 158 |
+
│ │ Gazette │ │ │ National │ │
|
| 159 |
+
│ │ Parliament │ │ │ Districts (25)│ │
|
| 160 |
+
│ └─────────────────┘ │ │ World Politics│ │
|
| 161 |
+
│ │ └───────────────┘ │
|
| 162 |
+
└────────────┬───────────┴────────┬──────────┘
|
| 163 |
+
│ Fan-In │
|
| 164 |
+
▼ ▼
|
| 165 |
+
┌────────────────────────────┐
|
| 166 |
+
│ Module 3: Feed Generation │
|
| 167 |
+
│ Categorize → LLM → Format │
|
| 168 |
+
└────────────────────────────┘
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
### 3. Economic Agent Graph (`economicalAgentGraph.py`)
|
| 174 |
+
**Market Intelligence & Technical Analysis**
|
| 175 |
+
|
| 176 |
+
| Component | Description |
|
| 177 |
+
|-----------|-------------|
|
| 178 |
+
| **Stock Collector** | CSE market data (200+ stocks) |
|
| 179 |
+
| **Technical Analyzer** | SMA, EMA, RSI, MACD |
|
| 180 |
+
| **Trend Detector** | Bullish/Bearish signals |
|
| 181 |
+
| **Feed Generator** | Risk/Opportunity classification |
|
| 182 |
+
|
| 183 |
+
**Indicators Calculated:**
|
| 184 |
+
- Simple Moving Average (SMA-20, SMA-50)
|
| 185 |
+
- Exponential Moving Average (EMA-12, EMA-26)
|
| 186 |
+
- Relative Strength Index (RSI)
|
| 187 |
+
- MACD with Signal Line
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
### 4. Meteorological Agent Graph (`meteorologicalAgentGraph.py`)
|
| 192 |
+
**Weather & Disaster Monitoring + FloodWatch Integration** 🆕
|
| 193 |
+
|
| 194 |
+
```
|
| 195 |
+
┌─────────────────────────────────────┐
|
| 196 |
+
│ DMC Weather Collector │
|
| 197 |
+
│ (Daily forecasts, 25 districts) │
|
| 198 |
+
└─────────────┬───────────────────────┘
|
| 199 |
+
│
|
| 200 |
+
▼
|
| 201 |
+
┌─────────────────────────────────────┐
|
| 202 |
+
│ RiverNet Data Collector │
|
| 203 |
+
│ (River levels, flood monitoring) │
|
| 204 |
+
└─────────────┬───────────────────────┘
|
| 205 |
+
│
|
| 206 |
+
▼
|
| 207 |
+
┌─────────────────────────────────────┐
|
| 208 |
+
│ FloodWatch Historical Data 🆕 │
|
| 209 |
+
│ (30-year climate analysis) │
|
| 210 |
+
└─────────────┬───────────────────────┘
|
| 211 |
+
│
|
| 212 |
+
▼
|
| 213 |
+
┌─────────────────────────────────────┐
|
| 214 |
+
│ National Threat Calculator 🆕 │
|
| 215 |
+
│ (Aggregated flood risk 0-100) │
|
| 216 |
+
└─────────────┬───────────────────────┘
|
| 217 |
+
│
|
| 218 |
+
▼
|
| 219 |
+
┌─────────────────────────────────────┐
|
| 220 |
+
│ Alert Generator │
|
| 221 |
+
│ (Severity classification) │
|
| 222 |
+
└─────────────────────────────────────┘
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
**Alert Levels:**
|
| 226 |
+
- 🟢 Normal: Standard conditions
|
| 227 |
+
- 🟡 Advisory: Watch for developments
|
| 228 |
+
- 🟠 Warning: Take precautions
|
| 229 |
+
- 🔴 Critical: Immediate action required
|
| 230 |
+
|
| 231 |
+
**FloodWatch Features (New):**
|
| 232 |
+
| Feature | Description |
|
| 233 |
+
|---------|-------------|
|
| 234 |
+
| **Historical Analysis** | 30-year climate data (1995-2025) |
|
| 235 |
+
| **Decadal Comparison** | 3 periods: 1995-2004, 2005-2014, 2015-2025 |
|
| 236 |
+
| **National Threat Score** | 0-100 aggregated risk from rivers + alerts + season |
|
| 237 |
+
| **High-Risk Periods** | May-Jun (SW Monsoon), Oct-Nov (NE Monsoon) |
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
### 5. Social Agent Graph (`socialAgentGraph.py`)
|
| 242 |
+
**Multi-Platform Social Media Monitoring**
|
| 243 |
+
|
| 244 |
+
| Platform | Data Source | Coverage |
|
| 245 |
+
|----------|-------------|----------|
|
| 246 |
+
| Reddit | PRAW API | r/srilanka, r/colombo |
|
| 247 |
+
| Twitter/X | Nitter scraping | #SriLanka, #Colombo |
|
| 248 |
+
| Facebook | Profile scraping | News pages |
|
| 249 |
+
| Threads | Meta API | Trending topics |
|
| 250 |
+
| BlueSky | AT Protocol | Political discourse |
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
### 6. Intelligence Agent Graph (`intelligenceAgentGraph.py`)
|
| 255 |
+
**Brand & Threat Monitoring + User-Configurable Targets** 🆕
|
| 256 |
+
|
| 257 |
+
```
|
| 258 |
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
| 259 |
+
│ Brand Monitor │ │ Threat Scanner │ │ User Targets 🆕 │
|
| 260 |
+
│ - Company news │ │ - Security │ │ - Custom keys │
|
| 261 |
+
│ - Competitor │ │ - Compliance │ │ - User profiles │
|
| 262 |
+
│ - Market share │ │ - Geopolitical │ │ - Products │
|
| 263 |
+
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
| 264 |
+
│ │ │
|
| 265 |
+
└──────────────────────┼──────────────────────┘
|
| 266 |
+
▼
|
| 267 |
+
┌─────────────────────┐
|
| 268 |
+
│ Intelligence Report │
|
| 269 |
+
│ (Priority ranked) │
|
| 270 |
+
└─────────────────────┘
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
**User-Configurable Monitoring** 🆕:
|
| 274 |
+
Users can define custom monitoring targets via the frontend settings panel or API:
|
| 275 |
+
|
| 276 |
+
| Config Type | Description | Example |
|
| 277 |
+
|-------------|-------------|---------|
|
| 278 |
+
| **Keywords** | Custom search terms | "Colombo Port", "BOI Investment" |
|
| 279 |
+
| **Products** | Products to track | "iPhone 15", "Samsung Galaxy" |
|
| 280 |
+
| **Profiles** | Social media accounts | @CompetitorX (Twitter), CompanyY (Facebook) |
|
| 281 |
+
|
| 282 |
+
**API Endpoints:**
|
| 283 |
+
```bash
|
| 284 |
+
# Get current config
|
| 285 |
+
GET /api/intel/config
|
| 286 |
+
|
| 287 |
+
# Update full config
|
| 288 |
+
POST /api/intel/config
|
| 289 |
+
Body: {"user_keywords": ["keyword1"], "user_profiles": {"twitter": ["@account"]}, "user_products": ["Product"]}
|
| 290 |
+
|
| 291 |
+
# Add single target
|
| 292 |
+
POST /api/intel/config/add?target_type=keyword&value=Colombo+Port
|
| 293 |
+
|
| 294 |
+
# Remove target
|
| 295 |
+
DELETE /api/intel/config/remove?target_type=profile&value=CompetitorX&platform=twitter
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
**Config File**: `src/config/intel_config.json`
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
### 7. DATA Retrieval Agent Graph (`dataRetrievalAgentGraph.py`)
|
| 303 |
+
**Web Scraping Orchestrator**
|
| 304 |
+
|
| 305 |
+
**Scraping Tools Available:**
|
| 306 |
+
- `scrape_news_site` - Generic news scraper
|
| 307 |
+
- `scrape_cse_live` - CSE stock prices
|
| 308 |
+
- `scrape_official_data` - Government portals
|
| 309 |
+
- `scrape_social_media` - Multi-platform
|
| 310 |
+
|
| 311 |
+
**Anti-Bot Features:**
|
| 312 |
+
- Random delays (1-3s)
|
| 313 |
+
- User-agent rotation
|
| 314 |
+
- Retry with exponential backoff
|
| 315 |
+
- Headless browser fallback
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
### 8. Vectorization Agent Graph (`vectorizationAgentGraph.py`) 🆕
|
| 320 |
+
**Multilingual Text-to-Vector Conversion + Anomaly Detection**
|
| 321 |
+
|
| 322 |
+
```
|
| 323 |
+
┌────��────────────────────────────────────────────┐
|
| 324 |
+
│ Step 1: Language Detection │
|
| 325 |
+
│ FastText + Unicode script analysis │
|
| 326 |
+
│ Supports: English, Sinhala (සිංහල), Tamil (தமிழ்)│
|
| 327 |
+
└─────────────────┬───────────────────────────────┘
|
| 328 |
+
│
|
| 329 |
+
▼
|
| 330 |
+
┌─────────────────────────────────────────────────┐
|
| 331 |
+
│ Step 2: Text Vectorization │
|
| 332 |
+
│ ┌─────────────┬─────────────┬─────────────────┐ │
|
| 333 |
+
│ │ DistilBERT │ SinhalaBERTo│ Tamil-BERT │ │
|
| 334 |
+
│ │ (English) │ (Sinhala) │ (Tamil) │ │
|
| 335 |
+
│ └─────────────┴─────────────┴─────────────────┘ │
|
| 336 |
+
│ Output: 768-dim vector per text │
|
| 337 |
+
└─────────────────┬───────────────────────────────┘
|
| 338 |
+
│
|
| 339 |
+
▼
|
| 340 |
+
┌─────────────────────────────────────────────────┐
|
| 341 |
+
│ Step 3: Anomaly Detection (Isolation Forest) 🆕 │
|
| 342 |
+
│ - Runs inference on every graph cycle │
|
| 343 |
+
│ - Outputs anomaly_score (0-1) │
|
| 344 |
+
│ - Graceful fallback if model not trained │
|
| 345 |
+
└─────────────────┬───────────────────────────────┘
|
| 346 |
+
│
|
| 347 |
+
▼
|
| 348 |
+
┌─────────────────────────────────────────────────┐
|
| 349 |
+
│ Step 4: Expert Summary (GroqLLM) │
|
| 350 |
+
│ - Opportunity identification │
|
| 351 |
+
│ - Threat detection │
|
| 352 |
+
│ - Sentiment analysis │
|
| 353 |
+
└─────────────────┬───────────────────────────────┘
|
| 354 |
+
│
|
| 355 |
+
▼
|
| 356 |
+
┌─────────────────────────────────────────────────┐
|
| 357 |
+
│ Step 5: Format Output │
|
| 358 |
+
│ - Includes anomaly insights in domain_insights │
|
| 359 |
+
│ - Passes results to parent graph │
|
| 360 |
+
└─────────────────────────────────────────────────┘
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
---
|
| 364 |
+
|
| 365 |
+
### 10. Weather Prediction Pipeline (`models/weather-prediction/`) 🆕
|
| 366 |
+
**LSTM-Based Multi-District Weather Forecasting**
|
| 367 |
+
|
| 368 |
+
```
|
| 369 |
+
┌─────────────────────────────────────────────────┐
|
| 370 |
+
│ Data Source: Tutiempo.net (21 stations) │
|
| 371 |
+
│ Historical data since 1944 │
|
| 372 |
+
└─────────────────┬───────────────────────────────┘
|
| 373 |
+
│
|
| 374 |
+
▼
|
| 375 |
+
┌─────────────────────────────────────────────────┐
|
| 376 |
+
│ LSTM Neural Network │
|
| 377 |
+
│ ┌─────────────────────────────────────────────┐ │
|
| 378 |
+
│ │ Input: 30-day sequence (11 features) │ │
|
| 379 |
+
│ │ Layer 1: LSTM(64) + BatchNorm + Dropout │ │
|
| 380 |
+
│ │ Layer 2: LSTM(32) + BatchNorm + Dropout │ │
|
| 381 |
+
│ │ Output: Dense(3) → temp_max, temp_min, rain │ │
|
| 382 |
+
│ └─────────────────────────────────────────────┘ │
|
| 383 |
+
└─────────────────┬───────────────────────────────┘
|
| 384 |
+
│
|
| 385 |
+
▼
|
| 386 |
+
┌─────────────────────────────────────────────────┐
|
| 387 |
+
│ Severity Classifier │
|
| 388 |
+
│ - Combines temp, rainfall, flood risk │
|
| 389 |
+
│ - Outputs: normal/advisory/warning/critical │
|
| 390 |
+
└─────────────────┬───────────────────────────────┘
|
| 391 |
+
│
|
| 392 |
+
▼
|
| 393 |
+
┌─────────────────────────────────────────────────┐
|
| 394 |
+
│ Output: 25 District Predictions │
|
| 395 |
+
│ - Temperature (high/low °C) │
|
| 396 |
+
│ - Rainfall (mm + probability) │
|
| 397 |
+
│ - Flood risk (integrated with RiverNet) │
|
| 398 |
+
└─────────────────────────────────────────────────┘
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
**Usage:**
|
| 402 |
+
```bash
|
| 403 |
+
# Run full pipeline
|
| 404 |
+
cd models/weather-prediction
|
| 405 |
+
python main.py --mode full
|
| 406 |
+
|
| 407 |
+
# Just predictions
|
| 408 |
+
python main.py --mode predict
|
| 409 |
+
|
| 410 |
+
# Train specific station
|
| 411 |
+
python main.py --mode train --station COLOMBO
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
---
|
| 415 |
+
|
| 416 |
+
### 11. Currency Prediction Pipeline (`models/currency-volatility-prediction/`) 🆕
|
| 417 |
+
**GRU-Based USD/LKR Exchange Rate Forecasting**
|
| 418 |
+
|
| 419 |
+
```
|
| 420 |
+
┌─────────────────────────────────────────────────┐
|
| 421 |
+
│ Data Sources (yfinance) │
|
| 422 |
+
│ - USD/LKR exchange rate │
|
| 423 |
+
│ - CSE stock index (correlation) │
|
| 424 |
+
│ - Gold, Oil prices (global factors) │
|
| 425 |
+
│ - USD strength index │
|
| 426 |
+
└─────────────────┬───────────────────────────────┘
|
| 427 |
+
│
|
| 428 |
+
▼
|
| 429 |
+
┌─────────────────────────────────────────────────┐
|
| 430 |
+
│ Feature Engineering (25+ features) │
|
| 431 |
+
│ - SMA, EMA, RSI, MACD, Bollinger Bands │
|
| 432 |
+
│ - Volatility, Momentum indicators │
|
| 433 |
+
│ - Temporal encoding (day/month cycles) │
|
| 434 |
+
└─────────────────┬───────────────────────────────┘
|
| 435 |
+
│
|
| 436 |
+
▼
|
| 437 |
+
┌─────────────────────────────────────────────────┐
|
| 438 |
+
│ GRU Neural Network (8GB RAM optimized) │
|
| 439 |
+
│ ┌─────────────────────────────────────────────┐ │
|
| 440 |
+
│ │ Input: 30-day sequence │ │
|
| 441 |
+
│ │ Layer 1: GRU(64) + BatchNorm + Dropout │ │
|
| 442 |
+
│ │ Layer 2: GRU(32) + BatchNorm + Dropout │ │
|
| 443 |
+
│ │ Output: Dense(1) → next_day_rate │ │
|
| 444 |
+
│ └─────────────────────────────────────────────┘ │
|
| 445 |
+
└─────────────────┬───────────────────────────────┘
|
| 446 |
+
│
|
| 447 |
+
▼
|
| 448 |
+
┌─────────────────────────────────────────────────┐
|
| 449 |
+
│ Output: USD/LKR Prediction │
|
| 450 |
+
│ - Current & predicted rate │
|
| 451 |
+
│ - Change % and direction │
|
| 452 |
+
│ - Volatility classification (low/medium/high) │
|
| 453 |
+
└─────────────────────────────────────────────────┘
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
**Usage:**
|
| 457 |
+
```bash
|
| 458 |
+
# Run full pipeline
|
| 459 |
+
cd models/currency-volatility-prediction
|
| 460 |
+
python main.py --mode full
|
| 461 |
+
|
| 462 |
+
# Just predict
|
| 463 |
+
python main.py --mode predict
|
| 464 |
+
|
| 465 |
+
# Train GRU model
|
| 466 |
+
python main.py --mode train --epochs 100
|
| 467 |
+
```
|
| 468 |
+
|
| 469 |
+
---
|
| 470 |
+
|
| 471 |
+
### 12. RAG Chatbot (`src/rag.py`)
|
| 472 |
+
**Chat-History Aware Intelligence Q&A**
|
| 473 |
+
|
| 474 |
+
```
|
| 475 |
+
┌─────────────────────────────────────────────────┐
|
| 476 |
+
│ MultiCollectionRetriever │
|
| 477 |
+
│ - Connects to ALL ChromaDB collections │
|
| 478 |
+
│ - Roger_feeds, Roger_rag_collection, etc. │
|
| 479 |
+
└─────────────────┬───────────────────────────────┘
|
| 480 |
+
│
|
| 481 |
+
▼
|
| 482 |
+
┌─────────────────────────────────────────────────┐
|
| 483 |
+
│ Question Reformulation (History-Aware) │
|
| 484 |
+
│ - Uses last 3-5 exchanges for context │
|
| 485 |
+
│ - Reformulates follow-up questions │
|
| 486 |
+
└─────────────────┬───────────────────────────────┘
|
| 487 |
+
│
|
| 488 |
+
▼
|
| 489 |
+
┌───��─────────────────────────────────────────────┐
|
| 490 |
+
│ Groq LLM (llama-3.1-70b-versatile) │
|
| 491 |
+
│ - RAG with source citations │
|
| 492 |
+
│ - Domain-specific analysis │
|
| 493 |
+
└─────────────────────────────────────────────────┘
|
| 494 |
+
```
|
| 495 |
+
|
| 496 |
+
**Usage:**
|
| 497 |
+
```bash
|
| 498 |
+
# CLI mode
|
| 499 |
+
python src/rag.py
|
| 500 |
+
|
| 501 |
+
# Or via API
|
| 502 |
+
curl -X POST http://localhost:8000/api/rag/chat \
|
| 503 |
+
-H "Content-Type: application/json" \
|
| 504 |
+
-d '{"message": "What are the latest political events?"}'
|
| 505 |
+
```
|
| 506 |
+
|
| 507 |
---
|
| 508 |
+
|
| 509 |
+
## 🤖 ML Anomaly Detection Pipeline
|
| 510 |
+
|
| 511 |
+
Located in `models/anomaly-detection/`
|
| 512 |
+
|
| 513 |
+
### Pipeline Components
|
| 514 |
+
|
| 515 |
+
| Component | File | Description |
|
| 516 |
+
|-----------|------|-------------|
|
| 517 |
+
| Data Ingestion | `data_ingestion.py` | SQLite + CSV fetching |
|
| 518 |
+
| Data Validation | `data_validation.py` | Schema-based validation |
|
| 519 |
+
| Data Transformation | `data_transformation.py` | Language detection + BERT vectorization |
|
| 520 |
+
| Model Trainer | `model_trainer.py` | Optuna + MLflow training |
|
| 521 |
+
|
| 522 |
+
### Clustering Models
|
| 523 |
+
|
| 524 |
+
| Model | Type | Use Case |
|
| 525 |
+
|-------|------|----------|
|
| 526 |
+
| **DBSCAN** | Density-based | Noise-robust clustering |
|
| 527 |
+
| **KMeans** | Centroid-based | Fast, fixed k clusters |
|
| 528 |
+
| **HDBSCAN** | Hierarchical density | Variable density clusters |
|
| 529 |
+
| **Isolation Forest** | Anomaly detection | Outlier identification |
|
| 530 |
+
| **LOF** | Local outlier | Density-based anomalies |
|
| 531 |
+
|
| 532 |
+
### Training with Optuna
|
| 533 |
+
|
| 534 |
+
```python
|
| 535 |
+
# Hyperparameter optimization
|
| 536 |
+
study = optuna.create_study(direction="maximize")
|
| 537 |
+
study.optimize(objective, n_trials=50)
|
| 538 |
+
```
|
| 539 |
+
|
| 540 |
+
### MLflow Tracking
|
| 541 |
+
|
| 542 |
+
```python
|
| 543 |
+
mlflow.set_tracking_uri("https://dagshub.com/...")
|
| 544 |
+
mlflow.log_params(best_params)
|
| 545 |
+
mlflow.log_metrics(metrics)
|
| 546 |
+
mlflow.sklearn.log_model(model, "model")
|
| 547 |
+
```
|
| 548 |
+
|
| 549 |
---
|
| 550 |
|
| 551 |
+
## 🌧️ Weather Data Scraper (`scripts/scrape_weather_data.py`) 🆕
|
| 552 |
+
|
| 553 |
+
**Historical weather data collection for ML model training**
|
| 554 |
+
|
| 555 |
+
### Data Sources
|
| 556 |
+
|
| 557 |
+
| Source | API Key? | Data Available |
|
| 558 |
+
|--------|----------|----------------|
|
| 559 |
+
| **Open-Meteo** | ❌ Free | Historical weather since 1940 |
|
| 560 |
+
| **NASA FIRMS** | ✅ Optional | Fire/heat spot detection |
|
| 561 |
+
|
| 562 |
+
### Collected Weather Variables
|
| 563 |
+
|
| 564 |
+
- `temperature_2m_max/min/mean`
|
| 565 |
+
- `precipitation_sum`, `rain_sum`
|
| 566 |
+
- `precipitation_hours`
|
| 567 |
+
- `wind_speed_10m_max`, `wind_gusts_10m_max`
|
| 568 |
+
- `wind_direction_10m_dominant`
|
| 569 |
+
|
| 570 |
+
### Usage
|
| 571 |
+
|
| 572 |
+
```bash
|
| 573 |
+
# Scrape last 30 days (default)
|
| 574 |
+
python scripts/scrape_weather_data.py
|
| 575 |
+
|
| 576 |
+
# Scrape specific date range
|
| 577 |
+
python scripts/scrape_weather_data.py --start 2020-01-01 --end 2024-12-31
|
| 578 |
+
|
| 579 |
+
# Scrape multiple years for training dataset
|
| 580 |
+
python scripts/scrape_weather_data.py --years 2020,2021,2022,2023,2024
|
| 581 |
+
|
| 582 |
+
# Include fire detection data
|
| 583 |
+
python scripts/scrape_weather_data.py --years 2023,2024 --fires
|
| 584 |
+
|
| 585 |
+
# Hourly resolution (default is daily)
|
| 586 |
+
python scripts/scrape_weather_data.py --start 2024-01-01 --end 2024-01-31 --resolution hourly
|
| 587 |
+
```
|
| 588 |
+
|
| 589 |
+
### Output
|
| 590 |
+
|
| 591 |
+
```
|
| 592 |
+
datasets/weather/
|
| 593 |
+
├── weather_daily_2020-01-01_2020-12-31.csv
|
| 594 |
+
├── weather_daily_2021-01-01_2021-12-31.csv
|
| 595 |
+
├── weather_combined.csv (merged file)
|
| 596 |
+
└── fire_detections_20241207.csv
|
| 597 |
+
```
|
| 598 |
+
|
| 599 |
+
### Coverage
|
| 600 |
+
|
| 601 |
+
All 25 Sri Lankan districts with coordinates:
|
| 602 |
+
- Colombo, Gampaha, Kalutara, Kandy, Matale, Nuwara Eliya
|
| 603 |
+
- Galle, Matara, Hambantota, Jaffna, Kilinochchi, Mannar
|
| 604 |
+
- Vavuniya, Mullaitivu, Batticaloa, Ampara, Trincomalee
|
| 605 |
+
- Kurunegala, Puttalam, Anuradhapura, Polonnaruwa
|
| 606 |
+
- Badulla, Monaragala, Ratnapura, Kegalle
|
| 607 |
+
|
| 608 |
+
---
|
| 609 |
+
|
| 610 |
+
## 🚀 Quick Start
|
| 611 |
+
|
| 612 |
+
### Prerequisites
|
| 613 |
+
- Python 3.11+
|
| 614 |
+
- Node.js 18+
|
| 615 |
+
- Docker Desktop (for Airflow)
|
| 616 |
+
- Groq API Key
|
| 617 |
+
|
| 618 |
+
### Installation
|
| 619 |
+
|
| 620 |
+
```bash
|
| 621 |
+
# 1. Clone repository
|
| 622 |
+
git clone <your-repo>
|
| 623 |
+
cd Roger-Final
|
| 624 |
+
|
| 625 |
+
# 2. Create virtual environment
|
| 626 |
+
python -m venv .venv
|
| 627 |
+
source .venv/bin/activate # Linux/Mac
|
| 628 |
+
.\.venv\Scripts\activate # Windows
|
| 629 |
+
|
| 630 |
+
# 3. Install dependencies
|
| 631 |
+
pip install -r requirements.txt
|
| 632 |
+
|
| 633 |
+
# 4. Configure environment
|
| 634 |
+
cp .env.template .env
|
| 635 |
+
# Edit .env with your API keys
|
| 636 |
+
|
| 637 |
+
# 5. Download ML models
|
| 638 |
+
python models/anomaly-detection/download_models.py
|
| 639 |
+
|
| 640 |
+
# 6. Launch all services
|
| 641 |
+
./start_services.sh # Linux/Mac
|
| 642 |
+
.\start_services.ps1 # Windows
|
| 643 |
+
```
|
| 644 |
+
|
| 645 |
+
---
|
| 646 |
+
|
| 647 |
+
## 🔧 API Endpoints
|
| 648 |
+
|
| 649 |
+
### REST API (FastAPI - Port 8000)
|
| 650 |
+
|
| 651 |
+
| Endpoint | Method | Description |
|
| 652 |
+
|----------|--------|-------------|
|
| 653 |
+
| `/api/status` | GET | System health |
|
| 654 |
+
| `/api/dashboard` | GET | Risk metrics |
|
| 655 |
+
| `/api/feed` | GET | Latest events |
|
| 656 |
+
| `/api/feeds` | GET | All feeds with pagination |
|
| 657 |
+
| `/api/feeds/by_district` | GET | Feeds filtered by district |
|
| 658 |
+
| `/api/rivernet` | GET | River monitoring data |
|
| 659 |
+
| `/api/predict` | POST | Run anomaly predictions |
|
| 660 |
+
| `/api/anomalies` | GET | Get anomalous feeds |
|
| 661 |
+
| `/api/model/status` | GET | ML model status |
|
| 662 |
+
| `/api/weather/predictions` | GET | All district forecasts |
|
| 663 |
+
| `/api/weather/predictions/{district}` | GET | Single district |
|
| 664 |
+
| `/api/weather/model/status` | GET | Weather model info |
|
| 665 |
+
| `/api/weather/historical` | GET | 30-year climate analysis 🆕 |
|
| 666 |
+
| `/api/weather/threat` | GET | National flood threat score 🆕 |
|
| 667 |
+
| `/api/currency/prediction` | GET | USD/LKR next-day forecast |
|
| 668 |
+
| `/api/currency/history` | GET | Historical rates |
|
| 669 |
+
| `/api/currency/model/status` | GET | Currency model info |
|
| 670 |
+
| `/api/stocks/predictions` | GET | All CSE stock forecasts |
|
| 671 |
+
| `/api/stocks/predictions/{symbol}` | GET | Single stock prediction |
|
| 672 |
+
| `/api/stocks/model/status` | GET | Stock models info |
|
| 673 |
+
| `/api/rag/chat` | POST | Chat with RAG |
|
| 674 |
+
| `/api/rag/stats` | GET | RAG system stats |
|
| 675 |
+
| `/api/rag/clear` | POST | Clear chat history |
|
| 676 |
+
|
| 677 |
+
### WebSocket
|
| 678 |
+
- `ws://localhost:8000/ws` - Real-time updates
|
| 679 |
+
|
| 680 |
+
---
|
| 681 |
+
|
| 682 |
+
## ⏰ Airflow Orchestration
|
| 683 |
+
|
| 684 |
+
### DAG: `anomaly_detection_training`
|
| 685 |
+
|
| 686 |
+
```
|
| 687 |
+
start → check_records → data_ingestion → data_validation
|
| 688 |
+
→ data_transformation → model_training → end
|
| 689 |
+
```
|
| 690 |
+
|
| 691 |
+
**Triggers:**
|
| 692 |
+
- Batch threshold: 1000 new records
|
| 693 |
+
- Daily fallback: Every 24 hours
|
| 694 |
+
|
| 695 |
+
**Access Dashboard:**
|
| 696 |
+
```bash
|
| 697 |
+
cd models/anomaly-detection
|
| 698 |
+
astro dev start
|
| 699 |
+
# Open http://localhost:8080
|
| 700 |
+
```
|
| 701 |
+
|
| 702 |
+
### DAG: `weather_prediction_daily` 🆕
|
| 703 |
+
|
| 704 |
+
```
|
| 705 |
+
ingest_data → train_models → generate_predictions → publish_predictions
|
| 706 |
+
```
|
| 707 |
+
|
| 708 |
+
**Schedule:** Daily at 4:00 AM IST
|
| 709 |
+
|
| 710 |
+
**Tasks:**
|
| 711 |
+
- Scrape Tutiempo.net for latest data
|
| 712 |
+
- Train LSTM models (MLflow tracked)
|
| 713 |
+
- Generate 25-district predictions
|
| 714 |
+
- Save to JSON for API
|
| 715 |
+
|
| 716 |
+
### DAG: `currency_prediction_daily` 🆕
|
| 717 |
+
|
| 718 |
+
```
|
| 719 |
+
ingest_data → train_model → generate_prediction → publish_prediction
|
| 720 |
+
```
|
| 721 |
+
|
| 722 |
+
**Schedule:** Daily at 4:00 AM IST
|
| 723 |
+
|
| 724 |
+
**Tasks:**
|
| 725 |
+
- Fetch USD/LKR + indicators from yfinance
|
| 726 |
+
- Train GRU model (MLflow tracked)
|
| 727 |
+
- Generate next-day prediction
|
| 728 |
+
- Save to JSON for API
|
| 729 |
+
|
| 730 |
+
---
|
| 731 |
+
|
| 732 |
+
## 📁 Project Structure
|
| 733 |
+
|
| 734 |
+
```
|
| 735 |
+
Roger-Ultimate/
|
| 736 |
+
├── src/
|
| 737 |
+
│ ├── graphs/ # LangGraph definitions
|
| 738 |
+
│ │ ├── combinedAgentGraph.py # Mother graph
|
| 739 |
+
│ │ ├── politicalAgentGraph.py
|
| 740 |
+
│ │ ├── economicalAgentGraph.py
|
| 741 |
+
│ │ ├── meteorologicalAgentGraph.py
|
| 742 |
+
│ │ ├── socialAgentGraph.py
|
| 743 |
+
│ │ ├── intelligenceAgentGraph.py
|
| 744 |
+
│ │ ├── dataRetrievalAgentGraph.py
|
| 745 |
+
│ │ └── vectorizationAgentGraph.py # 5-step with anomaly detection
|
| 746 |
+
│ ├── nodes/ # Agent implementations
|
| 747 |
+
│ ├── states/ # State definitions
|
| 748 |
+
│ ├── llms/ # LLM configurations
|
| 749 |
+
│ ├── storage/ # ChromaDB, SQLite, Neo4j stores
|
| 750 |
+
│ ├── rag.py # RAG chatbot
|
| 751 |
+
│ └── utils/
|
| 752 |
+
│ └── utils.py # Tools incl. FloodWatch 🆕
|
| 753 |
+
├── scripts/
|
| 754 |
+
│ └── scrape_weather_data.py # Weather data scraper 🆕
|
| 755 |
+
├── models/
|
| 756 |
+
│ ├── anomaly-detection/ # ML Anomaly Pipeline
|
| 757 |
+
│ │ ├── src/
|
| 758 |
+
│ │ │ ├── components/ # Pipeline stages
|
| 759 |
+
│ │ │ ├── entity/ # Config/Artifact classes
|
| 760 |
+
│ │ │ ├── pipeline/ # Orchestrators
|
| 761 |
+
│ │ │ └── utils/ # Vectorizer, metrics
|
| 762 |
+
│ │ ├── dags/ # Airflow DAGs
|
| 763 |
+
│ │ ├── data_schema/ # Validation schemas
|
| 764 |
+
│ │ ├── output/ # Trained models
|
| 765 |
+
│ │ └── models_cache/ # Downloaded BERT models
|
| 766 |
+
│ ├── weather-prediction/ # Weather ML Pipeline
|
| 767 |
+
│ │ ├── src/components/ # data_ingestion, model_trainer, predictor
|
| 768 |
+
│ │ ├── dags/ # weather_prediction_dag.py (4 AM)
|
| 769 |
+
│ │ ├── artifacts/ # Trained LSTM models (.h5)
|
| 770 |
+
│ │ └── main.py # CLI entry point
|
| 771 |
+
│ └── currency-volatility-prediction/ # Currency ML Pipeline
|
| 772 |
+
│ ├── src/components/ # data_ingestion, model_trainer, predictor
|
| 773 |
+
│ ├── dags/ # currency_prediction_dag.py (4 AM)
|
| 774 |
+
│ ├── artifacts/ # Trained GRU model
|
| 775 |
+
│ └── main.py # CLI entry point
|
| 776 |
+
├── datasets/
|
| 777 |
+
│ └── weather/ # Scraped weather CSVs 🆕
|
| 778 |
+
├── frontend/
|
| 779 |
+
│ └── app/
|
| 780 |
+
│ ├── components/
|
| 781 |
+
│ │ ├── dashboard/
|
| 782 |
+
│ │ │ ├── AnomalyDetection.tsx
|
| 783 |
+
│ │ │ ├── WeatherPredictions.tsx
|
| 784 |
+
│ │ │ ├── CurrencyPrediction.tsx
|
| 785 |
+
│ │ │ ├── NationalThreatCard.tsx # Flood threat score 🆕
|
| 786 |
+
│ │ │ ├── HistoricalIntel.tsx # 30-year climate 🆕
|
| 787 |
+
│ │ │ └── ...
|
| 788 |
+
│ │ ├── map/
|
| 789 |
+
│ │ │ ├── MapView.tsx
|
| 790 |
+
│ │ │ └── SatelliteView.tsx # Windy.com embed 🆕
|
| 791 |
+
│ │ ├── FloatingChatBox.tsx # RAG chat UI
|
| 792 |
+
│ │ └── ...
|
| 793 |
+
│ └── pages/
|
| 794 |
+
│ └── Index.tsx # 7 tabs incl. SATELLITE 🆕
|
| 795 |
+
├── main.py # FastAPI backend
|
| 796 |
+
├── start.sh # Startup script
|
| 797 |
+
└── requirements.txt
|
| 798 |
+
```
|
| 799 |
+
|
| 800 |
+
---
|
| 801 |
+
|
| 802 |
+
## 🔐 Environment Variables
|
| 803 |
+
|
| 804 |
+
```env
|
| 805 |
+
# LLM
|
| 806 |
+
GROQ_API_KEY=your_groq_key
|
| 807 |
+
|
| 808 |
+
# Database
|
| 809 |
+
MONGO_DB_URL=mongodb+srv://...
|
| 810 |
+
SQLITE_DB_PATH=./feed_cache.db
|
| 811 |
+
|
| 812 |
+
# MLflow (DagsHub)
|
| 813 |
+
MLFLOW_TRACKING_URI=https://dagshub.com/...
|
| 814 |
+
MLFLOW_TRACKING_USERNAME=...
|
| 815 |
+
MLFLOW_TRACKING_PASSWORD=...
|
| 816 |
+
|
| 817 |
+
# Pipeline
|
| 818 |
+
BATCH_THRESHOLD=1000
|
| 819 |
+
```
|
| 820 |
+
|
| 821 |
+
---
|
| 822 |
+
|
| 823 |
+
## 🐛 Troubleshooting
|
| 824 |
+
|
| 825 |
+
### FastText won't install on Windows
|
| 826 |
+
```bash
|
| 827 |
+
# Use pre-built wheel instead
|
| 828 |
+
pip install fasttext-wheel
|
| 829 |
+
```
|
| 830 |
+
|
| 831 |
+
### BERT models downloading slowly
|
| 832 |
+
```bash
|
| 833 |
+
# Pre-download all models
|
| 834 |
+
python models/anomaly-detection/download_models.py
|
| 835 |
+
```
|
| 836 |
+
|
| 837 |
+
### Airflow not starting
|
| 838 |
+
```bash
|
| 839 |
+
# Ensure Docker is running
|
| 840 |
+
docker info
|
| 841 |
+
|
| 842 |
+
# Initialize Astro project
|
| 843 |
+
cd models/anomaly-detection
|
| 844 |
+
astro dev init
|
| 845 |
+
astro dev start
|
| 846 |
+
```
|
| 847 |
+
|
| 848 |
+
---
|
| 849 |
+
|
| 850 |
+
## 📄 License
|
| 851 |
+
|
| 852 |
+
MIT License - Built for Production
|
| 853 |
+
|
| 854 |
+
---
|
| 855 |
+
|
| 856 |
+
## 🙏 Acknowledgments
|
| 857 |
+
|
| 858 |
+
- **Groq** - High-speed LLM inference
|
| 859 |
+
- **LangGraph** - Agent orchestration
|
| 860 |
+
- **HuggingFace** - SinhalaBERTo, Tamil-BERT, DistilBERT
|
| 861 |
+
- **Optuna** - Hyperparameter optimization
|
| 862 |
+
- **MLflow** - Experiment tracking
|
| 863 |
+
- Sri Lankan government for open data sources
|
app.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
Streamlit Dashboard for Roger Platform
|
| 4 |
+
Interactive interface with Infinite Auto-Refresh & Smart Updates
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import json
|
| 8 |
+
import hashlib
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import plotly.graph_objects as go
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
# Import Roger components
|
| 14 |
+
# NOTE: Ensure these imports work in your local environment
|
| 15 |
+
from src.graphs.RogerGraph import graph
|
| 16 |
+
from src.states.combinedAgentState import CombinedAgentState
|
| 17 |
+
|
| 18 |
+
# ============================================
|
| 19 |
+
# PAGE CONFIGURATION
|
| 20 |
+
# ============================================
|
| 21 |
+
|
| 22 |
+
st.set_page_config(
|
| 23 |
+
page_title="Roger - Situational Awareness Platform",
|
| 24 |
+
page_icon="🇱🇰",
|
| 25 |
+
layout="wide",
|
| 26 |
+
initial_sidebar_state="expanded"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# ============================================
|
| 30 |
+
# CUSTOM CSS
|
| 31 |
+
# ============================================
|
| 32 |
+
|
| 33 |
+
st.markdown("""
|
| 34 |
+
<style>
|
| 35 |
+
.main-header { font-size: 2.5rem; color: #FAFAFA; font-weight: bold; text-align: center; margin-bottom: 1rem; }
|
| 36 |
+
.sub-header { font-size: 1.2rem; color: #aaaaaa; text-align: center; margin-bottom: 2rem; }
|
| 37 |
+
.stApp { background-color: #0e1117; color: #FAFAFA; }
|
| 38 |
+
|
| 39 |
+
/* Severity Colors for Badges */
|
| 40 |
+
.severity-critical { color: #ff2b2b; font-weight: 800; }
|
| 41 |
+
.severity-high { color: #ff4444; font-weight: bold; }
|
| 42 |
+
.severity-medium { color: #ff9800; font-weight: bold; }
|
| 43 |
+
.severity-low { color: #4caf50; font-weight: bold; }
|
| 44 |
+
|
| 45 |
+
/* Opportunity Color */
|
| 46 |
+
.impact-opportunity { color: #00CC96; font-weight: bold; }
|
| 47 |
+
|
| 48 |
+
/* Loading Screen Animation */
|
| 49 |
+
@keyframes pulse {
|
| 50 |
+
0% { opacity: 0.5; }
|
| 51 |
+
50% { opacity: 1; }
|
| 52 |
+
100% { opacity: 0.5; }
|
| 53 |
+
}
|
| 54 |
+
.loading-text {
|
| 55 |
+
color: #00CC96;
|
| 56 |
+
font-family: monospace;
|
| 57 |
+
font-size: 1.5rem;
|
| 58 |
+
text-align: center;
|
| 59 |
+
animation: pulse 2s infinite;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* Card Styling */
|
| 63 |
+
.event-card {
|
| 64 |
+
border-left: 4px solid #444;
|
| 65 |
+
padding: 10px;
|
| 66 |
+
margin-bottom: 10px;
|
| 67 |
+
background-color: #262730;
|
| 68 |
+
border-radius: 4px;
|
| 69 |
+
}
|
| 70 |
+
</style>
|
| 71 |
+
""", unsafe_allow_html=True)
|
| 72 |
+
|
| 73 |
+
# ============================================
|
| 74 |
+
# HEADER
|
| 75 |
+
# ============================================
|
| 76 |
+
|
| 77 |
+
st.markdown('<div class="main-header">🇱🇰 Roger</div>', unsafe_allow_html=True)
|
| 78 |
+
st.markdown('<div class="sub-header">National Situational Awareness Platform</div>', unsafe_allow_html=True)
|
| 79 |
+
|
| 80 |
+
# ============================================
|
| 81 |
+
# SIDEBAR
|
| 82 |
+
# ============================================
|
| 83 |
+
|
| 84 |
+
with st.sidebar:
|
| 85 |
+
st.header("⚙️ Configuration")
|
| 86 |
+
|
| 87 |
+
# Auto-refresh interval
|
| 88 |
+
refresh_rate = st.slider("Polling Interval (s)", 5, 60, 10)
|
| 89 |
+
|
| 90 |
+
st.divider()
|
| 91 |
+
|
| 92 |
+
# Control Buttons
|
| 93 |
+
col_start, col_stop = st.columns(2)
|
| 94 |
+
with col_start:
|
| 95 |
+
if st.button("▶ START", type="primary", use_container_width=True):
|
| 96 |
+
st.session_state.monitoring_active = True
|
| 97 |
+
st.rerun()
|
| 98 |
+
|
| 99 |
+
with col_stop:
|
| 100 |
+
if st.button("⏹ STOP", use_container_width=True):
|
| 101 |
+
st.session_state.monitoring_active = False
|
| 102 |
+
st.rerun()
|
| 103 |
+
|
| 104 |
+
st.divider()
|
| 105 |
+
st.info("""
|
| 106 |
+
**Team Adagard** Open Innovation Track
|
| 107 |
+
|
| 108 |
+
Roger transforms national-scale noise into actionable business intelligence using autonomous multi-agent architecture.
|
| 109 |
+
""")
|
| 110 |
+
st.code("START → Fan-Out → [Agents] → Fan-In → Dashboard → Loop", language="text")
|
| 111 |
+
|
| 112 |
+
# ============================================
|
| 113 |
+
# SESSION STATE INITIALIZATION
|
| 114 |
+
# ============================================
|
| 115 |
+
|
| 116 |
+
if "monitoring_active" not in st.session_state:
|
| 117 |
+
st.session_state.monitoring_active = False
|
| 118 |
+
if "latest_result" not in st.session_state:
|
| 119 |
+
st.session_state.latest_result = None
|
| 120 |
+
if "last_hash" not in st.session_state:
|
| 121 |
+
st.session_state.last_hash = ""
|
| 122 |
+
if "execution_count" not in st.session_state:
|
| 123 |
+
st.session_state.execution_count = 0
|
| 124 |
+
|
| 125 |
+
# ============================================
|
| 126 |
+
# HELPER FUNCTIONS
|
| 127 |
+
# ============================================
|
| 128 |
+
|
| 129 |
+
def calculate_hash(data_dict):
|
| 130 |
+
"""Creates a hash of the dashboard data to detect changes."""
|
| 131 |
+
# We focus on the snapshot and the feed length/content
|
| 132 |
+
snapshot = data_dict.get("risk_dashboard_snapshot", {})
|
| 133 |
+
feed = data_dict.get("final_ranked_feed", [])
|
| 134 |
+
|
| 135 |
+
# Create a simplified string representation to hash
|
| 136 |
+
content_str = f"{snapshot.get('last_updated')}-{len(feed)}-{snapshot.get('opportunity_index')}"
|
| 137 |
+
return hashlib.md5(content_str.encode()).hexdigest()
|
| 138 |
+
|
| 139 |
+
def render_dashboard(container, result):
|
| 140 |
+
"""Renders the entire dashboard into the provided container."""
|
| 141 |
+
snapshot = result.get("risk_dashboard_snapshot", {})
|
| 142 |
+
feed = result.get("final_ranked_feed", [])
|
| 143 |
+
|
| 144 |
+
# Clear the container to ensure clean re-render
|
| 145 |
+
container.empty()
|
| 146 |
+
|
| 147 |
+
with container.container():
|
| 148 |
+
st.divider()
|
| 149 |
+
|
| 150 |
+
# -------------------------------------------------------------------------
|
| 151 |
+
# 1. METRICS ROW
|
| 152 |
+
# -------------------------------------------------------------------------
|
| 153 |
+
st.subheader("📊 Operational Metrics")
|
| 154 |
+
m1, m2, m3, m4 = st.columns(4)
|
| 155 |
+
|
| 156 |
+
with m1:
|
| 157 |
+
st.metric("Logistics Friction", f"{snapshot.get('logistics_friction', 0):.3f}", help="Route risk score")
|
| 158 |
+
with m2:
|
| 159 |
+
st.metric("Compliance Volatility", f"{snapshot.get('compliance_volatility', 0):.3f}", help="Regulatory risk")
|
| 160 |
+
with m3:
|
| 161 |
+
st.metric("Market Instability", f"{snapshot.get('market_instability', 0):.3f}", help="Economic volatility")
|
| 162 |
+
with m4:
|
| 163 |
+
opp_val = snapshot.get("opportunity_index", 0.0)
|
| 164 |
+
st.metric("Opportunity Index", f"{opp_val:.3f}", delta="Growth Signal" if opp_val > 0.5 else "Neutral", delta_color="normal")
|
| 165 |
+
|
| 166 |
+
# -------------------------------------------------------------------------
|
| 167 |
+
# 2. RADAR CHART
|
| 168 |
+
# -------------------------------------------------------------------------
|
| 169 |
+
st.divider()
|
| 170 |
+
c1, c2 = st.columns([1, 1])
|
| 171 |
+
|
| 172 |
+
with c1:
|
| 173 |
+
st.subheader("📡 Risk vs. Opportunity Radar")
|
| 174 |
+
|
| 175 |
+
categories = ['Logistics', 'Compliance', 'Market', 'Social', 'Weather']
|
| 176 |
+
risk_vals = [
|
| 177 |
+
snapshot.get('logistics_friction', 0),
|
| 178 |
+
snapshot.get('compliance_volatility', 0),
|
| 179 |
+
snapshot.get('market_instability', 0),
|
| 180 |
+
0.4, 0.2
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
fig = go.Figure()
|
| 184 |
+
|
| 185 |
+
# Risk Layer
|
| 186 |
+
fig.add_trace(go.Scatterpolar(
|
| 187 |
+
r=risk_vals, theta=categories, fill='toself', name='Operational Risk',
|
| 188 |
+
line_color='#ff4444'
|
| 189 |
+
))
|
| 190 |
+
|
| 191 |
+
# Opportunity Layer
|
| 192 |
+
fig.add_trace(go.Scatterpolar(
|
| 193 |
+
r=[opp_val] * 5, theta=categories, name='Opportunity Threshold',
|
| 194 |
+
line_color='#00CC96', opacity=0.7, line=dict(dash='dot')
|
| 195 |
+
))
|
| 196 |
+
|
| 197 |
+
fig.update_layout(
|
| 198 |
+
polar=dict(radialaxis=dict(visible=True, range=[0, 1])),
|
| 199 |
+
showlegend=True,
|
| 200 |
+
height=350,
|
| 201 |
+
margin=dict(l=40, r=40, t=20, b=20),
|
| 202 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 203 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 204 |
+
font=dict(color="white")
|
| 205 |
+
)
|
| 206 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 207 |
+
|
| 208 |
+
# -------------------------------------------------------------------------
|
| 209 |
+
# 3. INTELLIGENCE FEED
|
| 210 |
+
# -------------------------------------------------------------------------
|
| 211 |
+
with c2:
|
| 212 |
+
st.subheader("📰 Intelligence Feed")
|
| 213 |
+
|
| 214 |
+
tab_all, tab_risk, tab_opp = st.tabs(["All Events", "Risks ⚠️", "Opportunities 🚀"])
|
| 215 |
+
|
| 216 |
+
def render_feed(filter_type=None):
|
| 217 |
+
if not feed:
|
| 218 |
+
st.info("No events detected.")
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
count = 0
|
| 222 |
+
for event in feed[:15]:
|
| 223 |
+
imp = event.get("impact_type", "risk")
|
| 224 |
+
if filter_type and imp != filter_type: continue
|
| 225 |
+
|
| 226 |
+
border_color = "#ff4444" if imp == "risk" else "#00CC96"
|
| 227 |
+
icon = "⚠️" if imp == "risk" else "🚀"
|
| 228 |
+
|
| 229 |
+
summary = event.get("content_summary", "")
|
| 230 |
+
domain = event.get("target_agent", "unknown").upper()
|
| 231 |
+
score = event.get("confidence_score", 0.0)
|
| 232 |
+
|
| 233 |
+
st.markdown(
|
| 234 |
+
f"""
|
| 235 |
+
<div style="border-left: 4px solid {border_color}; padding: 10px; margin-bottom: 10px; background-color: #262730; border-radius: 4px;">
|
| 236 |
+
<div style="font-size: 0.8em; color: #aaa; display: flex; justify-content: space-between;">
|
| 237 |
+
<span>{domain}</span>
|
| 238 |
+
<span>SCORE: {score:.2f}</span>
|
| 239 |
+
</div>
|
| 240 |
+
<div style="margin-top: 4px; font-weight: 500;">
|
| 241 |
+
{icon} {summary}
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
""",
|
| 245 |
+
unsafe_allow_html=True
|
| 246 |
+
)
|
| 247 |
+
count += 1
|
| 248 |
+
|
| 249 |
+
if count == 0:
|
| 250 |
+
st.caption("No events in this category.")
|
| 251 |
+
|
| 252 |
+
with tab_all: render_feed()
|
| 253 |
+
with tab_risk: render_feed("risk")
|
| 254 |
+
with tab_opp: render_feed("opportunity")
|
| 255 |
+
|
| 256 |
+
st.divider()
|
| 257 |
+
st.caption(f"Last Updated: {datetime.utcnow().strftime('%H:%M:%S UTC')} | Run Count: {st.session_state.execution_count}")
|
| 258 |
+
|
| 259 |
+
# ============================================
|
| 260 |
+
# MAIN EXECUTION LOGIC
|
| 261 |
+
# ============================================
|
| 262 |
+
|
| 263 |
+
# We use a placeholder that we can overwrite dynamically
|
| 264 |
+
dashboard_placeholder = st.empty()
|
| 265 |
+
|
| 266 |
+
if st.session_state.monitoring_active:
|
| 267 |
+
|
| 268 |
+
# ---------------------------------------------------------
|
| 269 |
+
# PHASE 1: INITIAL LOAD (Runs only if we have NO data)
|
| 270 |
+
# ---------------------------------------------------------
|
| 271 |
+
if st.session_state.latest_result is None:
|
| 272 |
+
with dashboard_placeholder.container():
|
| 273 |
+
st.markdown("<br><br><br>", unsafe_allow_html=True)
|
| 274 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 275 |
+
with col2:
|
| 276 |
+
st.markdown('<div class="loading-text">INITIALIZING NEURAL AGENTS...</div>', unsafe_allow_html=True)
|
| 277 |
+
st.markdown('<div style="text-align:center; color:#666;">Connecting to Roger Graph Network</div>', unsafe_allow_html=True)
|
| 278 |
+
progress_bar = st.progress(0)
|
| 279 |
+
|
| 280 |
+
# Visual effect for initialization
|
| 281 |
+
steps = ["Loading Social Graph...", "Connecting to Market Data...", "Calibrating Risk Radar...", "Starting Fan-Out Sequence..."]
|
| 282 |
+
for i, step in enumerate(steps):
|
| 283 |
+
time.sleep(0.3)
|
| 284 |
+
progress_bar.progress((i + 1) * 25)
|
| 285 |
+
|
| 286 |
+
# --- PERFORM FIRST FETCH ---
|
| 287 |
+
try:
|
| 288 |
+
current_state = CombinedAgentState(max_runs=1, run_count=0)
|
| 289 |
+
result = graph.invoke(current_state)
|
| 290 |
+
|
| 291 |
+
# Save to session state
|
| 292 |
+
st.session_state.latest_result = result
|
| 293 |
+
st.session_state.last_hash = calculate_hash(result)
|
| 294 |
+
st.session_state.execution_count = 1
|
| 295 |
+
|
| 296 |
+
except Exception as e:
|
| 297 |
+
st.error(f"Initialization Error: {e}")
|
| 298 |
+
st.session_state.monitoring_active = False
|
| 299 |
+
st.stop()
|
| 300 |
+
|
| 301 |
+
# ---------------------------------------------------------
|
| 302 |
+
# PHASE 2: CONTINUOUS MONITORING LOOP
|
| 303 |
+
# ---------------------------------------------------------
|
| 304 |
+
# By this point, st.session_state.latest_result is GUARANTEED to have data.
|
| 305 |
+
|
| 306 |
+
while st.session_state.monitoring_active:
|
| 307 |
+
|
| 308 |
+
# 1. RENDER CURRENT DATA
|
| 309 |
+
# We render whatever is in the state immediately.
|
| 310 |
+
# This replaces the loading screen or the previous frame.
|
| 311 |
+
render_dashboard(dashboard_placeholder, st.session_state.latest_result)
|
| 312 |
+
|
| 313 |
+
# 2. WAIT (The "Background" part)
|
| 314 |
+
# The UI is now visible to the user while we sleep.
|
| 315 |
+
time.sleep(refresh_rate)
|
| 316 |
+
|
| 317 |
+
# 3. FETCH NEW DATA
|
| 318 |
+
try:
|
| 319 |
+
current_state = CombinedAgentState(max_runs=1, run_count=st.session_state.execution_count)
|
| 320 |
+
# Run the graph silently in background
|
| 321 |
+
new_result = graph.invoke(current_state)
|
| 322 |
+
|
| 323 |
+
# 4. CHECK FOR DIFFERENCES
|
| 324 |
+
new_hash = calculate_hash(new_result)
|
| 325 |
+
|
| 326 |
+
if new_hash != st.session_state.last_hash:
|
| 327 |
+
# DATA CHANGED: Update state
|
| 328 |
+
st.session_state.last_hash = new_hash
|
| 329 |
+
st.session_state.latest_result = new_result
|
| 330 |
+
st.session_state.execution_count += 1
|
| 331 |
+
|
| 332 |
+
# Optional: Pop a toast
|
| 333 |
+
st.toast(f"New Intel Detected ({len(new_result.get('final_ranked_feed', []))} events)", icon="⚡")
|
| 334 |
+
|
| 335 |
+
# The loop continues...
|
| 336 |
+
# The NEXT iteration (Step 1) will render this new data.
|
| 337 |
+
else:
|
| 338 |
+
# NO CHANGE:
|
| 339 |
+
# We do nothing. The loop continues.
|
| 340 |
+
# Step 1 will simply re-render the existing stable data.
|
| 341 |
+
pass
|
| 342 |
+
|
| 343 |
+
except Exception as e:
|
| 344 |
+
st.error(f"Monitoring Error: {e}")
|
| 345 |
+
time.sleep(5) # Wait before retrying on error
|
| 346 |
+
|
| 347 |
+
else:
|
| 348 |
+
# ---------------------------------------------------------
|
| 349 |
+
# IDLE STATE
|
| 350 |
+
# ---------------------------------------------------------
|
| 351 |
+
with dashboard_placeholder.container():
|
| 352 |
+
st.markdown("<br><br>", unsafe_allow_html=True)
|
| 353 |
+
col1, col2, col3 = st.columns([1, 4, 1])
|
| 354 |
+
with col2:
|
| 355 |
+
st.info("System Standby. Click '▶ START' in the sidebar to begin autonomous monitoring.")
|
| 356 |
+
|
| 357 |
+
if st.session_state.latest_result:
|
| 358 |
+
st.markdown("### Last Session Snapshot:")
|
| 359 |
+
# We use a temporary container here just for the snapshot
|
| 360 |
+
with st.container():
|
| 361 |
+
render_dashboard(st.empty(), st.session_state.latest_result)
|
debug_path.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Debug path calculation
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# Simulate the path from data_transformation.py
|
| 5 |
+
file_path = Path(r"C:\Users\LENOVO\Desktop\Roger-Ultimate\models\anomaly-detection\src\components\data_transformation.py")
|
| 6 |
+
|
| 7 |
+
print("File:", file_path)
|
| 8 |
+
print()
|
| 9 |
+
print("1 up (.parent):", file_path.parent) # components
|
| 10 |
+
print("2 up:", file_path.parent.parent) # src
|
| 11 |
+
print("3 up:", file_path.parent.parent.parent) # anomaly-detection
|
| 12 |
+
print("4 up:", file_path.parent.parent.parent.parent) # models
|
| 13 |
+
print("5 up:", file_path.parent.parent.parent.parent.parent) # Roger-Ultimate (CORRECT!)
|
| 14 |
+
print()
|
| 15 |
+
|
| 16 |
+
main_project = file_path.parent.parent.parent.parent.parent
|
| 17 |
+
print("Main project root:", main_project)
|
| 18 |
+
print("Should be:", r"C:\Users\LENOVO\Desktop\Roger-Ultimate")
|
| 19 |
+
print("Match:", str(main_project) == r"C:\Users\LENOVO\Desktop\Roger-Ultimate")
|
| 20 |
+
|
| 21 |
+
# Check if src/graphs exists
|
| 22 |
+
src_graphs = main_project / "src" / "graphs"
|
| 23 |
+
print()
|
| 24 |
+
print("src/graphs path:", src_graphs)
|
| 25 |
+
print("Exists:", src_graphs.exists())
|
| 26 |
+
|
| 27 |
+
# Check vectorizationAgentGraph
|
| 28 |
+
vec_graph = src_graphs / "vectorizationAgentGraph.py"
|
| 29 |
+
print("vectorizationAgentGraph.py:", vec_graph)
|
| 30 |
+
print("Exists:", vec_graph.exists())
|
debug_runner.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
# Ensure we can find the 'src' module from the root
|
| 7 |
+
sys.path.append(os.getcwd())
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from src.utils.utils import (
|
| 11 |
+
scrape_facebook,
|
| 12 |
+
scrape_twitter,
|
| 13 |
+
scrape_local_news,
|
| 14 |
+
scrape_reddit,
|
| 15 |
+
scrape_government_gazette,
|
| 16 |
+
scrape_cse_stock_data,
|
| 17 |
+
tool_weather_nowcast,
|
| 18 |
+
tool_dmc_alerts,
|
| 19 |
+
scrape_linkedin,
|
| 20 |
+
scrape_instagram,
|
| 21 |
+
)
|
| 22 |
+
print("✅ Libraries loaded successfully.\n")
|
| 23 |
+
except ImportError as e:
|
| 24 |
+
print(f"❌ Error loading libraries: {e}")
|
| 25 |
+
print("Make sure you are running this from the 'Roger-Final' folder.")
|
| 26 |
+
sys.exit(1)
|
| 27 |
+
|
| 28 |
+
def print_separator(char="=", length=70):
|
| 29 |
+
print(char * length)
|
| 30 |
+
|
| 31 |
+
def print_header(text):
|
| 32 |
+
print_separator()
|
| 33 |
+
print(f" {text}")
|
| 34 |
+
print_separator()
|
| 35 |
+
|
| 36 |
+
def run_test(name, func, description="", **kwargs):
|
| 37 |
+
print(f"\n🔍 Testing: {name}")
|
| 38 |
+
if description:
|
| 39 |
+
print(f" {description}")
|
| 40 |
+
print("-" * 70)
|
| 41 |
+
|
| 42 |
+
start_time = datetime.now()
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# Check if it's a LangChain tool (needs .invoke)
|
| 46 |
+
if hasattr(func, "invoke"):
|
| 47 |
+
res = func.invoke(kwargs)
|
| 48 |
+
else:
|
| 49 |
+
res = func(**kwargs)
|
| 50 |
+
|
| 51 |
+
elapsed = (datetime.now() - start_time).total_seconds()
|
| 52 |
+
|
| 53 |
+
# Try to print pretty JSON
|
| 54 |
+
try:
|
| 55 |
+
parsed = json.loads(res)
|
| 56 |
+
|
| 57 |
+
# Custom formatting for better readability
|
| 58 |
+
if isinstance(parsed, dict):
|
| 59 |
+
if "results" in parsed:
|
| 60 |
+
print(f"\n✅ Success! Found {len(parsed.get('results', []))} results in {elapsed:.2f}s")
|
| 61 |
+
print(f"\nSample Results:")
|
| 62 |
+
for i, item in enumerate(parsed['results'][:3], 1):
|
| 63 |
+
print(f"\n [{i}] {item.get('title', 'No title')}")
|
| 64 |
+
if 'snippet' in item:
|
| 65 |
+
snippet = item['snippet'][:150] + "..." if len(item['snippet']) > 150 else item['snippet']
|
| 66 |
+
print(f" {snippet}")
|
| 67 |
+
if 'url' in item:
|
| 68 |
+
print(f" 🔗 {item['url']}")
|
| 69 |
+
else:
|
| 70 |
+
print(f"\n✅ Success in {elapsed:.2f}s")
|
| 71 |
+
print(json.dumps(parsed, indent=2)[:1000])
|
| 72 |
+
else:
|
| 73 |
+
print(json.dumps(parsed, indent=2)[:1000])
|
| 74 |
+
|
| 75 |
+
except:
|
| 76 |
+
print(res[:1000] if len(res) > 1000 else res)
|
| 77 |
+
|
| 78 |
+
print(f"\n⏱️ Completed in {elapsed:.2f} seconds")
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"❌ Error: {e}")
|
| 82 |
+
|
| 83 |
+
print("-" * 70)
|
| 84 |
+
|
| 85 |
+
def check_sessions():
|
| 86 |
+
"""Check which session files exist"""
|
| 87 |
+
print_header("Session Status Check")
|
| 88 |
+
|
| 89 |
+
session_paths = [
|
| 90 |
+
"src/utils/.sessions",
|
| 91 |
+
".sessions"
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
platforms = ["facebook", "twitter", "linkedin", "instagram", "reddit"]
|
| 95 |
+
found_sessions = []
|
| 96 |
+
print("session_path: ", session_paths)
|
| 97 |
+
|
| 98 |
+
for path in session_paths:
|
| 99 |
+
if os.path.exists(path):
|
| 100 |
+
print(f"\n📁 Checking {path}/")
|
| 101 |
+
for platform in platforms:
|
| 102 |
+
session_file = os.path.join(path, f"{platform}_storage_state.json")
|
| 103 |
+
if os.path.exists(session_file):
|
| 104 |
+
size = os.path.getsize(session_file)
|
| 105 |
+
print(f" ✅ {platform:12} ({size:,} bytes)")
|
| 106 |
+
found_sessions.append(platform)
|
| 107 |
+
else:
|
| 108 |
+
print(f" ❌ {platform:12} (not found)")
|
| 109 |
+
|
| 110 |
+
if not found_sessions:
|
| 111 |
+
print("\n⚠️ No session files found!")
|
| 112 |
+
print(" Run 'python src/utils/session_manager.py' to create sessions.")
|
| 113 |
+
|
| 114 |
+
print_separator()
|
| 115 |
+
return found_sessions
|
| 116 |
+
|
| 117 |
+
def main():
|
| 118 |
+
print_header("Roger Debug Runner - Comprehensive Tool Testing")
|
| 119 |
+
|
| 120 |
+
print("\n📋 Available Test Categories:")
|
| 121 |
+
print(" 1. Weather & Alerts (No auth required)")
|
| 122 |
+
print(" 2. News & Government (No auth required)")
|
| 123 |
+
print(" 3. Financial Data (No auth required)")
|
| 124 |
+
print(" 4. Social Media (Requires auth)")
|
| 125 |
+
print(" 5. Check Sessions")
|
| 126 |
+
print(" 6. Run All Tests")
|
| 127 |
+
print(" q. Quit")
|
| 128 |
+
|
| 129 |
+
choice = input("\nSelect category (1-6 or q): ").strip()
|
| 130 |
+
|
| 131 |
+
if choice == "q":
|
| 132 |
+
return
|
| 133 |
+
|
| 134 |
+
if choice == "5":
|
| 135 |
+
check_sessions()
|
| 136 |
+
return
|
| 137 |
+
|
| 138 |
+
# === CATEGORY 1: Weather & Alerts ===
|
| 139 |
+
if choice in ["1", "6"]:
|
| 140 |
+
print_header("CATEGORY 1: Weather & Alerts")
|
| 141 |
+
|
| 142 |
+
run_test(
|
| 143 |
+
"Weather Nowcast",
|
| 144 |
+
tool_weather_nowcast,
|
| 145 |
+
"Comprehensive weather data from Department of Meteorology",
|
| 146 |
+
location="Colombo"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
run_test(
|
| 150 |
+
"DMC Alerts",
|
| 151 |
+
tool_dmc_alerts,
|
| 152 |
+
"Disaster Management Centre severe weather alerts"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# === CATEGORY 2: News & Government ===
|
| 156 |
+
if choice in ["2", "6"]:
|
| 157 |
+
print_header("CATEGORY 2: News & Government")
|
| 158 |
+
|
| 159 |
+
run_test(
|
| 160 |
+
"Local News",
|
| 161 |
+
scrape_local_news,
|
| 162 |
+
"Scraping Daily Mirror, Daily FT, News First",
|
| 163 |
+
keywords=["economy", "politics"],
|
| 164 |
+
max_articles=5
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
run_test(
|
| 168 |
+
"Government Gazette",
|
| 169 |
+
scrape_government_gazette,
|
| 170 |
+
"Latest gazette notifications",
|
| 171 |
+
keywords=["regulation"],
|
| 172 |
+
max_items=3
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# === CATEGORY 3: Financial Data ===
|
| 176 |
+
if choice in ["3", "6"]:
|
| 177 |
+
print_header("CATEGORY 3: Financial Data")
|
| 178 |
+
|
| 179 |
+
run_test(
|
| 180 |
+
"CSE Stock Data",
|
| 181 |
+
scrape_cse_stock_data,
|
| 182 |
+
"Colombo Stock Exchange - ASPI Index",
|
| 183 |
+
symbol="ASPI",
|
| 184 |
+
period="1d"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# === CATEGORY 4: Social Media ===
|
| 188 |
+
if choice in ["4", "6"]:
|
| 189 |
+
print_header("CATEGORY 4: Social Media (Authentication Required)")
|
| 190 |
+
|
| 191 |
+
available_sessions = check_sessions()
|
| 192 |
+
|
| 193 |
+
if "facebook" in available_sessions:
|
| 194 |
+
run_test(
|
| 195 |
+
"Facebook",
|
| 196 |
+
scrape_facebook,
|
| 197 |
+
"Facebook search results",
|
| 198 |
+
keywords=["Sri Lanka", "Elon musk", "business"],
|
| 199 |
+
max_items=5
|
| 200 |
+
)
|
| 201 |
+
else:
|
| 202 |
+
print("\n⚠️ Facebook session not found - skipping")
|
| 203 |
+
|
| 204 |
+
if "instagram" in available_sessions:
|
| 205 |
+
run_test(
|
| 206 |
+
"Instagram",
|
| 207 |
+
scrape_instagram,
|
| 208 |
+
"Instagram search results",
|
| 209 |
+
keywords=["Sri Lanka", "Elon musk", "business"],
|
| 210 |
+
max_items=5
|
| 211 |
+
)
|
| 212 |
+
else:
|
| 213 |
+
print("\n⚠️ Facebook session not found - skipping")
|
| 214 |
+
|
| 215 |
+
if "linkedin" in available_sessions:
|
| 216 |
+
run_test(
|
| 217 |
+
"Linkedin",
|
| 218 |
+
scrape_linkedin,
|
| 219 |
+
"Linkedin search results",
|
| 220 |
+
keywords=["Sri Lanka", "Elon musk", "business"],
|
| 221 |
+
max_items=5
|
| 222 |
+
)
|
| 223 |
+
else:
|
| 224 |
+
print("\n⚠️ Facebook session not found - skipping")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
if "twitter" in available_sessions:
|
| 228 |
+
run_test(
|
| 229 |
+
"Twitter",
|
| 230 |
+
scrape_twitter,
|
| 231 |
+
"Twitter/X search",
|
| 232 |
+
query="Sri Lanka economy"
|
| 233 |
+
)
|
| 234 |
+
else:
|
| 235 |
+
print("\n⚠️ Twitter session not found - skipping")
|
| 236 |
+
|
| 237 |
+
# Reddit doesn't need session
|
| 238 |
+
run_test(
|
| 239 |
+
"Reddit",
|
| 240 |
+
scrape_reddit,
|
| 241 |
+
"Reddit posts (no auth needed)",
|
| 242 |
+
keywords=["Sri Lanka"],
|
| 243 |
+
limit=5
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
print_header("Testing Complete!")
|
| 247 |
+
print(f"\n⏰ Finished at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 248 |
+
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
main()
|
docker-compose.prod.yml
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# Neo4j Knowledge Graph (Production Feature)
|
| 5 |
+
neo4j:
|
| 6 |
+
image: neo4j:5.15-community
|
| 7 |
+
container_name: modelx-neo4j
|
| 8 |
+
ports:
|
| 9 |
+
- "7474:7474" # Browser UI
|
| 10 |
+
- "7687:7687" # Bolt protocol
|
| 11 |
+
environment:
|
| 12 |
+
NEO4J_AUTH: neo4j/modelx2024
|
| 13 |
+
NEO4J_PLUGINS: '["apoc"]'
|
| 14 |
+
NEO4J_dbms_security_procedures_unrestricted: "apoc.*"
|
| 15 |
+
volumes:
|
| 16 |
+
- ./data/neo4j/data:/data
|
| 17 |
+
- ./data/neo4j/logs:/logs
|
| 18 |
+
healthcheck:
|
| 19 |
+
test: ["CMD-SHELL", "cypher-shell -u neo4j -p modelx2024 'RETURN 1' || exit 1"]
|
| 20 |
+
interval: 10s
|
| 21 |
+
timeout: 5s
|
| 22 |
+
retries: 5
|
| 23 |
+
|
| 24 |
+
# Backend API
|
| 25 |
+
backend:
|
| 26 |
+
build:
|
| 27 |
+
context: .
|
| 28 |
+
dockerfile: Dockerfile
|
| 29 |
+
ports:
|
| 30 |
+
- "8000:8000"
|
| 31 |
+
environment:
|
| 32 |
+
- GROQ_API_KEY=${GROQ_API_KEY}
|
| 33 |
+
- PYTHONUNBUFFERED=1
|
| 34 |
+
- NEO4J_ENABLED=true
|
| 35 |
+
- NEO4J_URI=bolt://neo4j:7687
|
| 36 |
+
- NEO4J_USER=neo4j
|
| 37 |
+
- NEO4J_PASSWORD=modelx2024
|
| 38 |
+
volumes:
|
| 39 |
+
- ./src:/app/src
|
| 40 |
+
- ./data:/app/data # Persist storage data
|
| 41 |
+
command: python main.py
|
| 42 |
+
depends_on:
|
| 43 |
+
neo4j:
|
| 44 |
+
condition: service_healthy
|
| 45 |
+
healthcheck:
|
| 46 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/api/status"]
|
| 47 |
+
interval: 30s
|
| 48 |
+
timeout: 10s
|
| 49 |
+
retries: 3
|
| 50 |
+
|
| 51 |
+
# Frontend (Next.js)
|
| 52 |
+
frontend:
|
| 53 |
+
build:
|
| 54 |
+
context: ./frontend
|
| 55 |
+
dockerfile: Dockerfile
|
| 56 |
+
ports:
|
| 57 |
+
- "3000:3000"
|
| 58 |
+
environment:
|
| 59 |
+
- NEXT_PUBLIC_API_URL=http://backend:8000
|
| 60 |
+
depends_on:
|
| 61 |
+
- backend
|
| 62 |
+
volumes:
|
| 63 |
+
- ./frontend:/app
|
| 64 |
+
- /app/node_modules
|
| 65 |
+
- /app/. next
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# Backend API
|
| 5 |
+
backend:
|
| 6 |
+
build:
|
| 7 |
+
context: .
|
| 8 |
+
dockerfile: backend/Dockerfile
|
| 9 |
+
ports:
|
| 10 |
+
- "8000:8000"
|
| 11 |
+
environment:
|
| 12 |
+
- GROQ_API_KEY=${GROQ_API_KEY}
|
| 13 |
+
- PYTHONUNBUFFERED=1
|
| 14 |
+
volumes:
|
| 15 |
+
- ./src:/app/src
|
| 16 |
+
- ./backend:/app/backend
|
| 17 |
+
command: python backend/api/main.py
|
| 18 |
+
healthcheck:
|
| 19 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/api/status"]
|
| 20 |
+
interval: 30s
|
| 21 |
+
timeout: 10s
|
| 22 |
+
retries: 3
|
| 23 |
+
|
| 24 |
+
# Frontend (Next.js)
|
| 25 |
+
frontend:
|
| 26 |
+
build:
|
| 27 |
+
context: ./frontend
|
| 28 |
+
dockerfile: Dockerfile
|
| 29 |
+
ports:
|
| 30 |
+
- "3000:3000"
|
| 31 |
+
environment:
|
| 32 |
+
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 33 |
+
depends_on:
|
| 34 |
+
- backend
|
| 35 |
+
volumes:
|
| 36 |
+
- ./frontend:/app
|
| 37 |
+
- /app/node_modules
|
| 38 |
+
- /app/.next
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/app/components/App.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { Routes, Route } from "react-router-dom";
|
| 4 |
+
import ClientWrapper from "./ClientWrapper";
|
| 5 |
+
import Index from "../pages/Index";
|
| 6 |
+
import NotFound from "../pages/NotFound";
|
| 7 |
+
import FloatingChatBox from "./FloatingChatBox";
|
| 8 |
+
|
| 9 |
+
export default function App() {
|
| 10 |
+
return (
|
| 11 |
+
<ClientWrapper>
|
| 12 |
+
<Routes>
|
| 13 |
+
<Route path="/" element={<Index />} />
|
| 14 |
+
<Route path="*" element={<NotFound />} />
|
| 15 |
+
</Routes>
|
| 16 |
+
<FloatingChatBox />
|
| 17 |
+
</ClientWrapper>
|
| 18 |
+
);
|
| 19 |
+
}
|
frontend/app/components/ClientWrapper.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { ReactNode, useState } from 'react';
|
| 4 |
+
import { Toaster } from "./ui/toaster";
|
| 5 |
+
import { Toaster as Sonner } from "./ui/sonner";
|
| 6 |
+
import { TooltipProvider } from "./ui/tooltip";
|
| 7 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 8 |
+
import { BrowserRouter } from "react-router-dom";
|
| 9 |
+
|
| 10 |
+
interface ClientWrapperProps {
|
| 11 |
+
children: ReactNode;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function ClientWrapper({ children }: ClientWrapperProps) {
|
| 15 |
+
const [queryClient] = useState(() => new QueryClient());
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<QueryClientProvider client={queryClient}>
|
| 19 |
+
<TooltipProvider>
|
| 20 |
+
<Toaster />
|
| 21 |
+
<Sonner />
|
| 22 |
+
<BrowserRouter>
|
| 23 |
+
{children}
|
| 24 |
+
</BrowserRouter>
|
| 25 |
+
</TooltipProvider>
|
| 26 |
+
</QueryClientProvider>
|
| 27 |
+
);
|
| 28 |
+
}
|
frontend/app/components/FloatingChatBox.tsx
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from 'react';
|
| 4 |
+
import { Send, Brain, Trash2, Radio } from 'lucide-react';
|
| 5 |
+
import { Badge } from './ui/badge';
|
| 6 |
+
import ReactMarkdown from 'react-markdown';
|
| 7 |
+
import remarkGfm from 'remark-gfm';
|
| 8 |
+
import './Roger.css';
|
| 9 |
+
|
| 10 |
+
interface Message {
|
| 11 |
+
id: string;
|
| 12 |
+
role: 'user' | 'assistant';
|
| 13 |
+
content: string;
|
| 14 |
+
sources?: Array<{
|
| 15 |
+
domain: string;
|
| 16 |
+
platform: string;
|
| 17 |
+
similarity: number;
|
| 18 |
+
}>;
|
| 19 |
+
timestamp: Date;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const FloatingChatBox = () => {
|
| 23 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 24 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 25 |
+
const [input, setInput] = useState('');
|
| 26 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 27 |
+
const [domainFilter, setDomainFilter] = useState<string | null>(null);
|
| 28 |
+
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
| 29 |
+
|
| 30 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 31 |
+
|
| 32 |
+
// Auto-scroll to bottom
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
if (scrollContainerRef.current) {
|
| 35 |
+
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
| 36 |
+
}
|
| 37 |
+
}, [messages, isLoading]);
|
| 38 |
+
|
| 39 |
+
// Handle body scroll when chat is open (mobile)
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
if (isOpen) {
|
| 42 |
+
document.body.style.overflow = 'hidden';
|
| 43 |
+
} else {
|
| 44 |
+
document.body.style.overflow = 'unset';
|
| 45 |
+
}
|
| 46 |
+
return () => {
|
| 47 |
+
document.body.style.overflow = 'unset';
|
| 48 |
+
};
|
| 49 |
+
}, [isOpen]);
|
| 50 |
+
|
| 51 |
+
const sendMessage = async () => {
|
| 52 |
+
if (!input.trim() || isLoading) return;
|
| 53 |
+
|
| 54 |
+
const userMessage: Message = {
|
| 55 |
+
id: Date.now().toString(),
|
| 56 |
+
role: 'user',
|
| 57 |
+
content: input,
|
| 58 |
+
timestamp: new Date()
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
setMessages(prev => [...prev, userMessage]);
|
| 62 |
+
const currentInput = input;
|
| 63 |
+
setInput('');
|
| 64 |
+
setIsLoading(true);
|
| 65 |
+
|
| 66 |
+
try {
|
| 67 |
+
const response = await fetch(`${API_BASE}/api/rag/chat`, {
|
| 68 |
+
method: 'POST',
|
| 69 |
+
headers: { 'Content-Type': 'application/json' },
|
| 70 |
+
body: JSON.stringify({
|
| 71 |
+
message: currentInput,
|
| 72 |
+
domain_filter: domainFilter,
|
| 73 |
+
use_history: true
|
| 74 |
+
})
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
const data = await response.json();
|
| 78 |
+
|
| 79 |
+
const assistantMessage: Message = {
|
| 80 |
+
id: (Date.now() + 1).toString(),
|
| 81 |
+
role: 'assistant',
|
| 82 |
+
content: data.answer || 'No response received.',
|
| 83 |
+
sources: data.sources,
|
| 84 |
+
timestamp: new Date()
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
setMessages(prev => [...prev, assistantMessage]);
|
| 88 |
+
} catch (error) {
|
| 89 |
+
const errorMessage: Message = {
|
| 90 |
+
id: (Date.now() + 1).toString(),
|
| 91 |
+
role: 'assistant',
|
| 92 |
+
content: 'Failed to connect to Roger Intelligence. Please ensure the backend is running.',
|
| 93 |
+
timestamp: new Date()
|
| 94 |
+
};
|
| 95 |
+
setMessages(prev => [...prev, errorMessage]);
|
| 96 |
+
} finally {
|
| 97 |
+
setIsLoading(false);
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const clearHistory = async () => {
|
| 102 |
+
try {
|
| 103 |
+
await fetch(`${API_BASE}/api/rag/clear`, { method: 'POST' });
|
| 104 |
+
setMessages([]);
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error('Failed to clear history:', error);
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
| 111 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 112 |
+
e.preventDefault();
|
| 113 |
+
sendMessage();
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const toggleChat = () => {
|
| 118 |
+
setIsOpen(!isOpen);
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const domains = ['political', 'economic', 'weather', 'social', 'intelligence'];
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<div className={`${isOpen ? 'h-[100vh] w-[100vw]' : ''} fixed z-[9999] text-white bottom-0 right-0`}>
|
| 125 |
+
{/* Backdrop */}
|
| 126 |
+
<div
|
| 127 |
+
onClick={() => setIsOpen(false)}
|
| 128 |
+
className={`absolute top-0 left-0 w-screen h-screen bg-black transition-opacity duration-500 ${isOpen ? 'opacity-40 flex' : 'opacity-0 hidden'}`}
|
| 129 |
+
/>
|
| 130 |
+
|
| 131 |
+
{/* Roger Button */}
|
| 132 |
+
<div
|
| 133 |
+
onClick={toggleChat}
|
| 134 |
+
className={`${isOpen ? 'translate-y-[100px]' : 'translate-y-0 delay-300'} select-none transition-transform duration-500 ease-in-out absolute bottom-[15px] right-[15px] sm:bottom-[20px] sm:right-[30px] flex items-center justify-center w-fit bg-[#373435] ring-[0.5px] ring-[#727376] rounded-full cursor-pointer px-[25px] py-[8px] sm:px-[30px] sm:py-[8px] shadow-lg hover:bg-[#4a4a4a] transition-colors`}
|
| 135 |
+
>
|
| 136 |
+
<Radio className="w-5 h-5 mr-2 text-green-400" />
|
| 137 |
+
<p className="select-none text-white text-[18px] sm:text-[18px] font-semibold">Roger</p>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
{/* Chat Container */}
|
| 141 |
+
<div className={`${isOpen ? 'scale-100 delay-200' : 'scale-0'} roger-scrollbar absolute bottom-0 right-0 sm:bottom-[20px] sm:right-[30px] origin-bottom-right transition-transform duration-500 ease-in-out flex flex-col bg-[#373435] ring-[0.5px] ring-[#727376] h-[100dvh] w-[100vw] sm:h-[600px] sm:w-[420px] sm:rounded-[12px] justify-center overflow-hidden`}>
|
| 142 |
+
|
| 143 |
+
{/* Header - with safe area for iPhone notch */}
|
| 144 |
+
<div className="w-full select-none px-[16px] sm:px-[20px] bg-[#000000] text-white flex flex-row justify-between sm:rounded-t-[12px] py-[14px] sm:py-[18px] pt-[max(14px,env(safe-area-inset-top))] h-fit items-center border-b border-[#373435]">
|
| 145 |
+
<div className="flex items-center gap-3">
|
| 146 |
+
<div className="p-2 rounded-lg bg-green-500/20">
|
| 147 |
+
<Brain className="w-5 h-5 text-green-400" />
|
| 148 |
+
</div>
|
| 149 |
+
<div>
|
| 150 |
+
<p className="text-[20px] sm:text-[18px] font-semibold">Roger</p>
|
| 151 |
+
<p className="text-[12px] text-gray-400">Intelligence Assistant</p>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
<div className="flex items-center gap-2">
|
| 155 |
+
<div
|
| 156 |
+
onClick={clearHistory}
|
| 157 |
+
className="cursor-pointer bg-[#373435] hover:bg-red-500/30 p-2 rounded-lg transition-colors"
|
| 158 |
+
title="Clear chat history"
|
| 159 |
+
>
|
| 160 |
+
<Trash2 className="w-4 h-4 text-gray-400 hover:text-red-400" />
|
| 161 |
+
</div>
|
| 162 |
+
<div
|
| 163 |
+
onClick={toggleChat}
|
| 164 |
+
className="cursor-pointer bg-[#373435] hover:bg-[#4a4a4a] active:bg-[#555] px-[14px] py-[8px] sm:px-[12px] sm:py-[6px] rounded-[8px] sm:rounded-[6px] transition-colors touch-manipulation"
|
| 165 |
+
>
|
| 166 |
+
<p className="text-[14px] sm:text-[13px]">Close</p>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
{/* Domain Filter - scrollable on mobile */}
|
| 172 |
+
<div className="flex gap-1.5 sm:gap-1 px-3 sm:px-4 py-3 bg-[#1a1a1a] border-b border-[#373435] overflow-x-auto sm:flex-wrap hide-scrollbar">
|
| 173 |
+
<Badge
|
| 174 |
+
className={`cursor-pointer text-xs sm:text-xs whitespace-nowrap px-3 py-1.5 sm:px-2 sm:py-1 transition-colors touch-manipulation ${!domainFilter ? 'bg-green-500 text-white' : 'bg-[#373435] text-gray-300 hover:bg-[#4a4a4a] active:bg-[#555]'}`}
|
| 175 |
+
onClick={() => setDomainFilter(null)}
|
| 176 |
+
>
|
| 177 |
+
All
|
| 178 |
+
</Badge>
|
| 179 |
+
{domains.map(domain => (
|
| 180 |
+
<Badge
|
| 181 |
+
key={domain}
|
| 182 |
+
className={`cursor-pointer text-xs sm:text-xs whitespace-nowrap px-3 py-1.5 sm:px-2 sm:py-1 capitalize transition-colors touch-manipulation ${domainFilter === domain ? 'bg-green-500 text-white' : 'bg-[#373435] text-gray-300 hover:bg-[#4a4a4a] active:bg-[#555]'}`}
|
| 183 |
+
onClick={() => setDomainFilter(domain)}
|
| 184 |
+
>
|
| 185 |
+
{domain}
|
| 186 |
+
</Badge>
|
| 187 |
+
))}
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
{/* Messages Container */}
|
| 191 |
+
{messages.length > 0 ? (
|
| 192 |
+
<div
|
| 193 |
+
className="flex flex-col flex-1 overflow-y-auto py-4 px-4 bg-[#101010] roger-scrollbar"
|
| 194 |
+
ref={scrollContainerRef}
|
| 195 |
+
style={{
|
| 196 |
+
WebkitOverflowScrolling: 'touch',
|
| 197 |
+
overscrollBehavior: 'contain',
|
| 198 |
+
}}
|
| 199 |
+
>
|
| 200 |
+
{/* Today Badge */}
|
| 201 |
+
<div className="flex justify-center mt-1 mb-4">
|
| 202 |
+
<div className="bg-[#373435] text-[11px] px-3 py-1 rounded-full border border-[#505050]">
|
| 203 |
+
<p className="text-gray-400">Today</p>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
{messages.map((msg) => (
|
| 208 |
+
<div
|
| 209 |
+
className={`flex mb-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 210 |
+
key={msg.id}
|
| 211 |
+
>
|
| 212 |
+
<div
|
| 213 |
+
className={`max-w-[85%] rounded-[10px] py-[10px] px-[14px] text-[14px] leading-relaxed ${msg.role === 'user'
|
| 214 |
+
? 'bg-[#505050] text-white border border-[#606060]'
|
| 215 |
+
: 'bg-white text-black border border-gray-300'
|
| 216 |
+
}`}
|
| 217 |
+
>
|
| 218 |
+
{msg.role === 'assistant' ? (
|
| 219 |
+
<div className="roger-markdown">
|
| 220 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 221 |
+
{msg.content}
|
| 222 |
+
</ReactMarkdown>
|
| 223 |
+
</div>
|
| 224 |
+
) : (
|
| 225 |
+
<p>{msg.content}</p>
|
| 226 |
+
)}
|
| 227 |
+
|
| 228 |
+
{/* Sources */}
|
| 229 |
+
{msg.sources && msg.sources.length > 0 && (
|
| 230 |
+
<div className="mt-2 pt-2 border-t border-gray-200">
|
| 231 |
+
<p className="text-[11px] text-gray-500 mb-1">Sources:</p>
|
| 232 |
+
<div className="flex flex-wrap gap-1">
|
| 233 |
+
{msg.sources.slice(0, 3).map((src, i) => (
|
| 234 |
+
<span
|
| 235 |
+
key={i}
|
| 236 |
+
className="text-[10px] bg-gray-100 text-gray-600 px-2 py-0.5 rounded"
|
| 237 |
+
>
|
| 238 |
+
{src.domain} ({Math.round(src.similarity * 100)}%)
|
| 239 |
+
</span>
|
| 240 |
+
))}
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
))}
|
| 247 |
+
|
| 248 |
+
{/* Typing Indicator */}
|
| 249 |
+
{isLoading && (
|
| 250 |
+
<div className="flex py-2 items-center justify-start mb-3">
|
| 251 |
+
<div className="rounded-lg p-3 flex h-[50px] justify-center items-center">
|
| 252 |
+
<span className="roger-loader"></span>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
)}
|
| 256 |
+
</div>
|
| 257 |
+
) : (
|
| 258 |
+
<div className="flex-1 flex flex-col justify-center items-center bg-[#101010] px-6">
|
| 259 |
+
<div className="p-4 rounded-full bg-green-500/10 mb-4">
|
| 260 |
+
<Radio className="w-12 h-12 text-green-400 opacity-50" />
|
| 261 |
+
</div>
|
| 262 |
+
<div className="text-gray-300 text-center max-w-[280px]">
|
| 263 |
+
<p className="text-[16px] mb-3 leading-relaxed">
|
| 264 |
+
Hello! I'm <strong>Roger</strong>, your intelligence assistant.
|
| 265 |
+
</p>
|
| 266 |
+
<p className="text-[14px] text-gray-400 leading-relaxed">
|
| 267 |
+
Ask me anything about Sri Lanka's political, economic, weather, or social intelligence data.
|
| 268 |
+
</p>
|
| 269 |
+
<div className="mt-4 space-y-2 text-[12px] text-gray-500">
|
| 270 |
+
<p>Try asking:</p>
|
| 271 |
+
<p className="italic">"What are the latest political events?"</p>
|
| 272 |
+
<p className="italic">"Any weather warnings today?"</p>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
)}
|
| 277 |
+
|
| 278 |
+
{/* Input Area - with safe area for bottom */}
|
| 279 |
+
<div className="w-full ring-1 ring-[#373435] sm:rounded-b-[12px] py-[10px] sm:py-[12px] px-[12px] pb-[max(10px,env(safe-area-inset-bottom))] bg-[#000000]">
|
| 280 |
+
<div className="relative">
|
| 281 |
+
<textarea
|
| 282 |
+
onKeyDown={handleKeyDown}
|
| 283 |
+
onChange={(e) => setInput(e.target.value)}
|
| 284 |
+
value={input}
|
| 285 |
+
disabled={isLoading}
|
| 286 |
+
className="w-full focus:outline-none focus:ring-2 focus:ring-green-500 min-h-[50px] max-h-[100px] leading-[22px] rounded-[10px] bg-white text-black py-[12px] px-[14px] pr-[60px] resize-none text-[15px] placeholder-gray-500 disabled:opacity-50"
|
| 287 |
+
placeholder="Ask Roger..."
|
| 288 |
+
rows={2}
|
| 289 |
+
style={{ fontSize: '16px' }}
|
| 290 |
+
/>
|
| 291 |
+
<div
|
| 292 |
+
onClick={sendMessage}
|
| 293 |
+
className={`absolute top-[6px] right-[6px] w-[44px] h-[44px] sm:w-[42px] sm:h-[42px] ring-[0.5px] ring-[#727376] cursor-pointer rounded-full flex items-center justify-center transition-all shadow-lg touch-manipulation active:scale-95 ${input.trim() && !isLoading
|
| 294 |
+
? 'bg-green-500 hover:bg-green-600 active:bg-green-700'
|
| 295 |
+
: 'bg-[#373435] hover:bg-[#4a4a4a] active:bg-[#555]'
|
| 296 |
+
}`}
|
| 297 |
+
>
|
| 298 |
+
<Send className={`w-5 h-5 ml-[2px] ${input.trim() && !isLoading ? 'text-white' : 'text-gray-400'}`} />
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
<p className="text-[11px] text-gray-500 mt-2 text-center sm:hidden">
|
| 302 |
+
Press Enter to send • Shift+Enter for new line
|
| 303 |
+
</p>
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
);
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
export default FloatingChatBox;
|
frontend/app/components/LoadingScreen.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { motion } from "framer-motion";
|
| 5 |
+
import { Card } from "./ui/card";
|
| 6 |
+
import { Loader2, Zap } from "lucide-react";
|
| 7 |
+
|
| 8 |
+
export default function LoadingScreen() {
|
| 9 |
+
const [progress, setProgress] = useState(0);
|
| 10 |
+
const [loadingText, setLoadingText] = useState("Initializing Roger Platform...");
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
const interval = setInterval(() => {
|
| 14 |
+
setProgress((prev) => {
|
| 15 |
+
if (prev >= 95) {
|
| 16 |
+
return prev;
|
| 17 |
+
}
|
| 18 |
+
return prev + 5;
|
| 19 |
+
});
|
| 20 |
+
}, 200);
|
| 21 |
+
|
| 22 |
+
const textInterval = setInterval(() => {
|
| 23 |
+
setLoadingText((prev) => {
|
| 24 |
+
const texts = [
|
| 25 |
+
"Initializing Roger Platform...",
|
| 26 |
+
"Connecting to Intelligence Agents...",
|
| 27 |
+
"Loading Social Media Monitor...",
|
| 28 |
+
"Loading Political Intelligence...",
|
| 29 |
+
"Loading Economic Analysis...",
|
| 30 |
+
"Loading Meteorological Data...",
|
| 31 |
+
"Establishing WebSocket Connection...",
|
| 32 |
+
"Syncing with Database...",
|
| 33 |
+
"Preparing Real-Time Dashboard..."
|
| 34 |
+
];
|
| 35 |
+
const currentIndex = texts.indexOf(prev);
|
| 36 |
+
return texts[(currentIndex + 1) % texts.length];
|
| 37 |
+
});
|
| 38 |
+
}, 1500);
|
| 39 |
+
|
| 40 |
+
return () => {
|
| 41 |
+
clearInterval(interval);
|
| 42 |
+
clearInterval(textInterval);
|
| 43 |
+
};
|
| 44 |
+
}, []);
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-background via-background/95 to-primary/5">
|
| 48 |
+
<Card className="p-12 bg-card/95 backdrop-blur border-border max-w-lg w-full mx-4">
|
| 49 |
+
<motion.div
|
| 50 |
+
initial={{ opacity: 0, y: 20 }}
|
| 51 |
+
animate={{ opacity: 1, y: 0 }}
|
| 52 |
+
transition={{ duration: 0.5 }}
|
| 53 |
+
className="space-y-8"
|
| 54 |
+
>
|
| 55 |
+
{/* Logo/Icon */}
|
| 56 |
+
<div className="flex items-center justify-center gap-3">
|
| 57 |
+
<motion.div
|
| 58 |
+
animate={{ rotate: 360 }}
|
| 59 |
+
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
| 60 |
+
>
|
| 61 |
+
<Zap className="w-12 h-12 text-primary" />
|
| 62 |
+
</motion.div>
|
| 63 |
+
<h1 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
| 64 |
+
ROGER
|
| 65 |
+
</h1>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
{/* Progress Bar */}
|
| 69 |
+
<div className="space-y-3">
|
| 70 |
+
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
| 71 |
+
<motion.div
|
| 72 |
+
className="h-full bg-gradient-to-r from-primary via-primary/80 to-primary/60"
|
| 73 |
+
initial={{ width: 0 }}
|
| 74 |
+
animate={{ width: `${progress}%` }}
|
| 75 |
+
transition={{ duration: 0.3 }}
|
| 76 |
+
/>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
| 79 |
+
<span>{progress}%</span>
|
| 80 |
+
<span>Loading Intelligence Platform</span>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Loading Text */}
|
| 85 |
+
<motion.div
|
| 86 |
+
key={loadingText}
|
| 87 |
+
initial={{ opacity: 0 }}
|
| 88 |
+
animate={{ opacity: 1 }}
|
| 89 |
+
transition={{ duration: 0.5 }}
|
| 90 |
+
className="text-center"
|
| 91 |
+
>
|
| 92 |
+
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
| 93 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 94 |
+
<p className="text-sm font-mono">{loadingText}</p>
|
| 95 |
+
</div>
|
| 96 |
+
</motion.div>
|
| 97 |
+
|
| 98 |
+
{/* Info */}
|
| 99 |
+
<div className="text-center space-y-2 pt-4 border-t border-border">
|
| 100 |
+
<p className="text-xs text-muted-foreground">
|
| 101 |
+
Real-Time Situational Awareness for Sri Lanka
|
| 102 |
+
</p>
|
| 103 |
+
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
| 104 |
+
<span>6 Domain Agents</span>
|
| 105 |
+
<span>•</span>
|
| 106 |
+
<span>47+ Data Sources</span>
|
| 107 |
+
<span>•</span>
|
| 108 |
+
<span>Live Updates</span>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</motion.div>
|
| 112 |
+
</Card>
|
| 113 |
+
</div>
|
| 114 |
+
);
|
| 115 |
+
}
|
frontend/app/components/NavLink.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
| 2 |
+
import { forwardRef } from "react";
|
| 3 |
+
import { cn } from "../lib/utils";
|
| 4 |
+
|
| 5 |
+
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
| 6 |
+
className?: string;
|
| 7 |
+
activeClassName?: string;
|
| 8 |
+
pendingClassName?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
| 12 |
+
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
| 13 |
+
return (
|
| 14 |
+
<RouterNavLink
|
| 15 |
+
ref={ref}
|
| 16 |
+
to={to}
|
| 17 |
+
className={({ isActive, isPending }) =>
|
| 18 |
+
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
| 19 |
+
}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
);
|
| 23 |
+
},
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
NavLink.displayName = "NavLink";
|
| 27 |
+
|
| 28 |
+
export { NavLink };
|
frontend/app/components/Roger.css
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Roger Chatbox Styles */
|
| 2 |
+
|
| 3 |
+
/* Typing Loader Animation */
|
| 4 |
+
.roger-loader {
|
| 5 |
+
width: 6px;
|
| 6 |
+
height: 25px;
|
| 7 |
+
border-radius: 4px;
|
| 8 |
+
display: block;
|
| 9 |
+
background-color: #22c55e;
|
| 10 |
+
margin: 0 auto;
|
| 11 |
+
position: relative;
|
| 12 |
+
animation: rogerAnimloader 0.3s 0.3s linear infinite alternate;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.roger-loader::after,
|
| 16 |
+
.roger-loader::before {
|
| 17 |
+
content: '';
|
| 18 |
+
width: 6px;
|
| 19 |
+
height: 25px;
|
| 20 |
+
border-radius: 4px;
|
| 21 |
+
background: #22c55e;
|
| 22 |
+
position: absolute;
|
| 23 |
+
top: 50%;
|
| 24 |
+
transform: translateY(-50%);
|
| 25 |
+
left: 13px;
|
| 26 |
+
animation: rogerAnimloader 0.3s 0.45s linear infinite alternate;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.roger-loader::before {
|
| 30 |
+
left: -13px;
|
| 31 |
+
animation-delay: 0s;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
@keyframes rogerAnimloader {
|
| 35 |
+
0% {
|
| 36 |
+
height: 48px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
100% {
|
| 40 |
+
height: 4px;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Custom Scrollbar */
|
| 45 |
+
.roger-scrollbar::-webkit-scrollbar {
|
| 46 |
+
width: 5px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.roger-scrollbar::-webkit-scrollbar-track {
|
| 50 |
+
background: #1a1a1a;
|
| 51 |
+
border-radius: 4px;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.roger-scrollbar::-webkit-scrollbar-thumb {
|
| 55 |
+
background: #22c55e;
|
| 56 |
+
border-radius: 4px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.roger-scrollbar::-webkit-scrollbar-thumb:hover {
|
| 60 |
+
background: #16a34a;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/* Firefox support */
|
| 64 |
+
.roger-scrollbar {
|
| 65 |
+
scrollbar-width: thin;
|
| 66 |
+
scrollbar-color: #22c55e #1a1a1a;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Markdown Styling for AI Responses */
|
| 70 |
+
.roger-markdown h1,
|
| 71 |
+
.roger-markdown h2,
|
| 72 |
+
.roger-markdown h3,
|
| 73 |
+
.roger-markdown h4 {
|
| 74 |
+
font-weight: 600;
|
| 75 |
+
margin-top: 0.75rem;
|
| 76 |
+
margin-bottom: 0.5rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.roger-markdown h1 {
|
| 80 |
+
font-size: 1.25rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.roger-markdown h2 {
|
| 84 |
+
font-size: 1.125rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.roger-markdown h3 {
|
| 88 |
+
font-size: 1rem;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.roger-markdown p {
|
| 92 |
+
margin-bottom: 0.5rem;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.roger-markdown ul,
|
| 96 |
+
.roger-markdown ol {
|
| 97 |
+
margin-left: 1.25rem;
|
| 98 |
+
margin-bottom: 0.5rem;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.roger-markdown li {
|
| 102 |
+
margin-bottom: 0.25rem;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.roger-markdown code {
|
| 106 |
+
background: #f3f4f6;
|
| 107 |
+
padding: 0.125rem 0.375rem;
|
| 108 |
+
border-radius: 4px;
|
| 109 |
+
font-size: 0.875rem;
|
| 110 |
+
font-family: ui-monospace, monospace;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.roger-markdown pre {
|
| 114 |
+
background: #1f2937;
|
| 115 |
+
color: #e5e7eb;
|
| 116 |
+
padding: 0.75rem;
|
| 117 |
+
border-radius: 6px;
|
| 118 |
+
overflow-x: auto;
|
| 119 |
+
margin: 0.5rem 0;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.roger-markdown pre code {
|
| 123 |
+
background: transparent;
|
| 124 |
+
color: inherit;
|
| 125 |
+
padding: 0;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.roger-markdown blockquote {
|
| 129 |
+
border-left: 3px solid #22c55e;
|
| 130 |
+
padding-left: 0.75rem;
|
| 131 |
+
margin: 0.5rem 0;
|
| 132 |
+
color: #6b7280;
|
| 133 |
+
font-style: italic;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.roger-markdown table {
|
| 137 |
+
width: 100%;
|
| 138 |
+
border-collapse: collapse;
|
| 139 |
+
margin: 0.5rem 0;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.roger-markdown th,
|
| 143 |
+
.roger-markdown td {
|
| 144 |
+
border: 1px solid #e5e7eb;
|
| 145 |
+
padding: 0.375rem 0.5rem;
|
| 146 |
+
font-size: 0.875rem;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.roger-markdown th {
|
| 150 |
+
background: #f9fafb;
|
| 151 |
+
font-weight: 600;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.roger-markdown a {
|
| 155 |
+
color: #22c55e;
|
| 156 |
+
text-decoration: underline;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.roger-markdown a:hover {
|
| 160 |
+
color: #16a34a;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.roger-markdown strong {
|
| 164 |
+
font-weight: 600;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.roger-markdown em {
|
| 168 |
+
font-style: italic;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/* Hide scrollbar for domain filter on mobile */
|
| 172 |
+
.hide-scrollbar {
|
| 173 |
+
-ms-overflow-style: none;
|
| 174 |
+
scrollbar-width: none;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.hide-scrollbar::-webkit-scrollbar {
|
| 178 |
+
display: none;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* Mobile Touch Improvements */
|
| 182 |
+
@media (max-width: 640px) {
|
| 183 |
+
|
| 184 |
+
/* Prevent text selection on buttons */
|
| 185 |
+
.touch-manipulation {
|
| 186 |
+
touch-action: manipulation;
|
| 187 |
+
-webkit-tap-highlight-color: transparent;
|
| 188 |
+
user-select: none;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* Larger touch targets on mobile */
|
| 192 |
+
.roger-markdown {
|
| 193 |
+
font-size: 15px;
|
| 194 |
+
line-height: 1.6;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* Smoother scrolling on mobile */
|
| 198 |
+
.roger-scrollbar {
|
| 199 |
+
-webkit-overflow-scrolling: touch;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/* Prevent iOS zoom on input focus */
|
| 204 |
+
@supports (-webkit-touch-callout: none) {
|
| 205 |
+
|
| 206 |
+
textarea,
|
| 207 |
+
input {
|
| 208 |
+
font-size: 16px !important;
|
| 209 |
+
}
|
| 210 |
+
}
|
frontend/app/components/dashboard/AnomalyDetection.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { Card } from "../ui/card";
|
| 4 |
+
import { Badge } from "../ui/badge";
|
| 5 |
+
import { Separator } from "../ui/separator";
|
| 6 |
+
import { Brain, AlertTriangle, TrendingUp, RefreshCw, Zap, Database } from "lucide-react";
|
| 7 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 8 |
+
import { useState, useEffect } from "react";
|
| 9 |
+
|
| 10 |
+
interface AnomalyEvent {
|
| 11 |
+
event_id: string;
|
| 12 |
+
summary: string;
|
| 13 |
+
domain: string;
|
| 14 |
+
severity: string;
|
| 15 |
+
impact_type: string;
|
| 16 |
+
anomaly_score: number;
|
| 17 |
+
is_anomaly: boolean;
|
| 18 |
+
language?: string;
|
| 19 |
+
timestamp?: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface ModelStatus {
|
| 23 |
+
model_loaded: boolean;
|
| 24 |
+
models_available: string[];
|
| 25 |
+
vectorizer_loaded: boolean;
|
| 26 |
+
batch_threshold: number;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const AnomalyDetection = () => {
|
| 30 |
+
const [anomalies, setAnomalies] = useState<AnomalyEvent[]>([]);
|
| 31 |
+
const [modelStatus, setModelStatus] = useState<ModelStatus | null>(null);
|
| 32 |
+
const [loading, setLoading] = useState(true);
|
| 33 |
+
const [error, setError] = useState<string | null>(null);
|
| 34 |
+
|
| 35 |
+
const fetchAnomalies = async () => {
|
| 36 |
+
try {
|
| 37 |
+
setLoading(true);
|
| 38 |
+
const [anomalyRes, statusRes] = await Promise.all([
|
| 39 |
+
fetch('http://localhost:8000/api/anomalies?limit=20'),
|
| 40 |
+
fetch('http://localhost:8000/api/model/status')
|
| 41 |
+
]);
|
| 42 |
+
|
| 43 |
+
const anomalyData = await anomalyRes.json();
|
| 44 |
+
const statusData = await statusRes.json();
|
| 45 |
+
|
| 46 |
+
setAnomalies(anomalyData.anomalies || []);
|
| 47 |
+
setModelStatus(statusData);
|
| 48 |
+
setError(null);
|
| 49 |
+
} catch (err) {
|
| 50 |
+
setError('Failed to fetch anomalies');
|
| 51 |
+
console.error(err);
|
| 52 |
+
} finally {
|
| 53 |
+
setLoading(false);
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
fetchAnomalies();
|
| 59 |
+
// Refresh every 30 seconds
|
| 60 |
+
const interval = setInterval(fetchAnomalies, 30000);
|
| 61 |
+
return () => clearInterval(interval);
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
+
const getScoreColor = (score: number) => {
|
| 65 |
+
if (score >= 0.8) return "text-destructive";
|
| 66 |
+
if (score >= 0.6) return "text-warning";
|
| 67 |
+
if (score >= 0.4) return "text-primary";
|
| 68 |
+
return "text-muted-foreground";
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const getScoreBg = (score: number) => {
|
| 72 |
+
if (score >= 0.8) return "bg-destructive/20";
|
| 73 |
+
if (score >= 0.6) return "bg-warning/20";
|
| 74 |
+
if (score >= 0.4) return "bg-primary/20";
|
| 75 |
+
return "bg-muted/20";
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<Card className="p-6 bg-card border-border">
|
| 80 |
+
{/* Header */}
|
| 81 |
+
<div className="flex items-center justify-between mb-4">
|
| 82 |
+
<div className="flex items-center gap-3">
|
| 83 |
+
<div className="p-2 rounded-lg bg-primary/20">
|
| 84 |
+
<Brain className="w-6 h-6 text-primary" />
|
| 85 |
+
</div>
|
| 86 |
+
<div>
|
| 87 |
+
<h2 className="text-lg font-bold">ML ANOMALY DETECTION</h2>
|
| 88 |
+
<p className="text-xs text-muted-foreground font-mono">
|
| 89 |
+
Powered by BERT + Isolation Forest
|
| 90 |
+
</p>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="flex items-center gap-2">
|
| 94 |
+
<button
|
| 95 |
+
onClick={fetchAnomalies}
|
| 96 |
+
disabled={loading}
|
| 97 |
+
className="p-2 rounded-lg hover:bg-muted/50 transition-colors disabled:opacity-50"
|
| 98 |
+
>
|
| 99 |
+
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
| 100 |
+
</button>
|
| 101 |
+
{modelStatus && (
|
| 102 |
+
<Badge className={modelStatus.model_loaded ? "bg-success/20 text-success" : "bg-warning/20 text-warning"}>
|
| 103 |
+
{modelStatus.model_loaded ? "Model Active" : "Model Training..."}
|
| 104 |
+
</Badge>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* Model Status Bar */}
|
| 110 |
+
{modelStatus && (
|
| 111 |
+
<div className="mb-4 p-3 rounded-lg bg-muted/30 flex items-center gap-4 text-xs">
|
| 112 |
+
<div className="flex items-center gap-2">
|
| 113 |
+
<Database className="w-4 h-4 text-muted-foreground" />
|
| 114 |
+
<span>Batch Threshold: <strong>{modelStatus.batch_threshold}</strong> records</span>
|
| 115 |
+
</div>
|
| 116 |
+
<Separator orientation="vertical" className="h-4" />
|
| 117 |
+
<div className="flex items-center gap-2">
|
| 118 |
+
<Zap className="w-4 h-4 text-muted-foreground" />
|
| 119 |
+
<span>Models: <strong>{modelStatus.models_available?.length || 0}</strong> trained</span>
|
| 120 |
+
</div>
|
| 121 |
+
{modelStatus.models_available?.length > 0 && (
|
| 122 |
+
<>
|
| 123 |
+
<Separator orientation="vertical" className="h-4" />
|
| 124 |
+
<span className="text-muted-foreground">
|
| 125 |
+
{modelStatus.models_available.join(', ')}
|
| 126 |
+
</span>
|
| 127 |
+
</>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
<Separator className="mb-4" />
|
| 133 |
+
|
| 134 |
+
{/* Anomalies List */}
|
| 135 |
+
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
| 136 |
+
{loading && anomalies.length === 0 ? (
|
| 137 |
+
<div className="text-center py-8">
|
| 138 |
+
<RefreshCw className="w-8 h-8 mx-auto animate-spin text-primary mb-3" />
|
| 139 |
+
<p className="text-sm text-muted-foreground">Loading anomalies...</p>
|
| 140 |
+
</div>
|
| 141 |
+
) : error ? (
|
| 142 |
+
<div className="text-center py-8">
|
| 143 |
+
<AlertTriangle className="w-8 h-8 mx-auto text-destructive mb-3" />
|
| 144 |
+
<p className="text-sm text-destructive">{error}</p>
|
| 145 |
+
</div>
|
| 146 |
+
) : anomalies.length === 0 ? (
|
| 147 |
+
<div className="text-center py-8">
|
| 148 |
+
<TrendingUp className="w-8 h-8 mx-auto text-success mb-3" />
|
| 149 |
+
<p className="text-sm text-muted-foreground">No anomalies detected</p>
|
| 150 |
+
<p className="text-xs text-muted-foreground mt-1">System operating normally</p>
|
| 151 |
+
</div>
|
| 152 |
+
) : (
|
| 153 |
+
<AnimatePresence>
|
| 154 |
+
{anomalies.map((anomaly, idx) => (
|
| 155 |
+
<motion.div
|
| 156 |
+
key={anomaly.event_id || idx}
|
| 157 |
+
initial={{ opacity: 0, y: 20 }}
|
| 158 |
+
animate={{ opacity: 1, y: 0 }}
|
| 159 |
+
exit={{ opacity: 0, y: -20 }}
|
| 160 |
+
transition={{ delay: idx * 0.05 }}
|
| 161 |
+
>
|
| 162 |
+
<Card className={`p-4 border-l-4 ${anomaly.is_anomaly ? 'border-l-destructive' : 'border-l-warning'} ${getScoreBg(anomaly.anomaly_score)}`}>
|
| 163 |
+
<div className="flex items-start justify-between gap-4">
|
| 164 |
+
<div className="flex-1 min-w-0">
|
| 165 |
+
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
| 166 |
+
<Badge className="bg-destructive/20 text-destructive text-xs">
|
| 167 |
+
⚠️ ANOMALY
|
| 168 |
+
</Badge>
|
| 169 |
+
<Badge className="border border-border text-xs">
|
| 170 |
+
{anomaly.domain}
|
| 171 |
+
</Badge>
|
| 172 |
+
{anomaly.language && anomaly.language !== 'english' && (
|
| 173 |
+
<Badge className="bg-info/20 text-info text-xs">
|
| 174 |
+
{anomaly.language.toUpperCase()}
|
| 175 |
+
</Badge>
|
| 176 |
+
)}
|
| 177 |
+
</div>
|
| 178 |
+
<p className="text-sm font-medium mb-2 leading-relaxed">
|
| 179 |
+
{anomaly.summary}
|
| 180 |
+
</p>
|
| 181 |
+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
| 182 |
+
<span>Severity: <strong className="text-foreground">{anomaly.severity}</strong></span>
|
| 183 |
+
{anomaly.timestamp && (
|
| 184 |
+
<span className="font-mono">
|
| 185 |
+
{new Date(anomaly.timestamp).toLocaleString()}
|
| 186 |
+
</span>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
<div className="text-right shrink-0">
|
| 191 |
+
<div className={`text-2xl font-bold ${getScoreColor(anomaly.anomaly_score)}`}>
|
| 192 |
+
{Math.round(anomaly.anomaly_score * 100)}%
|
| 193 |
+
</div>
|
| 194 |
+
<div className="text-xs text-muted-foreground">
|
| 195 |
+
Anomaly Score
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
</Card>
|
| 200 |
+
</motion.div>
|
| 201 |
+
))}
|
| 202 |
+
</AnimatePresence>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
{/* Footer */}
|
| 207 |
+
{anomalies.length > 0 && (
|
| 208 |
+
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
|
| 209 |
+
<span>Showing {anomalies.length} anomalous events</span>
|
| 210 |
+
<span className="font-mono">Auto-refresh: 30s</span>
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
</Card>
|
| 214 |
+
);
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
export default AnomalyDetection;
|
frontend/app/components/dashboard/CurrencyPrediction.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
|
| 5 |
+
interface CurrencyPrediction {
|
| 6 |
+
prediction_date: string;
|
| 7 |
+
generated_at: string;
|
| 8 |
+
model_version: string;
|
| 9 |
+
current_rate: number;
|
| 10 |
+
predicted_rate: number;
|
| 11 |
+
expected_change: number;
|
| 12 |
+
expected_change_pct: number;
|
| 13 |
+
direction: string;
|
| 14 |
+
direction_emoji: string;
|
| 15 |
+
volatility_class: string;
|
| 16 |
+
weekly_trend?: number;
|
| 17 |
+
monthly_trend?: number;
|
| 18 |
+
is_fallback?: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000";
|
| 22 |
+
|
| 23 |
+
const VOLATILITY_COLORS = {
|
| 24 |
+
low: "bg-green-500/20 text-green-400 border-green-500/50",
|
| 25 |
+
medium: "bg-yellow-500/20 text-yellow-400 border-yellow-500/50",
|
| 26 |
+
high: "bg-red-500/20 text-red-400 border-red-500/50",
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export default function CurrencyPrediction() {
|
| 30 |
+
const [prediction, setPrediction] = useState<CurrencyPrediction | null>(null);
|
| 31 |
+
const [history, setHistory] = useState<any[]>([]);
|
| 32 |
+
const [loading, setLoading] = useState(true);
|
| 33 |
+
const [error, setError] = useState<string | null>(null);
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
fetchPrediction();
|
| 37 |
+
fetchHistory();
|
| 38 |
+
// Refresh every hour
|
| 39 |
+
const interval = setInterval(() => {
|
| 40 |
+
fetchPrediction();
|
| 41 |
+
fetchHistory();
|
| 42 |
+
}, 60 * 60 * 1000);
|
| 43 |
+
return () => clearInterval(interval);
|
| 44 |
+
}, []);
|
| 45 |
+
|
| 46 |
+
const fetchPrediction = async () => {
|
| 47 |
+
try {
|
| 48 |
+
const res = await fetch(`${API_BASE}/api/currency/prediction`);
|
| 49 |
+
const data = await res.json();
|
| 50 |
+
|
| 51 |
+
if (data.status === "success") {
|
| 52 |
+
setPrediction(data.prediction);
|
| 53 |
+
setError(null);
|
| 54 |
+
} else {
|
| 55 |
+
setError(data.message || "Failed to load prediction");
|
| 56 |
+
}
|
| 57 |
+
} catch (err) {
|
| 58 |
+
setError("Failed to connect to API");
|
| 59 |
+
} finally {
|
| 60 |
+
setLoading(false);
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const fetchHistory = async () => {
|
| 65 |
+
try {
|
| 66 |
+
const res = await fetch(`${API_BASE}/api/currency/history?days=7`);
|
| 67 |
+
const data = await res.json();
|
| 68 |
+
if (data.status === "success") {
|
| 69 |
+
setHistory(data.history.slice(-7)); // Last 7 days
|
| 70 |
+
}
|
| 71 |
+
} catch (err) {
|
| 72 |
+
console.error("Failed to fetch history:", err);
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
if (loading) {
|
| 77 |
+
return (
|
| 78 |
+
<div className="bg-slate-800/50 rounded-xl p-6 border border-slate-700/50">
|
| 79 |
+
<div className="animate-pulse space-y-4">
|
| 80 |
+
<div className="h-6 bg-slate-700 rounded w-1/3"></div>
|
| 81 |
+
<div className="h-20 bg-slate-700 rounded"></div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<div className="bg-slate-800/50 rounded-xl p-6 border border-slate-700/50">
|
| 89 |
+
{/* Header */}
|
| 90 |
+
<div className="flex items-center justify-between mb-6">
|
| 91 |
+
<div>
|
| 92 |
+
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
| 93 |
+
💱 USD/LKR Prediction
|
| 94 |
+
</h2>
|
| 95 |
+
{prediction && (
|
| 96 |
+
<p className="text-sm text-slate-400 mt-1">
|
| 97 |
+
Forecast for {prediction.prediction_date}
|
| 98 |
+
</p>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
<button
|
| 102 |
+
onClick={fetchPrediction}
|
| 103 |
+
className="p-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors"
|
| 104 |
+
title="Refresh"
|
| 105 |
+
>
|
| 106 |
+
🔄
|
| 107 |
+
</button>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{error ? (
|
| 111 |
+
<div className="text-center py-8">
|
| 112 |
+
<p className="text-red-400 mb-4">{error}</p>
|
| 113 |
+
<button
|
| 114 |
+
onClick={fetchPrediction}
|
| 115 |
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors"
|
| 116 |
+
>
|
| 117 |
+
Retry
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
) : prediction ? (
|
| 121 |
+
<>
|
| 122 |
+
{/* Main Prediction Card */}
|
| 123 |
+
<div
|
| 124 |
+
className={`p-6 rounded-xl border mb-6 ${prediction.expected_change_pct < 0
|
| 125 |
+
? "bg-green-500/10 border-green-500/30"
|
| 126 |
+
: "bg-red-500/10 border-red-500/30"
|
| 127 |
+
}`}
|
| 128 |
+
>
|
| 129 |
+
<div className="grid grid-cols-3 gap-4 text-center">
|
| 130 |
+
<div>
|
| 131 |
+
<div className="text-slate-400 text-sm">Current Rate</div>
|
| 132 |
+
<div className="text-2xl font-bold text-white">
|
| 133 |
+
{prediction.current_rate.toFixed(2)}
|
| 134 |
+
</div>
|
| 135 |
+
<div className="text-xs text-slate-500">LKR/USD</div>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="flex items-center justify-center">
|
| 138 |
+
<div className="text-4xl">
|
| 139 |
+
{prediction.direction_emoji}
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
<div>
|
| 143 |
+
<div className="text-slate-400 text-sm">Predicted</div>
|
| 144 |
+
<div className="text-2xl font-bold text-white">
|
| 145 |
+
{prediction.predicted_rate.toFixed(2)}
|
| 146 |
+
</div>
|
| 147 |
+
<div className="text-xs text-slate-500">LKR/USD</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
| 152 |
+
<div>
|
| 153 |
+
<span className="text-slate-400">Expected Change: </span>
|
| 154 |
+
<span
|
| 155 |
+
className={`font-bold ${prediction.expected_change_pct < 0
|
| 156 |
+
? "text-green-400"
|
| 157 |
+
: "text-red-400"
|
| 158 |
+
}`}
|
| 159 |
+
>
|
| 160 |
+
{prediction.expected_change_pct > 0 ? "+" : ""}
|
| 161 |
+
{prediction.expected_change_pct.toFixed(3)}%
|
| 162 |
+
</span>
|
| 163 |
+
</div>
|
| 164 |
+
<div
|
| 165 |
+
className={`px-3 py-1 rounded-full text-sm ${VOLATILITY_COLORS[prediction.volatility_class as keyof typeof VOLATILITY_COLORS] ||
|
| 166 |
+
VOLATILITY_COLORS.low
|
| 167 |
+
}`}
|
| 168 |
+
>
|
| 169 |
+
{prediction.volatility_class.toUpperCase()} Volatility
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
{/* Trend Info */}
|
| 175 |
+
<div className="grid grid-cols-2 gap-4 mb-6">
|
| 176 |
+
{prediction.weekly_trend !== undefined && (
|
| 177 |
+
<div className="p-4 rounded-lg bg-slate-700/50">
|
| 178 |
+
<div className="text-slate-400 text-sm">7-Day Trend</div>
|
| 179 |
+
<div
|
| 180 |
+
className={`text-lg font-bold ${prediction.weekly_trend < 0 ? "text-green-400" : "text-red-400"
|
| 181 |
+
}`}
|
| 182 |
+
>
|
| 183 |
+
{prediction.weekly_trend > 0 ? "+" : ""}
|
| 184 |
+
{prediction.weekly_trend.toFixed(2)}%
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
)}
|
| 188 |
+
{prediction.monthly_trend !== undefined && (
|
| 189 |
+
<div className="p-4 rounded-lg bg-slate-700/50">
|
| 190 |
+
<div className="text-slate-400 text-sm">30-Day Trend</div>
|
| 191 |
+
<div
|
| 192 |
+
className={`text-lg font-bold ${prediction.monthly_trend < 0 ? "text-green-400" : "text-red-400"
|
| 193 |
+
}`}
|
| 194 |
+
>
|
| 195 |
+
{prediction.monthly_trend > 0 ? "+" : ""}
|
| 196 |
+
{prediction.monthly_trend.toFixed(2)}%
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
{/* Mini Chart (7-day history) */}
|
| 203 |
+
{history.length > 0 && (
|
| 204 |
+
<div className="p-4 rounded-lg bg-slate-700/30">
|
| 205 |
+
<div className="text-sm text-slate-400 mb-2">Last 7 Days</div>
|
| 206 |
+
<div className="flex items-end justify-between h-16 gap-1">
|
| 207 |
+
{history.map((day, i) => {
|
| 208 |
+
const minRate = Math.min(...history.map((h) => h.close));
|
| 209 |
+
const maxRate = Math.max(...history.map((h) => h.close));
|
| 210 |
+
const range = maxRate - minRate || 1;
|
| 211 |
+
const height = ((day.close - minRate) / range) * 100;
|
| 212 |
+
|
| 213 |
+
return (
|
| 214 |
+
<div
|
| 215 |
+
key={i}
|
| 216 |
+
className="flex-1 bg-blue-500/50 rounded-t hover:bg-blue-400/50 transition-colors"
|
| 217 |
+
style={{ height: `${Math.max(20, height)}%` }}
|
| 218 |
+
title={`${day.date}: ${day.close.toFixed(2)} LKR`}
|
| 219 |
+
/>
|
| 220 |
+
);
|
| 221 |
+
})}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
|
| 226 |
+
{/* Fallback Warning */}
|
| 227 |
+
{prediction.is_fallback && (
|
| 228 |
+
<div className="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg text-sm text-yellow-400">
|
| 229 |
+
⚠️ Using fallback model. Run training for accurate predictions.
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
|
| 233 |
+
{/* Footer */}
|
| 234 |
+
<div className="mt-4 text-xs text-slate-500 text-center">
|
| 235 |
+
Generated: {new Date(prediction.generated_at).toLocaleString()} |
|
| 236 |
+
Model: {prediction.model_version}
|
| 237 |
+
</div>
|
| 238 |
+
</>
|
| 239 |
+
) : null}
|
| 240 |
+
</div>
|
| 241 |
+
);
|
| 242 |
+
}
|
frontend/app/components/dashboard/DashboardOverview.tsx
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card } from "../ui/card";
|
| 2 |
+
import { AlertTriangle, TrendingUp, Cloud, Zap, Users, Building, Wifi, WifiOff, Waves } from "lucide-react";
|
| 3 |
+
import { Badge } from "../ui/badge";
|
| 4 |
+
import { useRogerData } from "../../hooks/use-roger-data";
|
| 5 |
+
import { motion } from "framer-motion";
|
| 6 |
+
import RiverNetStatus from "./RiverNetStatus";
|
| 7 |
+
|
| 8 |
+
const DashboardOverview = () => {
|
| 9 |
+
// Get riverData directly from hook (fetched via /api/rivernet)
|
| 10 |
+
const { dashboard, events, isConnected, status, riverData } = useRogerData();
|
| 11 |
+
|
| 12 |
+
// Safety check: ensure events is always an array
|
| 13 |
+
const safeEvents = events || [];
|
| 14 |
+
|
| 15 |
+
// Sort events by timestamp descending (latest first)
|
| 16 |
+
const sortedEvents = [...safeEvents].sort((a, b) => {
|
| 17 |
+
const dateA = new Date(a.timestamp).getTime();
|
| 18 |
+
const dateB = new Date(b.timestamp).getTime();
|
| 19 |
+
return dateB - dateA; // Descending order (newest first)
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// Calculate domain-specific metrics from sorted events
|
| 23 |
+
const domainCounts = sortedEvents.reduce((acc, event) => {
|
| 24 |
+
acc[event.domain] = (acc[event.domain] || 0) + 1;
|
| 25 |
+
return acc;
|
| 26 |
+
}, {} as Record<string, number>);
|
| 27 |
+
|
| 28 |
+
const riskEvents = sortedEvents.filter(e => e.impact_type === 'risk');
|
| 29 |
+
const opportunityEvents = sortedEvents.filter(e => e.impact_type === 'opportunity');
|
| 30 |
+
const criticalEvents = sortedEvents.filter(e => e.severity === 'critical' || e.severity === 'high');
|
| 31 |
+
|
| 32 |
+
// Count flood-related events
|
| 33 |
+
const floodEvents = sortedEvents.filter(e =>
|
| 34 |
+
e.category === 'flood_monitoring' ||
|
| 35 |
+
e.category === 'flood_alert' ||
|
| 36 |
+
(e.summary && e.summary.toLowerCase().includes('flood'))
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
const metrics = [
|
| 40 |
+
{
|
| 41 |
+
label: "Risk Events",
|
| 42 |
+
value: riskEvents.length.toString(),
|
| 43 |
+
change: criticalEvents.length > 0 ? `${criticalEvents.length} critical` : "—",
|
| 44 |
+
icon: AlertTriangle,
|
| 45 |
+
status: criticalEvents.length > 3 ? "warning" : "success"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
label: "Opportunities",
|
| 49 |
+
value: opportunityEvents.length.toString(),
|
| 50 |
+
change: "+Growth",
|
| 51 |
+
icon: TrendingUp,
|
| 52 |
+
status: "success"
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
label: "Data Sources",
|
| 56 |
+
value: Object.keys(domainCounts).length.toString(),
|
| 57 |
+
change: "Active",
|
| 58 |
+
icon: Zap,
|
| 59 |
+
status: "info"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
label: "Flood Alerts",
|
| 63 |
+
value: floodEvents.length.toString(),
|
| 64 |
+
change: riverData ? "Monitoring" : "Offline",
|
| 65 |
+
icon: Waves,
|
| 66 |
+
status: floodEvents.length > 0 ? "warning" : "success"
|
| 67 |
+
},
|
| 68 |
+
];
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className="space-y-6">
|
| 72 |
+
{/* Connection Status Banner */}
|
| 73 |
+
<Card className={`p-4 ${isConnected ? 'bg-success/10 border-success/50' : 'bg-warning/10 border-warning/50'}`}>
|
| 74 |
+
<div className="flex items-center gap-3">
|
| 75 |
+
{isConnected ? (
|
| 76 |
+
<>
|
| 77 |
+
<Wifi className="w-5 h-5 text-success" />
|
| 78 |
+
<div className="flex-1">
|
| 79 |
+
<h3 className="font-bold text-success">SYSTEM OPERATIONAL</h3>
|
| 80 |
+
<p className="text-xs text-muted-foreground">Real-time intelligence streaming • Run #{dashboard.total_events}</p>
|
| 81 |
+
</div>
|
| 82 |
+
</>
|
| 83 |
+
) : (
|
| 84 |
+
<>
|
| 85 |
+
<WifiOff className="w-5 h-5 text-warning" />
|
| 86 |
+
<div className="flex-1">
|
| 87 |
+
<h3 className="font-bold text-warning">RECONNECTING...</h3>
|
| 88 |
+
<p className="text-xs text-muted-foreground">Attempting to restore live feed</p>
|
| 89 |
+
</div>
|
| 90 |
+
</>
|
| 91 |
+
)}
|
| 92 |
+
<Badge className="font-mono text-xs">
|
| 93 |
+
{new Date(dashboard.last_updated).toLocaleTimeString()}
|
| 94 |
+
</Badge>
|
| 95 |
+
</div>
|
| 96 |
+
</Card>
|
| 97 |
+
|
| 98 |
+
{/* Metrics Grid */}
|
| 99 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 100 |
+
{metrics.map((metric, idx) => {
|
| 101 |
+
const Icon = metric.icon;
|
| 102 |
+
return (
|
| 103 |
+
<motion.div
|
| 104 |
+
key={idx}
|
| 105 |
+
initial={{ opacity: 0, y: 20 }}
|
| 106 |
+
animate={{ opacity: 1, y: 0 }}
|
| 107 |
+
transition={{ delay: idx * 0.1 }}
|
| 108 |
+
>
|
| 109 |
+
<Card className="p-4 bg-card border-border hover:border-primary/50 transition-all">
|
| 110 |
+
<div className="flex items-start justify-between mb-2">
|
| 111 |
+
<div className={`p-2 rounded bg-${metric.status}/20`}>
|
| 112 |
+
<Icon className={`w-5 h-5 text-${metric.status}`} />
|
| 113 |
+
</div>
|
| 114 |
+
<span className="text-xs font-mono text-success">{metric.change}</span>
|
| 115 |
+
</div>
|
| 116 |
+
<div>
|
| 117 |
+
<p className="text-2xl font-bold">{metric.value}</p>
|
| 118 |
+
<p className="text-xs text-muted-foreground uppercase tracking-wide">{metric.label}</p>
|
| 119 |
+
</div>
|
| 120 |
+
</Card>
|
| 121 |
+
</motion.div>
|
| 122 |
+
);
|
| 123 |
+
})}
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* RiverNet Flood Monitoring */}
|
| 127 |
+
<RiverNetStatus riverData={riverData} compact={false} />
|
| 128 |
+
|
| 129 |
+
{/* Live Intelligence Feed - SORTED BY LATEST FIRST */}
|
| 130 |
+
<Card className="p-6 bg-card border-border">
|
| 131 |
+
<h3 className="font-bold mb-4 flex items-center gap-2">
|
| 132 |
+
<Zap className="w-5 h-5 text-primary" />
|
| 133 |
+
LIVE INTELLIGENCE FEED
|
| 134 |
+
<span className="text-xs text-muted-foreground ml-2">(Latest First)</span>
|
| 135 |
+
<Badge className="ml-auto">{sortedEvents.length} Events</Badge>
|
| 136 |
+
</h3>
|
| 137 |
+
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
| 138 |
+
{sortedEvents.slice(0, 10).map((event, idx) => {
|
| 139 |
+
const isRisk = event.impact_type === 'risk';
|
| 140 |
+
const isFlood = event.category === 'flood_monitoring' || event.category === 'flood_alert';
|
| 141 |
+
const severityColor = {
|
| 142 |
+
critical: 'destructive',
|
| 143 |
+
high: 'warning',
|
| 144 |
+
medium: 'primary',
|
| 145 |
+
low: 'secondary'
|
| 146 |
+
}[event.severity] || 'secondary';
|
| 147 |
+
|
| 148 |
+
return (
|
| 149 |
+
<motion.div
|
| 150 |
+
key={event.event_id}
|
| 151 |
+
initial={{ opacity: 0, x: -20 }}
|
| 152 |
+
animate={{ opacity: 1, x: 0 }}
|
| 153 |
+
transition={{ delay: idx * 0.05 }}
|
| 154 |
+
>
|
| 155 |
+
<Card className={`p-4 bg-muted/30 border-l-4 border-l-${severityColor} hover:bg-muted/50 transition-colors`}>
|
| 156 |
+
<div className="flex items-start justify-between mb-2">
|
| 157 |
+
<div className="flex-1">
|
| 158 |
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
| 159 |
+
<Badge className={`bg-${severityColor} text-${severityColor}-foreground`}>
|
| 160 |
+
{event.severity.toUpperCase()}
|
| 161 |
+
</Badge>
|
| 162 |
+
<Badge className={isRisk ? "bg-destructive/20 text-destructive" : "bg-success/20 text-success"}>
|
| 163 |
+
{isRisk ? "⚠️ RISK" : "✨ OPPORTUNITY"}
|
| 164 |
+
</Badge>
|
| 165 |
+
<Badge className="border border-border">{event.domain}</Badge>
|
| 166 |
+
{isFlood && (
|
| 167 |
+
<Badge className="bg-info/20 text-info">🌊 FLOOD</Badge>
|
| 168 |
+
)}
|
| 169 |
+
</div>
|
| 170 |
+
<p className="font-semibold text-sm mb-1">{event.summary}</p>
|
| 171 |
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
| 172 |
+
<span>Confidence: {Math.round(event.confidence * 100)}%</span>
|
| 173 |
+
<span>•</span>
|
| 174 |
+
<span className="font-mono">{new Date(event.timestamp).toLocaleTimeString()}</span>
|
| 175 |
+
<span>•</span>
|
| 176 |
+
<span className="font-mono">{new Date(event.timestamp).toLocaleDateString()}</span>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</Card>
|
| 181 |
+
</motion.div>
|
| 182 |
+
);
|
| 183 |
+
})}
|
| 184 |
+
{sortedEvents.length === 0 && (
|
| 185 |
+
<div className="text-center text-muted-foreground py-8">
|
| 186 |
+
<AlertTriangle className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 187 |
+
<p className="text-sm font-mono">Initializing intelligence gathering...</p>
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
</div>
|
| 191 |
+
</Card>
|
| 192 |
+
|
| 193 |
+
{/* Operational Risk Radar */}
|
| 194 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 195 |
+
<Card className="p-6 bg-card border-border">
|
| 196 |
+
<Cloud className="w-8 h-8 text-warning mb-3" />
|
| 197 |
+
<p className="text-2xl font-bold">{Math.round(dashboard.logistics_friction * 100)}%</p>
|
| 198 |
+
<p className="text-xs text-muted-foreground uppercase">Logistics Friction</p>
|
| 199 |
+
</Card>
|
| 200 |
+
<Card className="p-6 bg-card border-border">
|
| 201 |
+
<AlertTriangle className="w-8 h-8 text-destructive mb-3" />
|
| 202 |
+
<p className="text-2xl font-bold">{Math.round(dashboard.compliance_volatility * 100)}%</p>
|
| 203 |
+
<p className="text-xs text-muted-foreground uppercase">Compliance Volatility</p>
|
| 204 |
+
</Card>
|
| 205 |
+
<Card className="p-6 bg-card border-border">
|
| 206 |
+
<TrendingUp className="w-8 h-8 text-info mb-3" />
|
| 207 |
+
<p className="text-2xl font-bold">{Math.round(dashboard.market_instability * 100)}%</p>
|
| 208 |
+
<p className="text-xs text-muted-foreground uppercase">Market Instability</p>
|
| 209 |
+
</Card>
|
| 210 |
+
<Card className="p-6 bg-card border-border">
|
| 211 |
+
<Building className="w-8 h-8 text-success mb-3" />
|
| 212 |
+
<p className="text-2xl font-bold">{Math.round(dashboard.opportunity_index * 100)}%</p>
|
| 213 |
+
<p className="text-xs text-muted-foreground uppercase">Opportunity Index</p>
|
| 214 |
+
</Card>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
);
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
export default DashboardOverview;
|
frontend/app/components/dashboard/HistoricalIntel.tsx
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { Card } from '../ui/card';
|
| 5 |
+
import { Badge } from '../ui/badge';
|
| 6 |
+
import { History, TrendingUp, CloudRain, AlertTriangle, Calendar, Droplets } from 'lucide-react';
|
| 7 |
+
import { motion } from 'framer-motion';
|
| 8 |
+
|
| 9 |
+
interface HistoricalData {
|
| 10 |
+
source: string;
|
| 11 |
+
period: string;
|
| 12 |
+
fetched_at: string;
|
| 13 |
+
statistics: {
|
| 14 |
+
avg_annual_rainfall_mm: number;
|
| 15 |
+
max_daily_rainfall_mm: number;
|
| 16 |
+
heavy_rain_days_50mm: number;
|
| 17 |
+
extreme_rain_days_100mm: number;
|
| 18 |
+
avg_flood_events_per_year: number;
|
| 19 |
+
};
|
| 20 |
+
decadal_analysis: {
|
| 21 |
+
period: string;
|
| 22 |
+
avg_rainfall_mm: number;
|
| 23 |
+
extreme_days: number;
|
| 24 |
+
max_daily_mm: number;
|
| 25 |
+
major_flood_events: number;
|
| 26 |
+
}[];
|
| 27 |
+
key_findings: string[];
|
| 28 |
+
high_risk_periods: {
|
| 29 |
+
months: string;
|
| 30 |
+
type: string;
|
| 31 |
+
risk: string;
|
| 32 |
+
}[];
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 36 |
+
|
| 37 |
+
export default function HistoricalIntel() {
|
| 38 |
+
const [data, setData] = useState<HistoricalData | null>(null);
|
| 39 |
+
const [loading, setLoading] = useState(true);
|
| 40 |
+
const [error, setError] = useState<string | null>(null);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const fetchData = async () => {
|
| 44 |
+
try {
|
| 45 |
+
const res = await fetch(`${API_BASE}/api/weather/historical`);
|
| 46 |
+
const result = await res.json();
|
| 47 |
+
if (result.status === 'success') {
|
| 48 |
+
setData(result.data);
|
| 49 |
+
} else {
|
| 50 |
+
setError(result.error || 'Failed to load data');
|
| 51 |
+
}
|
| 52 |
+
} catch (err) {
|
| 53 |
+
setError('Failed to connect to API');
|
| 54 |
+
} finally {
|
| 55 |
+
setLoading(false);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
fetchData();
|
| 59 |
+
}, []);
|
| 60 |
+
|
| 61 |
+
if (loading) {
|
| 62 |
+
return (
|
| 63 |
+
<Card className="p-6 bg-card border-border">
|
| 64 |
+
<div className="flex items-center gap-3 mb-4">
|
| 65 |
+
<History className="w-6 h-6 text-info animate-pulse" />
|
| 66 |
+
<h3 className="font-bold">Loading Historical Data...</h3>
|
| 67 |
+
</div>
|
| 68 |
+
</Card>
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (error || !data) {
|
| 73 |
+
return (
|
| 74 |
+
<Card className="p-6 bg-card border-border">
|
| 75 |
+
<div className="flex items-center gap-3 mb-4">
|
| 76 |
+
<AlertTriangle className="w-6 h-6 text-warning" />
|
| 77 |
+
<h3 className="font-bold">Historical Data Unavailable</h3>
|
| 78 |
+
</div>
|
| 79 |
+
<p className="text-sm text-muted-foreground">{error}</p>
|
| 80 |
+
</Card>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const stats = data.statistics;
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div className="space-y-4">
|
| 88 |
+
{/* Header */}
|
| 89 |
+
<Card className="p-4 sm:p-6 bg-card border-border">
|
| 90 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
| 91 |
+
<div className="flex items-center gap-3">
|
| 92 |
+
<div className="p-2 rounded-lg bg-info/20">
|
| 93 |
+
<History className="w-5 h-5 text-info" />
|
| 94 |
+
</div>
|
| 95 |
+
<div>
|
| 96 |
+
<h3 className="font-bold text-base sm:text-lg">Historical Flood Pattern Analysis</h3>
|
| 97 |
+
<p className="text-xs text-muted-foreground">{data.period}</p>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
<Badge className="bg-info/20 text-info w-fit">
|
| 101 |
+
<Calendar className="w-3 h-3 mr-1" />
|
| 102 |
+
30-Year Data
|
| 103 |
+
</Badge>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Quick Stats */}
|
| 107 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
| 108 |
+
<motion.div
|
| 109 |
+
initial={{ opacity: 0, y: 10 }}
|
| 110 |
+
animate={{ opacity: 1, y: 0 }}
|
| 111 |
+
transition={{ delay: 0.1 }}
|
| 112 |
+
className="p-3 sm:p-4 bg-muted/30 rounded-lg"
|
| 113 |
+
>
|
| 114 |
+
<div className="flex items-center gap-2 mb-1">
|
| 115 |
+
<Droplets className="w-4 h-4 text-info" />
|
| 116 |
+
<span className="text-xs text-muted-foreground">Avg Annual</span>
|
| 117 |
+
</div>
|
| 118 |
+
<p className="text-lg sm:text-xl font-bold">{stats.avg_annual_rainfall_mm}</p>
|
| 119 |
+
<p className="text-xs text-muted-foreground">mm rainfall</p>
|
| 120 |
+
</motion.div>
|
| 121 |
+
|
| 122 |
+
<motion.div
|
| 123 |
+
initial={{ opacity: 0, y: 10 }}
|
| 124 |
+
animate={{ opacity: 1, y: 0 }}
|
| 125 |
+
transition={{ delay: 0.2 }}
|
| 126 |
+
className="p-3 sm:p-4 bg-muted/30 rounded-lg"
|
| 127 |
+
>
|
| 128 |
+
<div className="flex items-center gap-2 mb-1">
|
| 129 |
+
<CloudRain className="w-4 h-4 text-warning" />
|
| 130 |
+
<span className="text-xs text-muted-foreground">Max Daily</span>
|
| 131 |
+
</div>
|
| 132 |
+
<p className="text-lg sm:text-xl font-bold">{stats.max_daily_rainfall_mm}</p>
|
| 133 |
+
<p className="text-xs text-muted-foreground">mm recorded</p>
|
| 134 |
+
</motion.div>
|
| 135 |
+
|
| 136 |
+
<motion.div
|
| 137 |
+
initial={{ opacity: 0, y: 10 }}
|
| 138 |
+
animate={{ opacity: 1, y: 0 }}
|
| 139 |
+
transition={{ delay: 0.3 }}
|
| 140 |
+
className="p-3 sm:p-4 bg-muted/30 rounded-lg"
|
| 141 |
+
>
|
| 142 |
+
<div className="flex items-center gap-2 mb-1">
|
| 143 |
+
<TrendingUp className="w-4 h-4 text-success" />
|
| 144 |
+
<span className="text-xs text-muted-foreground">Heavy Days</span>
|
| 145 |
+
</div>
|
| 146 |
+
<p className="text-lg sm:text-xl font-bold">{stats.heavy_rain_days_50mm}</p>
|
| 147 |
+
<p className="text-xs text-muted-foreground">>50mm days</p>
|
| 148 |
+
</motion.div>
|
| 149 |
+
|
| 150 |
+
<motion.div
|
| 151 |
+
initial={{ opacity: 0, y: 10 }}
|
| 152 |
+
animate={{ opacity: 1, y: 0 }}
|
| 153 |
+
transition={{ delay: 0.4 }}
|
| 154 |
+
className="p-3 sm:p-4 bg-destructive/10 rounded-lg"
|
| 155 |
+
>
|
| 156 |
+
<div className="flex items-center gap-2 mb-1">
|
| 157 |
+
<AlertTriangle className="w-4 h-4 text-destructive" />
|
| 158 |
+
<span className="text-xs text-muted-foreground">Extreme</span>
|
| 159 |
+
</div>
|
| 160 |
+
<p className="text-lg sm:text-xl font-bold">{stats.extreme_rain_days_100mm}</p>
|
| 161 |
+
<p className="text-xs text-muted-foreground">>100mm days</p>
|
| 162 |
+
</motion.div>
|
| 163 |
+
</div>
|
| 164 |
+
</Card>
|
| 165 |
+
|
| 166 |
+
{/* Climate Change Comparison */}
|
| 167 |
+
<Card className="p-4 sm:p-6 bg-card border-border">
|
| 168 |
+
<h4 className="font-bold mb-4 flex items-center gap-2">
|
| 169 |
+
<TrendingUp className="w-5 h-5 text-warning" />
|
| 170 |
+
How Climate Has Changed
|
| 171 |
+
</h4>
|
| 172 |
+
|
| 173 |
+
<div className="overflow-x-auto">
|
| 174 |
+
<table className="w-full text-sm">
|
| 175 |
+
<thead>
|
| 176 |
+
<tr className="border-b border-border">
|
| 177 |
+
<th className="text-left py-2 px-2 text-muted-foreground font-medium">Period</th>
|
| 178 |
+
<th className="text-right py-2 px-2 text-muted-foreground font-medium">Avg Rainfall</th>
|
| 179 |
+
<th className="text-right py-2 px-2 text-muted-foreground font-medium">Extreme Days</th>
|
| 180 |
+
<th className="text-right py-2 px-2 text-muted-foreground font-medium">Max Daily</th>
|
| 181 |
+
</tr>
|
| 182 |
+
</thead>
|
| 183 |
+
<tbody>
|
| 184 |
+
{data.decadal_analysis.map((decade, idx) => (
|
| 185 |
+
<motion.tr
|
| 186 |
+
key={decade.period}
|
| 187 |
+
initial={{ opacity: 0, x: -10 }}
|
| 188 |
+
animate={{ opacity: 1, x: 0 }}
|
| 189 |
+
transition={{ delay: 0.1 * idx }}
|
| 190 |
+
className="border-b border-border/50 hover:bg-muted/30"
|
| 191 |
+
>
|
| 192 |
+
<td className="py-3 px-2 font-medium">{decade.period}</td>
|
| 193 |
+
<td className="py-3 px-2 text-right">{decade.avg_rainfall_mm} mm</td>
|
| 194 |
+
<td className="py-3 px-2 text-right">
|
| 195 |
+
<Badge className={idx === 2 ? 'bg-destructive/20 text-destructive' : 'bg-muted'}>
|
| 196 |
+
{decade.extreme_days}
|
| 197 |
+
</Badge>
|
| 198 |
+
</td>
|
| 199 |
+
<td className="py-3 px-2 text-right">{decade.max_daily_mm} mm</td>
|
| 200 |
+
</motion.tr>
|
| 201 |
+
))}
|
| 202 |
+
</tbody>
|
| 203 |
+
</table>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
{/* Key Findings */}
|
| 207 |
+
<div className="mt-4 p-3 bg-warning/10 border border-warning/30 rounded-lg">
|
| 208 |
+
<p className="text-sm font-medium text-warning mb-2">📊 Key Finding</p>
|
| 209 |
+
<p className="text-sm">{data.key_findings[0]}</p>
|
| 210 |
+
</div>
|
| 211 |
+
</Card>
|
| 212 |
+
|
| 213 |
+
{/* High Risk Periods */}
|
| 214 |
+
<Card className="p-4 sm:p-6 bg-card border-border">
|
| 215 |
+
<h4 className="font-bold mb-4 flex items-center gap-2">
|
| 216 |
+
<Calendar className="w-5 h-5 text-primary" />
|
| 217 |
+
High Risk Periods
|
| 218 |
+
</h4>
|
| 219 |
+
<div className="flex flex-wrap gap-2">
|
| 220 |
+
{data.high_risk_periods.map((period, idx) => (
|
| 221 |
+
<Badge
|
| 222 |
+
key={idx}
|
| 223 |
+
className={`${period.risk === 'high'
|
| 224 |
+
? 'bg-destructive/20 text-destructive'
|
| 225 |
+
: 'bg-warning/20 text-warning'
|
| 226 |
+
}`}
|
| 227 |
+
>
|
| 228 |
+
{period.months}: {period.type}
|
| 229 |
+
</Badge>
|
| 230 |
+
))}
|
| 231 |
+
</div>
|
| 232 |
+
</Card>
|
| 233 |
+
</div>
|
| 234 |
+
);
|
| 235 |
+
}
|
frontend/app/components/dashboard/NationalThreatCard.tsx
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import { Card } from '../ui/card';
|
| 5 |
+
import { Badge } from '../ui/badge';
|
| 6 |
+
import { Shield, AlertTriangle, TrendingUp, CloudRain, Waves, Activity } from 'lucide-react';
|
| 7 |
+
import { motion } from 'framer-motion';
|
| 8 |
+
|
| 9 |
+
interface ThreatData {
|
| 10 |
+
status: string;
|
| 11 |
+
national_threat_score: number;
|
| 12 |
+
threat_level: string;
|
| 13 |
+
color: string;
|
| 14 |
+
breakdown: {
|
| 15 |
+
river_contribution: number;
|
| 16 |
+
alert_contribution: number;
|
| 17 |
+
seasonal_contribution: number;
|
| 18 |
+
};
|
| 19 |
+
risk_summary: {
|
| 20 |
+
critical_count: number;
|
| 21 |
+
high_count: number;
|
| 22 |
+
medium_count: number;
|
| 23 |
+
critical_districts: string[];
|
| 24 |
+
high_risk_districts: string[];
|
| 25 |
+
medium_risk_districts: string[];
|
| 26 |
+
};
|
| 27 |
+
calculated_at: string;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 31 |
+
|
| 32 |
+
export default function NationalThreatCard() {
|
| 33 |
+
const [data, setData] = useState<ThreatData | null>(null);
|
| 34 |
+
const [loading, setLoading] = useState(true);
|
| 35 |
+
const [error, setError] = useState<string | null>(null);
|
| 36 |
+
|
| 37 |
+
const fetchData = async () => {
|
| 38 |
+
try {
|
| 39 |
+
const res = await fetch(`${API_BASE}/api/weather/threat`);
|
| 40 |
+
const result = await res.json();
|
| 41 |
+
if (result.status === 'success') {
|
| 42 |
+
setData(result);
|
| 43 |
+
setError(null);
|
| 44 |
+
} else {
|
| 45 |
+
setError(result.error || 'Failed to load data');
|
| 46 |
+
}
|
| 47 |
+
} catch (err) {
|
| 48 |
+
setError('Failed to connect to API');
|
| 49 |
+
} finally {
|
| 50 |
+
setLoading(false);
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
fetchData();
|
| 56 |
+
// Refresh every 5 minutes
|
| 57 |
+
const interval = setInterval(fetchData, 5 * 60 * 1000);
|
| 58 |
+
return () => clearInterval(interval);
|
| 59 |
+
}, []);
|
| 60 |
+
|
| 61 |
+
const getThreatConfig = (level: string) => {
|
| 62 |
+
switch (level) {
|
| 63 |
+
case 'CRITICAL':
|
| 64 |
+
return {
|
| 65 |
+
bgColor: 'bg-destructive/20',
|
| 66 |
+
borderColor: 'border-destructive',
|
| 67 |
+
textColor: 'text-destructive',
|
| 68 |
+
icon: AlertTriangle,
|
| 69 |
+
gradient: 'from-destructive to-red-700',
|
| 70 |
+
pulse: true
|
| 71 |
+
};
|
| 72 |
+
case 'HIGH':
|
| 73 |
+
return {
|
| 74 |
+
bgColor: 'bg-warning/20',
|
| 75 |
+
borderColor: 'border-warning',
|
| 76 |
+
textColor: 'text-warning',
|
| 77 |
+
icon: TrendingUp,
|
| 78 |
+
gradient: 'from-warning to-orange-600',
|
| 79 |
+
pulse: true
|
| 80 |
+
};
|
| 81 |
+
case 'MODERATE':
|
| 82 |
+
return {
|
| 83 |
+
bgColor: 'bg-yellow-500/20',
|
| 84 |
+
borderColor: 'border-yellow-500',
|
| 85 |
+
textColor: 'text-yellow-500',
|
| 86 |
+
icon: Activity,
|
| 87 |
+
gradient: 'from-yellow-500 to-amber-600',
|
| 88 |
+
pulse: false
|
| 89 |
+
};
|
| 90 |
+
default:
|
| 91 |
+
return {
|
| 92 |
+
bgColor: 'bg-success/20',
|
| 93 |
+
borderColor: 'border-success',
|
| 94 |
+
textColor: 'text-success',
|
| 95 |
+
icon: Shield,
|
| 96 |
+
gradient: 'from-success to-green-600',
|
| 97 |
+
pulse: false
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
if (loading) {
|
| 103 |
+
return (
|
| 104 |
+
<Card className="p-6 bg-card border-border">
|
| 105 |
+
<div className="flex items-center gap-3">
|
| 106 |
+
<Shield className="w-6 h-6 text-muted-foreground animate-pulse" />
|
| 107 |
+
<span className="text-muted-foreground">Calculating threat level...</span>
|
| 108 |
+
</div>
|
| 109 |
+
</Card>
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (error || !data) {
|
| 114 |
+
return (
|
| 115 |
+
<Card className="p-6 bg-card border-border">
|
| 116 |
+
<div className="flex items-center gap-3">
|
| 117 |
+
<AlertTriangle className="w-6 h-6 text-warning" />
|
| 118 |
+
<span className="text-warning">Threat assessment unavailable</span>
|
| 119 |
+
</div>
|
| 120 |
+
</Card>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const config = getThreatConfig(data.threat_level);
|
| 125 |
+
const ThreatIcon = config.icon;
|
| 126 |
+
const { breakdown, risk_summary } = data;
|
| 127 |
+
|
| 128 |
+
return (
|
| 129 |
+
<Card className={`p-4 sm:p-6 ${config.bgColor} border-l-4 ${config.borderColor}`}>
|
| 130 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 131 |
+
{/* Left: Threat Score */}
|
| 132 |
+
<div className="flex items-center gap-4">
|
| 133 |
+
<motion.div
|
| 134 |
+
initial={{ scale: 0.8, opacity: 0 }}
|
| 135 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 136 |
+
className={`relative p-3 sm:p-4 rounded-full bg-gradient-to-br ${config.gradient}`}
|
| 137 |
+
>
|
| 138 |
+
<ThreatIcon className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
| 139 |
+
{config.pulse && (
|
| 140 |
+
<span className="absolute inset-0 rounded-full animate-ping bg-current opacity-20" />
|
| 141 |
+
)}
|
| 142 |
+
</motion.div>
|
| 143 |
+
|
| 144 |
+
<div>
|
| 145 |
+
<p className="text-xs sm:text-sm text-muted-foreground uppercase tracking-wide">National Threat</p>
|
| 146 |
+
<div className="flex items-baseline gap-2">
|
| 147 |
+
<motion.span
|
| 148 |
+
initial={{ opacity: 0, y: 10 }}
|
| 149 |
+
animate={{ opacity: 1, y: 0 }}
|
| 150 |
+
className="text-3xl sm:text-4xl font-bold"
|
| 151 |
+
>
|
| 152 |
+
{data.national_threat_score}
|
| 153 |
+
</motion.span>
|
| 154 |
+
<span className="text-lg text-muted-foreground">/100</span>
|
| 155 |
+
</div>
|
| 156 |
+
<Badge className={`${config.bgColor} ${config.textColor} mt-1`}>
|
| 157 |
+
{data.threat_level}
|
| 158 |
+
</Badge>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* Right: Breakdown */}
|
| 163 |
+
<div className="grid grid-cols-3 gap-2 sm:gap-4 text-center">
|
| 164 |
+
<div className="p-2 sm:p-3 bg-background/50 rounded-lg">
|
| 165 |
+
<Waves className="w-4 h-4 mx-auto mb-1 text-info" />
|
| 166 |
+
<p className="text-xs text-muted-foreground">Rivers</p>
|
| 167 |
+
<p className="font-bold">{breakdown.river_contribution}</p>
|
| 168 |
+
</div>
|
| 169 |
+
<div className="p-2 sm:p-3 bg-background/50 rounded-lg">
|
| 170 |
+
<AlertTriangle className="w-4 h-4 mx-auto mb-1 text-warning" />
|
| 171 |
+
<p className="text-xs text-muted-foreground">Alerts</p>
|
| 172 |
+
<p className="font-bold">{breakdown.alert_contribution}</p>
|
| 173 |
+
</div>
|
| 174 |
+
<div className="p-2 sm:p-3 bg-background/50 rounded-lg">
|
| 175 |
+
<CloudRain className="w-4 h-4 mx-auto mb-1 text-primary" />
|
| 176 |
+
<p className="text-xs text-muted-foreground">Season</p>
|
| 177 |
+
<p className="font-bold">{breakdown.seasonal_contribution}</p>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* Risk Districts */}
|
| 183 |
+
{(risk_summary.critical_count > 0 || risk_summary.high_count > 0) && (
|
| 184 |
+
<div className="mt-4 pt-4 border-t border-border/50">
|
| 185 |
+
<p className="text-xs text-muted-foreground mb-2">At-Risk Districts</p>
|
| 186 |
+
<div className="flex flex-wrap gap-2">
|
| 187 |
+
{risk_summary.critical_districts.map((d) => (
|
| 188 |
+
<Badge key={d} className="bg-destructive/20 text-destructive text-xs">
|
| 189 |
+
{d}
|
| 190 |
+
</Badge>
|
| 191 |
+
))}
|
| 192 |
+
{risk_summary.high_risk_districts.map((d) => (
|
| 193 |
+
<Badge key={d} className="bg-warning/20 text-warning text-xs">
|
| 194 |
+
{d}
|
| 195 |
+
</Badge>
|
| 196 |
+
))}
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
</Card>
|
| 201 |
+
);
|
| 202 |
+
}
|
frontend/app/components/dashboard/RiverNetStatus.tsx
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Card } from "../ui/card";
|
| 4 |
+
import { Badge } from "../ui/badge";
|
| 5 |
+
import { Waves, AlertTriangle, CheckCircle, TrendingUp, Clock } from "lucide-react";
|
| 6 |
+
import { motion } from "framer-motion";
|
| 7 |
+
|
| 8 |
+
interface RiverData {
|
| 9 |
+
location_key: string;
|
| 10 |
+
name: string;
|
| 11 |
+
region: string;
|
| 12 |
+
status: "danger" | "warning" | "rising" | "normal" | "unknown" | "error";
|
| 13 |
+
water_level?: {
|
| 14 |
+
value: number;
|
| 15 |
+
unit: string;
|
| 16 |
+
};
|
| 17 |
+
url?: string;
|
| 18 |
+
last_updated?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface RiverNetData {
|
| 22 |
+
rivers: RiverData[];
|
| 23 |
+
alerts: Array<{
|
| 24 |
+
text: string;
|
| 25 |
+
severity: string;
|
| 26 |
+
source: string;
|
| 27 |
+
}>;
|
| 28 |
+
summary: {
|
| 29 |
+
total_monitored: number;
|
| 30 |
+
overall_status: string;
|
| 31 |
+
has_alerts: boolean;
|
| 32 |
+
status_breakdown?: Record<string, number>;
|
| 33 |
+
};
|
| 34 |
+
fetched_at: string;
|
| 35 |
+
source: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface RiverNetStatusProps {
|
| 39 |
+
riverData?: RiverNetData | null;
|
| 40 |
+
compact?: boolean;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const statusConfig = {
|
| 44 |
+
danger: {
|
| 45 |
+
color: "destructive",
|
| 46 |
+
bgColor: "bg-destructive/20",
|
| 47 |
+
borderColor: "border-destructive",
|
| 48 |
+
textColor: "text-destructive",
|
| 49 |
+
icon: AlertTriangle,
|
| 50 |
+
emoji: "🔴",
|
| 51 |
+
label: "DANGER"
|
| 52 |
+
},
|
| 53 |
+
warning: {
|
| 54 |
+
color: "warning",
|
| 55 |
+
bgColor: "bg-warning/20",
|
| 56 |
+
borderColor: "border-warning",
|
| 57 |
+
textColor: "text-warning",
|
| 58 |
+
icon: AlertTriangle,
|
| 59 |
+
emoji: "🟠",
|
| 60 |
+
label: "WARNING"
|
| 61 |
+
},
|
| 62 |
+
rising: {
|
| 63 |
+
color: "primary",
|
| 64 |
+
bgColor: "bg-primary/20",
|
| 65 |
+
borderColor: "border-primary",
|
| 66 |
+
textColor: "text-primary",
|
| 67 |
+
icon: TrendingUp,
|
| 68 |
+
emoji: "🟡",
|
| 69 |
+
label: "RISING"
|
| 70 |
+
},
|
| 71 |
+
normal: {
|
| 72 |
+
color: "success",
|
| 73 |
+
bgColor: "bg-success/20",
|
| 74 |
+
borderColor: "border-success",
|
| 75 |
+
textColor: "text-success",
|
| 76 |
+
icon: CheckCircle,
|
| 77 |
+
emoji: "🟢",
|
| 78 |
+
label: "NORMAL"
|
| 79 |
+
},
|
| 80 |
+
unknown: {
|
| 81 |
+
color: "muted",
|
| 82 |
+
bgColor: "bg-muted/20",
|
| 83 |
+
borderColor: "border-muted",
|
| 84 |
+
textColor: "text-muted-foreground",
|
| 85 |
+
icon: Clock,
|
| 86 |
+
emoji: "⚪",
|
| 87 |
+
label: "UNKNOWN"
|
| 88 |
+
},
|
| 89 |
+
error: {
|
| 90 |
+
color: "destructive",
|
| 91 |
+
bgColor: "bg-destructive/10",
|
| 92 |
+
borderColor: "border-destructive/50",
|
| 93 |
+
textColor: "text-destructive/70",
|
| 94 |
+
icon: AlertTriangle,
|
| 95 |
+
emoji: "❌",
|
| 96 |
+
label: "ERROR"
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const RiverNetStatus = ({ riverData, compact = false }: RiverNetStatusProps) => {
|
| 101 |
+
if (!riverData || !riverData.rivers || riverData.rivers.length === 0) {
|
| 102 |
+
return (
|
| 103 |
+
<Card className="p-6 bg-card border-border">
|
| 104 |
+
<div className="flex items-center gap-3 mb-4">
|
| 105 |
+
<Waves className="w-6 h-6 text-info" />
|
| 106 |
+
<h3 className="font-bold">FLOOD MONITORING</h3>
|
| 107 |
+
<Badge className="ml-auto bg-muted">Offline</Badge>
|
| 108 |
+
</div>
|
| 109 |
+
<div className="text-center text-muted-foreground py-4">
|
| 110 |
+
<Waves className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
| 111 |
+
<p className="text-sm">River monitoring data unavailable</p>
|
| 112 |
+
<p className="text-xs mt-1">Check rivernet.lk for live data</p>
|
| 113 |
+
</div>
|
| 114 |
+
</Card>
|
| 115 |
+
);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const { rivers, summary, alerts, fetched_at } = riverData;
|
| 119 |
+
const overallStatus = summary?.overall_status || "normal";
|
| 120 |
+
const statusInfo = statusConfig[overallStatus as keyof typeof statusConfig] || statusConfig.unknown;
|
| 121 |
+
const StatusIcon = statusInfo.icon;
|
| 122 |
+
|
| 123 |
+
// Count rivers by status
|
| 124 |
+
const statusCounts = summary?.status_breakdown || {};
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<Card className={`p-6 bg-card border-border ${summary?.has_alerts ? 'border-warning/50' : ''}`}>
|
| 128 |
+
{/* Header */}
|
| 129 |
+
<div className="flex items-center justify-between mb-4">
|
| 130 |
+
<div className="flex items-center gap-3">
|
| 131 |
+
<div className={`p-2 rounded-lg ${statusInfo.bgColor}`}>
|
| 132 |
+
<Waves className={`w-6 h-6 ${statusInfo.textColor}`} />
|
| 133 |
+
</div>
|
| 134 |
+
<div>
|
| 135 |
+
<h3 className="font-bold flex items-center gap-2">
|
| 136 |
+
🌊 FLOOD MONITORING
|
| 137 |
+
{summary?.has_alerts && (
|
| 138 |
+
<Badge className="bg-warning text-warning-foreground">ALERTS</Badge>
|
| 139 |
+
)}
|
| 140 |
+
</h3>
|
| 141 |
+
<p className="text-xs text-muted-foreground">
|
| 142 |
+
RiverNet.lk • {rivers.length} rivers monitored
|
| 143 |
+
</p>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
<div className="text-right">
|
| 147 |
+
<Badge className={`${statusInfo.bgColor} ${statusInfo.textColor}`}>
|
| 148 |
+
{statusInfo.emoji} {statusInfo.label}
|
| 149 |
+
</Badge>
|
| 150 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 151 |
+
{new Date(fetched_at).toLocaleTimeString()}
|
| 152 |
+
</p>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
{/* Status Summary */}
|
| 157 |
+
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 mb-4">
|
| 158 |
+
{Object.entries(statusCounts).map(([status, count]) => {
|
| 159 |
+
if (count === 0) return null;
|
| 160 |
+
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown;
|
| 161 |
+
return (
|
| 162 |
+
<div key={status} className={`p-2 rounded text-center ${config.bgColor}`}>
|
| 163 |
+
<p className={`text-lg font-bold ${config.textColor}`}>{count}</p>
|
| 164 |
+
<p className="text-xs text-muted-foreground uppercase">{status}</p>
|
| 165 |
+
</div>
|
| 166 |
+
);
|
| 167 |
+
})}
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
{/* Alerts Section */}
|
| 171 |
+
{alerts && alerts.length > 0 && (
|
| 172 |
+
<div className="mb-4 p-3 rounded-lg bg-warning/10 border border-warning/30">
|
| 173 |
+
<p className="text-sm font-semibold text-warning mb-2">⚠️ Active Alerts</p>
|
| 174 |
+
{alerts.slice(0, 2).map((alert, idx) => (
|
| 175 |
+
<p key={idx} className="text-xs text-warning/80 mb-1">
|
| 176 |
+
• {alert.text.slice(0, 100)}...
|
| 177 |
+
</p>
|
| 178 |
+
))}
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
|
| 182 |
+
{/* Rivers Grid */}
|
| 183 |
+
<div className={`grid ${compact ? 'grid-cols-2' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'} gap-3`}>
|
| 184 |
+
{rivers.map((river, idx) => {
|
| 185 |
+
const config = statusConfig[river.status] || statusConfig.normal;
|
| 186 |
+
const RiverStatusIcon = config.icon;
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<motion.div
|
| 190 |
+
key={river.location_key}
|
| 191 |
+
initial={{ opacity: 0, y: 10 }}
|
| 192 |
+
animate={{ opacity: 1, y: 0 }}
|
| 193 |
+
transition={{ delay: idx * 0.05 }}
|
| 194 |
+
>
|
| 195 |
+
<Card className={`p-3 ${config.bgColor} border-l-4 ${config.borderColor} hover:shadow-md transition-all cursor-pointer`}>
|
| 196 |
+
<div className="flex items-start justify-between">
|
| 197 |
+
<div className="flex-1">
|
| 198 |
+
<div className="flex items-center gap-2 mb-1">
|
| 199 |
+
<RiverStatusIcon className={`w-4 h-4 ${config.textColor}`} />
|
| 200 |
+
<span className="font-semibold text-sm">{river.name}</span>
|
| 201 |
+
</div>
|
| 202 |
+
<p className="text-xs text-muted-foreground">{river.region}</p>
|
| 203 |
+
{river.water_level && (
|
| 204 |
+
<p className={`text-xs font-mono ${config.textColor} mt-1`}>
|
| 205 |
+
Level: {river.water_level.value}{river.water_level.unit}
|
| 206 |
+
</p>
|
| 207 |
+
)}
|
| 208 |
+
</div>
|
| 209 |
+
<Badge className={`${config.bgColor} ${config.textColor} text-xs`}>
|
| 210 |
+
{config.emoji} {river.status.toUpperCase()}
|
| 211 |
+
</Badge>
|
| 212 |
+
</div>
|
| 213 |
+
</Card>
|
| 214 |
+
</motion.div>
|
| 215 |
+
);
|
| 216 |
+
})}
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{/* Footer Link */}
|
| 220 |
+
<div className="mt-4 pt-3 border-t border-border flex items-center justify-between">
|
| 221 |
+
<p className="text-xs text-muted-foreground">
|
| 222 |
+
Source: <a href="https://rivernet.lk" target="_blank" rel="noopener noreferrer"
|
| 223 |
+
className="text-primary hover:underline">rivernet.lk</a>
|
| 224 |
+
</p>
|
| 225 |
+
{rivers.length > (compact ? 4 : 6) && (
|
| 226 |
+
<Badge variant="outline" className="text-xs">
|
| 227 |
+
+{rivers.length - (compact ? 4 : 6)} more rivers
|
| 228 |
+
</Badge>
|
| 229 |
+
)}
|
| 230 |
+
</div>
|
| 231 |
+
</Card>
|
| 232 |
+
);
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
export default RiverNetStatus;
|
frontend/app/components/dashboard/StockPredictions.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card } from "../ui/card";
|
| 2 |
+
import { Badge } from "../ui/badge";
|
| 3 |
+
import { TrendingUp, TrendingDown, Activity } from "lucide-react";
|
| 4 |
+
import { motion } from "framer-motion";
|
| 5 |
+
import { useRogerData } from "../../hooks/use-roger-data";
|
| 6 |
+
|
| 7 |
+
const StockPredictions = () => {
|
| 8 |
+
const { events } = useRogerData();
|
| 9 |
+
|
| 10 |
+
// Filter for economic/market events
|
| 11 |
+
const marketEvents = events.filter(e =>
|
| 12 |
+
e.domain === 'economical' || e.domain === 'market'
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
// Extract market insights
|
| 16 |
+
const marketInsights = marketEvents.map(event => {
|
| 17 |
+
const isBullish = event.impact_type === 'opportunity' ||
|
| 18 |
+
event.summary.toLowerCase().includes('bullish') ||
|
| 19 |
+
event.summary.toLowerCase().includes('growth');
|
| 20 |
+
|
| 21 |
+
const isBearish = event.summary.toLowerCase().includes('bearish') ||
|
| 22 |
+
event.summary.toLowerCase().includes('contraction');
|
| 23 |
+
|
| 24 |
+
return {
|
| 25 |
+
symbol: "ASPI",
|
| 26 |
+
title: event.summary,
|
| 27 |
+
sentiment: isBullish ? 'bullish' : isBearish ? 'bearish' : 'neutral',
|
| 28 |
+
confidence: event.confidence,
|
| 29 |
+
severity: event.severity,
|
| 30 |
+
timestamp: event.timestamp
|
| 31 |
+
};
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
// Mock stock data structure (in production, parse from actual events)
|
| 35 |
+
const stocks = [
|
| 36 |
+
{
|
| 37 |
+
symbol: "JKH.N0000",
|
| 38 |
+
name: "John Keells Holdings",
|
| 39 |
+
current: 145.50,
|
| 40 |
+
predicted: 148.20,
|
| 41 |
+
change: 2.70,
|
| 42 |
+
changePercent: 1.86,
|
| 43 |
+
volume: "1.2M",
|
| 44 |
+
sentiment: marketInsights[0]?.sentiment || 'neutral'
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
symbol: "COMB.N0000",
|
| 48 |
+
name: "Commercial Bank",
|
| 49 |
+
current: 89.75,
|
| 50 |
+
predicted: 87.30,
|
| 51 |
+
change: -2.45,
|
| 52 |
+
changePercent: -2.73,
|
| 53 |
+
volume: "856K",
|
| 54 |
+
sentiment: marketInsights[1]?.sentiment || 'neutral'
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
symbol: "HNB.N0000",
|
| 58 |
+
name: "Hatton National Bank",
|
| 59 |
+
current: 178.20,
|
| 60 |
+
predicted: 182.50,
|
| 61 |
+
change: 4.30,
|
| 62 |
+
changePercent: 2.41,
|
| 63 |
+
volume: "632K",
|
| 64 |
+
sentiment: 'bullish'
|
| 65 |
+
},
|
| 66 |
+
];
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<div className="space-y-6">
|
| 70 |
+
<Card className="p-6 bg-card border-border">
|
| 71 |
+
<div className="flex items-center justify-between mb-4">
|
| 72 |
+
<div className="flex items-center gap-2">
|
| 73 |
+
<Activity className="w-5 h-5 text-success" />
|
| 74 |
+
<h2 className="text-lg font-bold">MARKET INTELLIGENCE - CSE</h2>
|
| 75 |
+
</div>
|
| 76 |
+
<Badge className="font-mono text-xs border">
|
| 77 |
+
LIVE AI ANALYSIS
|
| 78 |
+
</Badge>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
{/* AI-Generated Market Insights */}
|
| 82 |
+
<div className="mb-6 space-y-2">
|
| 83 |
+
<h3 className="text-sm font-semibold text-muted-foreground uppercase">AI Market Analysis</h3>
|
| 84 |
+
{marketInsights.length > 0 ? (
|
| 85 |
+
marketInsights.slice(0, 3).map((insight, idx) => (
|
| 86 |
+
<motion.div
|
| 87 |
+
key={idx}
|
| 88 |
+
initial={{ opacity: 0, x: -10 }}
|
| 89 |
+
animate={{ opacity: 1, x: 0 }}
|
| 90 |
+
transition={{ delay: idx * 0.1 }}
|
| 91 |
+
className={`p-3 rounded border-l-4 ${
|
| 92 |
+
insight.sentiment === 'bullish' ? 'border-l-success bg-success/10' :
|
| 93 |
+
insight.sentiment === 'bearish' ? 'border-l-destructive bg-destructive/10' :
|
| 94 |
+
'border-l-muted bg-muted/30'
|
| 95 |
+
}`}
|
| 96 |
+
>
|
| 97 |
+
<div className="flex items-center gap-2 mb-1">
|
| 98 |
+
{insight.sentiment === 'bullish' && <TrendingUp className="w-4 h-4 text-success" />}
|
| 99 |
+
{insight.sentiment === 'bearish' && <TrendingDown className="w-4 h-4 text-destructive" />}
|
| 100 |
+
<Badge className="text-xs">{insight.sentiment.toUpperCase()}</Badge>
|
| 101 |
+
<span className="text-xs text-muted-foreground ml-auto">
|
| 102 |
+
{Math.round(insight.confidence * 100)}% confidence
|
| 103 |
+
</span>
|
| 104 |
+
</div>
|
| 105 |
+
<p className="text-sm">{insight.title}</p>
|
| 106 |
+
</motion.div>
|
| 107 |
+
))
|
| 108 |
+
) : (
|
| 109 |
+
<p className="text-sm text-muted-foreground">Waiting for market data...</p>
|
| 110 |
+
)}
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Stock Grid */}
|
| 114 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
| 115 |
+
{stocks.map((stock, idx) => {
|
| 116 |
+
const isPositive = stock.change > 0;
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
<motion.div
|
| 120 |
+
key={stock.symbol}
|
| 121 |
+
initial={{ opacity: 0, y: 20 }}
|
| 122 |
+
animate={{ opacity: 1, y: 0 }}
|
| 123 |
+
transition={{ delay: idx * 0.1 }}
|
| 124 |
+
>
|
| 125 |
+
<Card className="p-4 bg-muted/30 border-border hover:border-primary/50 transition-all">
|
| 126 |
+
<div className="flex items-start justify-between mb-2">
|
| 127 |
+
<div>
|
| 128 |
+
<h3 className="font-bold text-sm">{stock.symbol}</h3>
|
| 129 |
+
<p className="text-xs text-muted-foreground">{stock.name}</p>
|
| 130 |
+
</div>
|
| 131 |
+
<Badge
|
| 132 |
+
className={`font-mono text-xs ${isPositive ? "bg-primary text-primary-foreground" : "bg-destructive text-destructive-foreground"}`}
|
| 133 |
+
>
|
| 134 |
+
{isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
|
| 135 |
+
{isPositive ? "+" : ""}{stock.changePercent.toFixed(2)}%
|
| 136 |
+
</Badge>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div className="grid grid-cols-2 gap-3 mt-3">
|
| 140 |
+
<div>
|
| 141 |
+
<p className="text-xs text-muted-foreground mb-1">Current</p>
|
| 142 |
+
<p className="text-lg font-bold font-mono">
|
| 143 |
+
LKR {stock.current.toFixed(2)}
|
| 144 |
+
</p>
|
| 145 |
+
</div>
|
| 146 |
+
<div>
|
| 147 |
+
<p className="text-xs text-muted-foreground mb-1">AI Forecast</p>
|
| 148 |
+
<p className={`text-lg font-bold font-mono ${isPositive ? "text-success" : "text-destructive"}`}>
|
| 149 |
+
LKR {stock.predicted.toFixed(2)}
|
| 150 |
+
</p>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
| 155 |
+
<span className="text-xs text-muted-foreground">
|
| 156 |
+
Vol: {stock.volume}
|
| 157 |
+
</span>
|
| 158 |
+
<span className={`text-xs font-bold font-mono ${isPositive ? "text-success" : "text-destructive"}`}>
|
| 159 |
+
{isPositive ? "+" : ""}{stock.change.toFixed(2)}
|
| 160 |
+
</span>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* AI Sentiment Badge */}
|
| 164 |
+
<div className="mt-2">
|
| 165 |
+
<Badge className={`text-xs ${
|
| 166 |
+
stock.sentiment === 'bullish' ? 'bg-success/20 text-success' :
|
| 167 |
+
stock.sentiment === 'bearish' ? 'bg-destructive/20 text-destructive' :
|
| 168 |
+
'bg-muted'
|
| 169 |
+
}`}>
|
| 170 |
+
AI: {stock.sentiment.toUpperCase()}
|
| 171 |
+
</Badge>
|
| 172 |
+
</div>
|
| 173 |
+
</Card>
|
| 174 |
+
</motion.div>
|
| 175 |
+
);
|
| 176 |
+
})}
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="mt-4 p-3 bg-muted/20 rounded border border-border">
|
| 180 |
+
<p className="text-xs text-muted-foreground font-mono">
|
| 181 |
+
<span className="text-warning font-bold">⚠ DISCLAIMER:</span> AI predictions based on real-time data analysis. Not financial advice.
|
| 182 |
+
</p>
|
| 183 |
+
</div>
|
| 184 |
+
</Card>
|
| 185 |
+
</div>
|
| 186 |
+
);
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
export default StockPredictions;
|
frontend/app/components/dashboard/WeatherPredictions.tsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
|
| 5 |
+
interface DistrictPrediction {
|
| 6 |
+
temperature: {
|
| 7 |
+
high_c: number;
|
| 8 |
+
low_c: number;
|
| 9 |
+
};
|
| 10 |
+
rainfall: {
|
| 11 |
+
amount_mm: number;
|
| 12 |
+
probability: number;
|
| 13 |
+
};
|
| 14 |
+
flood_risk: number;
|
| 15 |
+
humidity_pct: number;
|
| 16 |
+
severity: "normal" | "advisory" | "warning" | "critical";
|
| 17 |
+
station_used: string;
|
| 18 |
+
is_fallback?: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface WeatherPredictions {
|
| 22 |
+
status: string;
|
| 23 |
+
prediction_date: string;
|
| 24 |
+
generated_at: string;
|
| 25 |
+
districts: Record<string, DistrictPrediction>;
|
| 26 |
+
total_districts: number;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000";
|
| 30 |
+
|
| 31 |
+
const SEVERITY_COLORS = {
|
| 32 |
+
normal: "bg-green-500/20 text-green-400 border-green-500/50",
|
| 33 |
+
advisory: "bg-yellow-500/20 text-yellow-400 border-yellow-500/50",
|
| 34 |
+
warning: "bg-orange-500/20 text-orange-400 border-orange-500/50",
|
| 35 |
+
critical: "bg-red-500/20 text-red-400 border-red-500/50",
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const SEVERITY_ICONS = {
|
| 39 |
+
normal: "☀️",
|
| 40 |
+
advisory: "🌤️",
|
| 41 |
+
warning: "⛈️",
|
| 42 |
+
critical: "🌊",
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export default function WeatherPredictions() {
|
| 46 |
+
const [predictions, setPredictions] = useState<WeatherPredictions | null>(null);
|
| 47 |
+
const [loading, setLoading] = useState(true);
|
| 48 |
+
const [error, setError] = useState<string | null>(null);
|
| 49 |
+
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
|
| 50 |
+
const [filter, setFilter] = useState<string>("all");
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
fetchPredictions();
|
| 54 |
+
// Refresh every 30 minutes
|
| 55 |
+
const interval = setInterval(fetchPredictions, 30 * 60 * 1000);
|
| 56 |
+
return () => clearInterval(interval);
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
const fetchPredictions = async () => {
|
| 60 |
+
try {
|
| 61 |
+
const res = await fetch(`${API_BASE}/api/weather/predictions`);
|
| 62 |
+
const data = await res.json();
|
| 63 |
+
|
| 64 |
+
if (data.status === "success") {
|
| 65 |
+
setPredictions(data);
|
| 66 |
+
setError(null);
|
| 67 |
+
} else {
|
| 68 |
+
setError(data.message || "Failed to load predictions");
|
| 69 |
+
}
|
| 70 |
+
} catch (err) {
|
| 71 |
+
setError("Failed to connect to weather API");
|
| 72 |
+
} finally {
|
| 73 |
+
setLoading(false);
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const getFilteredDistricts = () => {
|
| 78 |
+
if (!predictions?.districts) return [];
|
| 79 |
+
|
| 80 |
+
const entries = Object.entries(predictions.districts);
|
| 81 |
+
|
| 82 |
+
if (filter === "all") return entries;
|
| 83 |
+
return entries.filter(([_, pred]) => pred.severity === filter);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const getSeverityCounts = () => {
|
| 87 |
+
if (!predictions?.districts) return { normal: 0, advisory: 0, warning: 0, critical: 0 };
|
| 88 |
+
|
| 89 |
+
const counts = { normal: 0, advisory: 0, warning: 0, critical: 0 };
|
| 90 |
+
Object.values(predictions.districts).forEach((pred) => {
|
| 91 |
+
counts[pred.severity] = (counts[pred.severity] || 0) + 1;
|
| 92 |
+
});
|
| 93 |
+
return counts;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
if (loading) {
|
| 97 |
+
return (
|
| 98 |
+
<div className="bg-slate-800/50 rounded-xl p-6 border border-slate-700/50">
|
| 99 |
+
<div className="animate-pulse space-y-4">
|
| 100 |
+
<div className="h-6 bg-slate-700 rounded w-1/3"></div>
|
| 101 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 102 |
+
{[1, 2, 3, 4].map((i) => (
|
| 103 |
+
<div key={i} className="h-24 bg-slate-700 rounded-lg"></div>
|
| 104 |
+
))}
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const sevCounts = getSeverityCounts();
|
| 112 |
+
const filteredDistricts = getFilteredDistricts();
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="bg-slate-800/50 rounded-xl p-6 border border-slate-700/50">
|
| 116 |
+
{/* Header */}
|
| 117 |
+
<div className="flex items-center justify-between mb-6">
|
| 118 |
+
<div>
|
| 119 |
+
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
| 120 |
+
🌦️ Weather Predictions
|
| 121 |
+
</h2>
|
| 122 |
+
{predictions && (
|
| 123 |
+
<p className="text-sm text-slate-400 mt-1">
|
| 124 |
+
Forecast for {predictions.prediction_date}
|
| 125 |
+
</p>
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
<button
|
| 129 |
+
onClick={fetchPredictions}
|
| 130 |
+
className="p-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors"
|
| 131 |
+
title="Refresh predictions"
|
| 132 |
+
>
|
| 133 |
+
🔄
|
| 134 |
+
</button>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
{error ? (
|
| 138 |
+
<div className="text-center py-8">
|
| 139 |
+
<p className="text-red-400 mb-4">{error}</p>
|
| 140 |
+
<button
|
| 141 |
+
onClick={fetchPredictions}
|
| 142 |
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors"
|
| 143 |
+
>
|
| 144 |
+
Retry
|
| 145 |
+
</button>
|
| 146 |
+
</div>
|
| 147 |
+
) : (
|
| 148 |
+
<>
|
| 149 |
+
{/* Severity Summary */}
|
| 150 |
+
<div className="grid grid-cols-4 gap-3 mb-6">
|
| 151 |
+
{(["normal", "advisory", "warning", "critical"] as const).map((sev) => (
|
| 152 |
+
<button
|
| 153 |
+
key={sev}
|
| 154 |
+
onClick={() => setFilter(filter === sev ? "all" : sev)}
|
| 155 |
+
className={`p-3 rounded-lg border transition-all ${filter === sev ? "ring-2 ring-white/30" : ""
|
| 156 |
+
} ${SEVERITY_COLORS[sev]}`}
|
| 157 |
+
>
|
| 158 |
+
<div className="text-2xl">{SEVERITY_ICONS[sev]}</div>
|
| 159 |
+
<div className="text-lg font-bold">{sevCounts[sev]}</div>
|
| 160 |
+
<div className="text-xs capitalize">{sev}</div>
|
| 161 |
+
</button>
|
| 162 |
+
))}
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{/* District Grid */}
|
| 166 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-[500px] overflow-y-auto pr-2">
|
| 167 |
+
{filteredDistricts.map(([district, pred]) => (
|
| 168 |
+
<div
|
| 169 |
+
key={district}
|
| 170 |
+
className={`p-4 rounded-lg border cursor-pointer transition-all hover:scale-[1.02] ${SEVERITY_COLORS[pred.severity]
|
| 171 |
+
} ${selectedDistrict === district ? "ring-2 ring-white/50" : ""}`}
|
| 172 |
+
onClick={() => setSelectedDistrict(selectedDistrict === district ? null : district)}
|
| 173 |
+
>
|
| 174 |
+
<div className="flex items-center justify-between mb-2">
|
| 175 |
+
<h3 className="font-semibold text-white">{district}</h3>
|
| 176 |
+
<span className="text-xl">{SEVERITY_ICONS[pred.severity]}</span>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="grid grid-cols-2 gap-2 text-sm">
|
| 180 |
+
<div>
|
| 181 |
+
<span className="text-slate-400">Temp:</span>
|
| 182 |
+
<span className="ml-1 text-white">
|
| 183 |
+
{pred.temperature.low_c}° - {pred.temperature.high_c}°C
|
| 184 |
+
</span>
|
| 185 |
+
</div>
|
| 186 |
+
<div>
|
| 187 |
+
<span className="text-slate-400">Rain:</span>
|
| 188 |
+
<span className="ml-1 text-white">
|
| 189 |
+
{pred.rainfall.amount_mm}mm
|
| 190 |
+
</span>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{pred.flood_risk > 0 && (
|
| 195 |
+
<div className="mt-2 text-sm">
|
| 196 |
+
<span className="text-red-400">⚠️ Flood Risk: </span>
|
| 197 |
+
<span className="text-white">{(pred.flood_risk * 100).toFixed(0)}%</span>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
|
| 201 |
+
{/* Expanded details */}
|
| 202 |
+
{selectedDistrict === district && (
|
| 203 |
+
<div className="mt-4 pt-3 border-t border-white/20 text-sm space-y-2">
|
| 204 |
+
<div className="flex justify-between">
|
| 205 |
+
<span className="text-slate-400">Rain Probability:</span>
|
| 206 |
+
<span className="text-white">{(pred.rainfall.probability * 100).toFixed(0)}%</span>
|
| 207 |
+
</div>
|
| 208 |
+
<div className="flex justify-between">
|
| 209 |
+
<span className="text-slate-400">Humidity:</span>
|
| 210 |
+
<span className="text-white">{pred.humidity_pct}%</span>
|
| 211 |
+
</div>
|
| 212 |
+
<div className="flex justify-between">
|
| 213 |
+
<span className="text-slate-400">Station:</span>
|
| 214 |
+
<span className="text-white">{pred.station_used}</span>
|
| 215 |
+
</div>
|
| 216 |
+
{pred.is_fallback && (
|
| 217 |
+
<div className="text-xs text-yellow-400">
|
| 218 |
+
⚠️ Using climate fallback (LSTM model not trained)
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
</div>
|
| 224 |
+
))}
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
{/* Footer */}
|
| 228 |
+
{predictions && (
|
| 229 |
+
<div className="mt-4 text-xs text-slate-500 text-center">
|
| 230 |
+
Generated: {new Date(predictions.generated_at).toLocaleString()} |
|
| 231 |
+
{predictions.total_districts} districts
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</>
|
| 235 |
+
)}
|
| 236 |
+
</div>
|
| 237 |
+
);
|
| 238 |
+
}
|
frontend/app/components/intelligence/IntelligenceFeed.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card } from "../ui/card";
|
| 2 |
+
import { Badge } from "../ui/badge";
|
| 3 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
| 4 |
+
import { Newspaper, Cloud, TrendingUp, FileText, Radio, Globe, MapPin } from "lucide-react";
|
| 5 |
+
import { useRogerData, RogerEvent } from "../../hooks/use-roger-data";
|
| 6 |
+
import { motion } from "framer-motion";
|
| 7 |
+
import { useState } from "react";
|
| 8 |
+
|
| 9 |
+
const IntelligenceFeed = () => {
|
| 10 |
+
const { events, isConnected } = useRogerData();
|
| 11 |
+
|
| 12 |
+
// Region toggle state (Sri Lanka / World)
|
| 13 |
+
const [region, setRegion] = useState<"sri_lanka" | "world">("sri_lanka");
|
| 14 |
+
|
| 15 |
+
// ALWAYS ensure events is an array
|
| 16 |
+
const safeEvents: RogerEvent[] = Array.isArray(events) ? events : [];
|
| 17 |
+
|
| 18 |
+
// Filter by region first
|
| 19 |
+
const regionFilteredEvents = safeEvents.filter(e => {
|
| 20 |
+
// If event has region field, use it; otherwise infer from domain
|
| 21 |
+
const eventRegion = e?.region || "sri_lanka"; // Default to Sri Lanka
|
| 22 |
+
return eventRegion === region;
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// Then filter by category
|
| 26 |
+
const allEvents = regionFilteredEvents;
|
| 27 |
+
const newsEvents = regionFilteredEvents.filter(e => e?.domain === "social" || e?.domain === "intelligence");
|
| 28 |
+
const politicalEvents = regionFilteredEvents.filter(e => e?.domain === "political");
|
| 29 |
+
const weatherEvents = regionFilteredEvents.filter(e => e?.domain === "weather" || e?.domain === "meteorological");
|
| 30 |
+
const economicEvents = regionFilteredEvents.filter(e => e?.domain === "economical" || e?.domain === "market");
|
| 31 |
+
|
| 32 |
+
const renderEventCard = (item: RogerEvent, idx: number) => {
|
| 33 |
+
if (!item) return null;
|
| 34 |
+
|
| 35 |
+
const isRisk = item.impact_type === "risk";
|
| 36 |
+
|
| 37 |
+
const severityColorMap: Record<string, string> = {
|
| 38 |
+
critical: "destructive",
|
| 39 |
+
high: "warning",
|
| 40 |
+
medium: "primary",
|
| 41 |
+
low: "secondary",
|
| 42 |
+
};
|
| 43 |
+
const severityColor = severityColorMap[item.severity] || "secondary";
|
| 44 |
+
|
| 45 |
+
const domainIconMap: Record<string, React.ComponentType<any>> = {
|
| 46 |
+
social: Newspaper,
|
| 47 |
+
political: FileText,
|
| 48 |
+
weather: Cloud,
|
| 49 |
+
meteorological: Cloud,
|
| 50 |
+
economical: TrendingUp,
|
| 51 |
+
market: TrendingUp,
|
| 52 |
+
intelligence: Radio,
|
| 53 |
+
};
|
| 54 |
+
const Icon = domainIconMap[item.domain] || Radio;
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<motion.div
|
| 58 |
+
key={item.event_id}
|
| 59 |
+
initial={{ opacity: 0, y: 10 }}
|
| 60 |
+
animate={{ opacity: 1, y: 0 }}
|
| 61 |
+
transition={{ delay: idx * 0.05 }}
|
| 62 |
+
>
|
| 63 |
+
<Card
|
| 64 |
+
className={`p-4 bg-muted/30 border-l-4 hover:bg-muted/50 transition-colors ${severityColor === "destructive"
|
| 65 |
+
? "border-l-destructive"
|
| 66 |
+
: severityColor === "warning"
|
| 67 |
+
? "border-l-warning"
|
| 68 |
+
: severityColor === "primary"
|
| 69 |
+
? "border-l-primary"
|
| 70 |
+
: "border-l-secondary"
|
| 71 |
+
}`}
|
| 72 |
+
>
|
| 73 |
+
<div className="flex items-start justify-between mb-2">
|
| 74 |
+
<div className="flex-1">
|
| 75 |
+
<div className="flex items-center gap-2 mb-1">
|
| 76 |
+
<Icon className="w-4 h-4" />
|
| 77 |
+
<Badge
|
| 78 |
+
className={
|
| 79 |
+
severityColor === "destructive"
|
| 80 |
+
? "bg-destructive text-destructive-foreground"
|
| 81 |
+
: severityColor === "warning"
|
| 82 |
+
? "bg-warning text-warning-foreground"
|
| 83 |
+
: severityColor === "primary"
|
| 84 |
+
? "bg-primary text-primary-foreground"
|
| 85 |
+
: "bg-secondary text-secondary-foreground"
|
| 86 |
+
}
|
| 87 |
+
>
|
| 88 |
+
{item.severity?.toUpperCase()}
|
| 89 |
+
</Badge>
|
| 90 |
+
|
| 91 |
+
<Badge className={isRisk ? "bg-destructive/20 text-destructive" : "bg-success/20 text-success"}>
|
| 92 |
+
{isRisk ? "⚠️ RISK" : "✨ OPP"}
|
| 93 |
+
</Badge>
|
| 94 |
+
|
| 95 |
+
<Badge className="border border-border">{item.domain}</Badge>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<h3 className="font-bold text-sm mb-1">{item.summary}</h3>
|
| 99 |
+
|
| 100 |
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
| 101 |
+
<span>Confidence: {Math.round((item.confidence ?? 0) * 100)}%</span>
|
| 102 |
+
<span>•</span>
|
| 103 |
+
<span className="font-mono">
|
| 104 |
+
{item.timestamp ? new Date(item.timestamp).toLocaleTimeString() : ""}
|
| 105 |
+
</span>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</Card>
|
| 110 |
+
</motion.div>
|
| 111 |
+
);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="space-y-6">
|
| 116 |
+
<Card className="p-6 bg-card border-border">
|
| 117 |
+
<div className="flex items-center gap-2 mb-4">
|
| 118 |
+
<Radio className="w-5 h-5 text-primary" />
|
| 119 |
+
<h2 className="text-lg font-bold">INTELLIGENCE FEED</h2>
|
| 120 |
+
|
| 121 |
+
<span className="ml-auto text-xs font-mono text-muted-foreground">
|
| 122 |
+
{isConnected ? (
|
| 123 |
+
<span className="flex items-center gap-2">
|
| 124 |
+
<span className="w-2 h-2 rounded-full bg-success animate-pulse"></span>
|
| 125 |
+
Live
|
| 126 |
+
</span>
|
| 127 |
+
) : (
|
| 128 |
+
<span className="flex items-center gap-2">
|
| 129 |
+
<span className="w-2 h-2 rounded-full bg-warning"></span>
|
| 130 |
+
Reconnecting...
|
| 131 |
+
</span>
|
| 132 |
+
)}
|
| 133 |
+
</span>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* REGION TOGGLE - Sri Lanka / World */}
|
| 137 |
+
<div className="flex gap-2 mb-4 overflow-x-auto hide-scrollbar">
|
| 138 |
+
<button
|
| 139 |
+
onClick={() => setRegion("sri_lanka")}
|
| 140 |
+
className={`flex items-center gap-2 px-3 sm:px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap text-sm ${region === "sri_lanka"
|
| 141 |
+
? "bg-primary text-primary-foreground shadow-lg"
|
| 142 |
+
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
| 143 |
+
}`}
|
| 144 |
+
>
|
| 145 |
+
<MapPin className="w-4 h-4" />
|
| 146 |
+
🇱🇰 <span className="hidden sm:inline">Sri Lanka</span>
|
| 147 |
+
<Badge variant="secondary" className="ml-1">
|
| 148 |
+
{safeEvents.filter(e => (e?.region || "sri_lanka") === "sri_lanka").length}
|
| 149 |
+
</Badge>
|
| 150 |
+
</button>
|
| 151 |
+
<button
|
| 152 |
+
onClick={() => setRegion("world")}
|
| 153 |
+
className={`flex items-center gap-2 px-3 sm:px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap text-sm ${region === "world"
|
| 154 |
+
? "bg-primary text-primary-foreground shadow-lg"
|
| 155 |
+
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
| 156 |
+
}`}
|
| 157 |
+
>
|
| 158 |
+
<Globe className="w-4 h-4" />
|
| 159 |
+
🌍 <span className="hidden sm:inline">World</span>
|
| 160 |
+
<Badge variant="secondary" className="ml-1">
|
| 161 |
+
{safeEvents.filter(e => e?.region === "world").length}
|
| 162 |
+
</Badge>
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<Tabs defaultValue="all" className="w-full">
|
| 167 |
+
<div className="overflow-x-auto hide-scrollbar -mx-2 px-2 sm:mx-0 sm:px-0">
|
| 168 |
+
<TabsList className="inline-flex w-max sm:grid sm:w-full sm:grid-cols-5 mb-4">
|
| 169 |
+
<TabsTrigger value="all" className="px-3 sm:px-4 py-2 text-xs sm:text-sm">ALL ({allEvents.length})</TabsTrigger>
|
| 170 |
+
<TabsTrigger value="news" className="px-3 sm:px-4 py-2 text-xs sm:text-sm">NEWS ({newsEvents.length})</TabsTrigger>
|
| 171 |
+
<TabsTrigger value="political" className="px-3 sm:px-4 py-2 text-xs sm:text-sm whitespace-nowrap">POLITICAL ({politicalEvents.length})</TabsTrigger>
|
| 172 |
+
<TabsTrigger value="weather" className="px-3 sm:px-4 py-2 text-xs sm:text-sm">WEATHER ({weatherEvents.length})</TabsTrigger>
|
| 173 |
+
<TabsTrigger value="economic" className="px-3 sm:px-4 py-2 text-xs sm:text-sm">ECONOMIC ({economicEvents.length})</TabsTrigger>
|
| 174 |
+
</TabsList>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
{/* ALL */}
|
| 178 |
+
<TabsContent value="all" className="space-y-3 max-h-[600px] overflow-y-auto">
|
| 179 |
+
{allEvents.length > 0 ? (
|
| 180 |
+
allEvents.map(renderEventCard)
|
| 181 |
+
) : (
|
| 182 |
+
<div className="text-center py-12 text-muted-foreground">
|
| 183 |
+
<Radio className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 184 |
+
<p className="text-sm font-mono">Waiting for intelligence data...</p>
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
</TabsContent>
|
| 188 |
+
|
| 189 |
+
{/* NEWS */}
|
| 190 |
+
<TabsContent value="news" className="space-y-3 max-h-[600px] overflow-y-auto">
|
| 191 |
+
{newsEvents.length > 0 ? (
|
| 192 |
+
newsEvents.map(renderEventCard)
|
| 193 |
+
) : (
|
| 194 |
+
<div className="text-center py-12 text-muted-foreground">
|
| 195 |
+
<Newspaper className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 196 |
+
<p className="text-sm">No news events yet</p>
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
</TabsContent>
|
| 200 |
+
|
| 201 |
+
{/* POLITICAL */}
|
| 202 |
+
<TabsContent value="political" className="space-y-3 max-h-[600px] overflow-y-auto">
|
| 203 |
+
{politicalEvents.length > 0 ? (
|
| 204 |
+
politicalEvents.map(renderEventCard)
|
| 205 |
+
) : (
|
| 206 |
+
<div className="text-center py-12 text-muted-foreground">
|
| 207 |
+
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 208 |
+
<p className="text-sm">No political updates yet</p>
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
</TabsContent>
|
| 212 |
+
|
| 213 |
+
{/* WEATHER */}
|
| 214 |
+
<TabsContent value="weather" className="space-y-3 max-h-[600px] overflow-y-auto">
|
| 215 |
+
{weatherEvents.length > 0 ? (
|
| 216 |
+
weatherEvents.map(renderEventCard)
|
| 217 |
+
) : (
|
| 218 |
+
<div className="text-center py-12 text-muted-foreground">
|
| 219 |
+
<Cloud className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 220 |
+
<p className="text-sm">No weather alerts yet</p>
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
</TabsContent>
|
| 224 |
+
|
| 225 |
+
{/* ECONOMIC */}
|
| 226 |
+
<TabsContent value="economic" className="space-y-3 max-h-[600px] overflow-y-auto">
|
| 227 |
+
{economicEvents.length > 0 ? (
|
| 228 |
+
economicEvents.map(renderEventCard)
|
| 229 |
+
) : (
|
| 230 |
+
<div className="text-center py-12 text-muted-foreground">
|
| 231 |
+
<TrendingUp className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 232 |
+
<p className="text-sm">No economic data yet</p>
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
</TabsContent>
|
| 236 |
+
</Tabs>
|
| 237 |
+
</Card>
|
| 238 |
+
</div>
|
| 239 |
+
);
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
export default IntelligenceFeed;
|
frontend/app/components/intelligence/IntelligenceSettings.tsx
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Card } from "../ui/card";
|
| 5 |
+
import { Button } from "../ui/button";
|
| 6 |
+
import { Input } from "../ui/input";
|
| 7 |
+
import { Badge } from "../ui/badge";
|
| 8 |
+
import {
|
| 9 |
+
Settings,
|
| 10 |
+
Plus,
|
| 11 |
+
X,
|
| 12 |
+
Save,
|
| 13 |
+
RefreshCw,
|
| 14 |
+
Twitter,
|
| 15 |
+
Linkedin,
|
| 16 |
+
Facebook,
|
| 17 |
+
Search,
|
| 18 |
+
Package,
|
| 19 |
+
User
|
| 20 |
+
} from "lucide-react";
|
| 21 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 22 |
+
|
| 23 |
+
// API base URL - adjust based on your backend
|
| 24 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 25 |
+
|
| 26 |
+
interface IntelConfig {
|
| 27 |
+
user_profiles: {
|
| 28 |
+
twitter: string[];
|
| 29 |
+
facebook: string[];
|
| 30 |
+
linkedin: string[];
|
| 31 |
+
};
|
| 32 |
+
user_keywords: string[];
|
| 33 |
+
user_products: string[];
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const defaultConfig: IntelConfig = {
|
| 37 |
+
user_profiles: { twitter: [], facebook: [], linkedin: [] },
|
| 38 |
+
user_keywords: [],
|
| 39 |
+
user_products: [],
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const IntelligenceSettings = () => {
|
| 43 |
+
const [config, setConfig] = useState<IntelConfig>(defaultConfig);
|
| 44 |
+
const [loading, setLoading] = useState(true);
|
| 45 |
+
const [saving, setSaving] = useState(false);
|
| 46 |
+
const [error, setError] = useState<string | null>(null);
|
| 47 |
+
const [success, setSuccess] = useState<string | null>(null);
|
| 48 |
+
|
| 49 |
+
// Input states
|
| 50 |
+
const [newKeyword, setNewKeyword] = useState("");
|
| 51 |
+
const [newProduct, setNewProduct] = useState("");
|
| 52 |
+
const [newProfile, setNewProfile] = useState("");
|
| 53 |
+
const [selectedPlatform, setSelectedPlatform] = useState<"twitter" | "facebook" | "linkedin">("twitter");
|
| 54 |
+
|
| 55 |
+
// Fetch config on mount
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
fetchConfig();
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
const fetchConfig = async () => {
|
| 61 |
+
setLoading(true);
|
| 62 |
+
setError(null);
|
| 63 |
+
try {
|
| 64 |
+
const res = await fetch(`${API_BASE}/api/intel/config`);
|
| 65 |
+
const data = await res.json();
|
| 66 |
+
if (data.status === "success" && data.config) {
|
| 67 |
+
setConfig(data.config);
|
| 68 |
+
} else if (data.config) {
|
| 69 |
+
setConfig(data.config);
|
| 70 |
+
}
|
| 71 |
+
} catch (err) {
|
| 72 |
+
setError("Failed to load configuration");
|
| 73 |
+
console.error("Failed to fetch intel config:", err);
|
| 74 |
+
} finally {
|
| 75 |
+
setLoading(false);
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
const saveConfig = async () => {
|
| 80 |
+
setSaving(true);
|
| 81 |
+
setError(null);
|
| 82 |
+
setSuccess(null);
|
| 83 |
+
try {
|
| 84 |
+
const res = await fetch(`${API_BASE}/api/intel/config`, {
|
| 85 |
+
method: "POST",
|
| 86 |
+
headers: { "Content-Type": "application/json" },
|
| 87 |
+
body: JSON.stringify(config),
|
| 88 |
+
});
|
| 89 |
+
const data = await res.json();
|
| 90 |
+
if (data.status === "updated") {
|
| 91 |
+
setSuccess("Configuration saved! Changes will apply on next agent cycle.");
|
| 92 |
+
setTimeout(() => setSuccess(null), 3000);
|
| 93 |
+
} else {
|
| 94 |
+
setError(data.error || "Failed to save");
|
| 95 |
+
}
|
| 96 |
+
} catch (err) {
|
| 97 |
+
setError("Failed to save configuration");
|
| 98 |
+
console.error("Failed to save intel config:", err);
|
| 99 |
+
} finally {
|
| 100 |
+
setSaving(false);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const addKeyword = () => {
|
| 105 |
+
if (newKeyword.trim() && !config.user_keywords.includes(newKeyword.trim())) {
|
| 106 |
+
setConfig({
|
| 107 |
+
...config,
|
| 108 |
+
user_keywords: [...config.user_keywords, newKeyword.trim()],
|
| 109 |
+
});
|
| 110 |
+
setNewKeyword("");
|
| 111 |
+
}
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
const removeKeyword = (keyword: string) => {
|
| 115 |
+
setConfig({
|
| 116 |
+
...config,
|
| 117 |
+
user_keywords: config.user_keywords.filter((k) => k !== keyword),
|
| 118 |
+
});
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const addProduct = () => {
|
| 122 |
+
if (newProduct.trim() && !config.user_products.includes(newProduct.trim())) {
|
| 123 |
+
setConfig({
|
| 124 |
+
...config,
|
| 125 |
+
user_products: [...config.user_products, newProduct.trim()],
|
| 126 |
+
});
|
| 127 |
+
setNewProduct("");
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const removeProduct = (product: string) => {
|
| 132 |
+
setConfig({
|
| 133 |
+
...config,
|
| 134 |
+
user_products: config.user_products.filter((p) => p !== product),
|
| 135 |
+
});
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const addProfile = () => {
|
| 139 |
+
if (newProfile.trim() && !config.user_profiles[selectedPlatform].includes(newProfile.trim())) {
|
| 140 |
+
setConfig({
|
| 141 |
+
...config,
|
| 142 |
+
user_profiles: {
|
| 143 |
+
...config.user_profiles,
|
| 144 |
+
[selectedPlatform]: [...config.user_profiles[selectedPlatform], newProfile.trim()],
|
| 145 |
+
},
|
| 146 |
+
});
|
| 147 |
+
setNewProfile("");
|
| 148 |
+
}
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const removeProfile = (platform: "twitter" | "facebook" | "linkedin", profile: string) => {
|
| 152 |
+
setConfig({
|
| 153 |
+
...config,
|
| 154 |
+
user_profiles: {
|
| 155 |
+
...config.user_profiles,
|
| 156 |
+
[platform]: config.user_profiles[platform].filter((p) => p !== profile),
|
| 157 |
+
},
|
| 158 |
+
});
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const platformIcons = {
|
| 162 |
+
twitter: <Twitter className="w-4 h-4" />,
|
| 163 |
+
facebook: <Facebook className="w-4 h-4" />,
|
| 164 |
+
linkedin: <Linkedin className="w-4 h-4" />,
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
const platformColors = {
|
| 168 |
+
twitter: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
| 169 |
+
facebook: "bg-indigo-500/20 text-indigo-400 border-indigo-500/30",
|
| 170 |
+
linkedin: "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
if (loading) {
|
| 174 |
+
return (
|
| 175 |
+
<Card className="p-6 bg-card border-border">
|
| 176 |
+
<div className="flex items-center justify-center py-12">
|
| 177 |
+
<RefreshCw className="w-6 h-6 animate-spin text-primary" />
|
| 178 |
+
<span className="ml-2 text-muted-foreground">Loading configuration...</span>
|
| 179 |
+
</div>
|
| 180 |
+
</Card>
|
| 181 |
+
);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
return (
|
| 185 |
+
<Card className="p-6 bg-card border-border">
|
| 186 |
+
<div className="flex items-center justify-between mb-6">
|
| 187 |
+
<div className="flex items-center gap-2">
|
| 188 |
+
<Settings className="w-5 h-5 text-primary" />
|
| 189 |
+
<h2 className="text-lg font-bold">INTELLIGENCE SETTINGS</h2>
|
| 190 |
+
</div>
|
| 191 |
+
<div className="flex gap-2">
|
| 192 |
+
<Button variant="outline" size="sm" onClick={fetchConfig} disabled={loading}>
|
| 193 |
+
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? "animate-spin" : ""}`} />
|
| 194 |
+
Refresh
|
| 195 |
+
</Button>
|
| 196 |
+
<Button size="sm" onClick={saveConfig} disabled={saving}>
|
| 197 |
+
<Save className={`w-4 h-4 mr-1 ${saving ? "animate-pulse" : ""}`} />
|
| 198 |
+
{saving ? "Saving..." : "Save Changes"}
|
| 199 |
+
</Button>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Status Messages */}
|
| 204 |
+
<AnimatePresence>
|
| 205 |
+
{error && (
|
| 206 |
+
<motion.div
|
| 207 |
+
initial={{ opacity: 0, y: -10 }}
|
| 208 |
+
animate={{ opacity: 1, y: 0 }}
|
| 209 |
+
exit={{ opacity: 0 }}
|
| 210 |
+
className="mb-4 p-3 bg-destructive/20 border border-destructive/30 rounded-lg text-destructive text-sm"
|
| 211 |
+
>
|
| 212 |
+
{error}
|
| 213 |
+
</motion.div>
|
| 214 |
+
)}
|
| 215 |
+
{success && (
|
| 216 |
+
<motion.div
|
| 217 |
+
initial={{ opacity: 0, y: -10 }}
|
| 218 |
+
animate={{ opacity: 1, y: 0 }}
|
| 219 |
+
exit={{ opacity: 0 }}
|
| 220 |
+
className="mb-4 p-3 bg-success/20 border border-success/30 rounded-lg text-success text-sm"
|
| 221 |
+
>
|
| 222 |
+
{success}
|
| 223 |
+
</motion.div>
|
| 224 |
+
)}
|
| 225 |
+
</AnimatePresence>
|
| 226 |
+
|
| 227 |
+
<div className="space-y-6">
|
| 228 |
+
{/* Keywords Section */}
|
| 229 |
+
<div className="p-4 bg-muted/30 rounded-lg border border-border">
|
| 230 |
+
<div className="flex items-center gap-2 mb-3">
|
| 231 |
+
<Search className="w-4 h-4 text-primary" />
|
| 232 |
+
<h3 className="font-semibold">Custom Keywords</h3>
|
| 233 |
+
<Badge variant="secondary" className="ml-auto">
|
| 234 |
+
{config.user_keywords.length} keywords
|
| 235 |
+
</Badge>
|
| 236 |
+
</div>
|
| 237 |
+
<p className="text-xs text-muted-foreground mb-3">
|
| 238 |
+
Add keywords to monitor across all social platforms
|
| 239 |
+
</p>
|
| 240 |
+
<div className="flex gap-2 mb-3">
|
| 241 |
+
<Input
|
| 242 |
+
placeholder="Enter keyword (e.g., 'Colombo Port')"
|
| 243 |
+
value={newKeyword}
|
| 244 |
+
onChange={(e) => setNewKeyword(e.target.value)}
|
| 245 |
+
onKeyDown={(e) => e.key === "Enter" && addKeyword()}
|
| 246 |
+
className="flex-1"
|
| 247 |
+
/>
|
| 248 |
+
<Button size="sm" onClick={addKeyword}>
|
| 249 |
+
<Plus className="w-4 h-4" />
|
| 250 |
+
</Button>
|
| 251 |
+
</div>
|
| 252 |
+
<div className="flex flex-wrap gap-2">
|
| 253 |
+
<AnimatePresence>
|
| 254 |
+
{config.user_keywords.map((keyword) => (
|
| 255 |
+
<motion.div
|
| 256 |
+
key={keyword}
|
| 257 |
+
initial={{ opacity: 0, scale: 0.8 }}
|
| 258 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 259 |
+
exit={{ opacity: 0, scale: 0.8 }}
|
| 260 |
+
>
|
| 261 |
+
<Badge className="flex items-center gap-1 pr-1 bg-primary/20 text-primary border border-primary/30">
|
| 262 |
+
{keyword}
|
| 263 |
+
<button
|
| 264 |
+
onClick={() => removeKeyword(keyword)}
|
| 265 |
+
className="ml-1 p-0.5 rounded hover:bg-destructive/20"
|
| 266 |
+
>
|
| 267 |
+
<X className="w-3 h-3" />
|
| 268 |
+
</button>
|
| 269 |
+
</Badge>
|
| 270 |
+
</motion.div>
|
| 271 |
+
))}
|
| 272 |
+
</AnimatePresence>
|
| 273 |
+
{config.user_keywords.length === 0 && (
|
| 274 |
+
<span className="text-xs text-muted-foreground italic">No custom keywords added</span>
|
| 275 |
+
)}
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
{/* Products Section */}
|
| 280 |
+
<div className="p-4 bg-muted/30 rounded-lg border border-border">
|
| 281 |
+
<div className="flex items-center gap-2 mb-3">
|
| 282 |
+
<Package className="w-4 h-4 text-warning" />
|
| 283 |
+
<h3 className="font-semibold">Products to Track</h3>
|
| 284 |
+
<Badge variant="secondary" className="ml-auto">
|
| 285 |
+
{config.user_products.length} products
|
| 286 |
+
</Badge>
|
| 287 |
+
</div>
|
| 288 |
+
<p className="text-xs text-muted-foreground mb-3">
|
| 289 |
+
Track mentions and reviews of specific products
|
| 290 |
+
</p>
|
| 291 |
+
<div className="flex gap-2 mb-3">
|
| 292 |
+
<Input
|
| 293 |
+
placeholder="Enter product name (e.g., 'iPhone 15')"
|
| 294 |
+
value={newProduct}
|
| 295 |
+
onChange={(e) => setNewProduct(e.target.value)}
|
| 296 |
+
onKeyDown={(e) => e.key === "Enter" && addProduct()}
|
| 297 |
+
className="flex-1"
|
| 298 |
+
/>
|
| 299 |
+
<Button size="sm" onClick={addProduct}>
|
| 300 |
+
<Plus className="w-4 h-4" />
|
| 301 |
+
</Button>
|
| 302 |
+
</div>
|
| 303 |
+
<div className="flex flex-wrap gap-2">
|
| 304 |
+
<AnimatePresence>
|
| 305 |
+
{config.user_products.map((product) => (
|
| 306 |
+
<motion.div
|
| 307 |
+
key={product}
|
| 308 |
+
initial={{ opacity: 0, scale: 0.8 }}
|
| 309 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 310 |
+
exit={{ opacity: 0, scale: 0.8 }}
|
| 311 |
+
>
|
| 312 |
+
<Badge className="flex items-center gap-1 pr-1 bg-warning/20 text-warning border border-warning/30">
|
| 313 |
+
{product}
|
| 314 |
+
<button
|
| 315 |
+
onClick={() => removeProduct(product)}
|
| 316 |
+
className="ml-1 p-0.5 rounded hover:bg-destructive/20"
|
| 317 |
+
>
|
| 318 |
+
<X className="w-3 h-3" />
|
| 319 |
+
</button>
|
| 320 |
+
</Badge>
|
| 321 |
+
</motion.div>
|
| 322 |
+
))}
|
| 323 |
+
</AnimatePresence>
|
| 324 |
+
{config.user_products.length === 0 && (
|
| 325 |
+
<span className="text-xs text-muted-foreground italic">No custom products added</span>
|
| 326 |
+
)}
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
{/* Profiles Section */}
|
| 331 |
+
<div className="p-4 bg-muted/30 rounded-lg border border-border">
|
| 332 |
+
<div className="flex items-center gap-2 mb-3">
|
| 333 |
+
<User className="w-4 h-4 text-success" />
|
| 334 |
+
<h3 className="font-semibold">Profiles to Monitor</h3>
|
| 335 |
+
<Badge variant="secondary" className="ml-auto">
|
| 336 |
+
{Object.values(config.user_profiles).flat().length} profiles
|
| 337 |
+
</Badge>
|
| 338 |
+
</div>
|
| 339 |
+
<p className="text-xs text-muted-foreground mb-3">
|
| 340 |
+
Add social media profiles/pages to monitor
|
| 341 |
+
</p>
|
| 342 |
+
|
| 343 |
+
{/* Platform Selector */}
|
| 344 |
+
<div className="flex gap-2 mb-3">
|
| 345 |
+
{(["twitter", "facebook", "linkedin"] as const).map((platform) => (
|
| 346 |
+
<button
|
| 347 |
+
key={platform}
|
| 348 |
+
onClick={() => setSelectedPlatform(platform)}
|
| 349 |
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${selectedPlatform === platform
|
| 350 |
+
? platformColors[platform] + " border"
|
| 351 |
+
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
| 352 |
+
}`}
|
| 353 |
+
>
|
| 354 |
+
{platformIcons[platform]}
|
| 355 |
+
{platform.charAt(0).toUpperCase() + platform.slice(1)}
|
| 356 |
+
</button>
|
| 357 |
+
))}
|
| 358 |
+
</div>
|
| 359 |
+
|
| 360 |
+
<div className="flex gap-2 mb-3">
|
| 361 |
+
<Input
|
| 362 |
+
placeholder={`Enter ${selectedPlatform} username/page`}
|
| 363 |
+
value={newProfile}
|
| 364 |
+
onChange={(e) => setNewProfile(e.target.value)}
|
| 365 |
+
onKeyDown={(e) => e.key === "Enter" && addProfile()}
|
| 366 |
+
className="flex-1"
|
| 367 |
+
/>
|
| 368 |
+
<Button size="sm" onClick={addProfile}>
|
| 369 |
+
<Plus className="w-4 h-4" />
|
| 370 |
+
</Button>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
{/* Show profiles by platform */}
|
| 374 |
+
<div className="space-y-3">
|
| 375 |
+
{(["twitter", "facebook", "linkedin"] as const).map((platform) => (
|
| 376 |
+
<div key={platform}>
|
| 377 |
+
<div className="flex items-center gap-2 mb-2">
|
| 378 |
+
{platformIcons[platform]}
|
| 379 |
+
<span className="text-xs font-medium text-muted-foreground uppercase">
|
| 380 |
+
{platform}
|
| 381 |
+
</span>
|
| 382 |
+
<span className="text-xs text-muted-foreground">
|
| 383 |
+
({config.user_profiles[platform].length})
|
| 384 |
+
</span>
|
| 385 |
+
</div>
|
| 386 |
+
<div className="flex flex-wrap gap-2">
|
| 387 |
+
<AnimatePresence>
|
| 388 |
+
{config.user_profiles[platform].map((profile) => (
|
| 389 |
+
<motion.div
|
| 390 |
+
key={`${platform}-${profile}`}
|
| 391 |
+
initial={{ opacity: 0, scale: 0.8 }}
|
| 392 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 393 |
+
exit={{ opacity: 0, scale: 0.8 }}
|
| 394 |
+
>
|
| 395 |
+
<Badge className={`flex items-center gap-1 pr-1 border ${platformColors[platform]}`}>
|
| 396 |
+
@{profile}
|
| 397 |
+
<button
|
| 398 |
+
onClick={() => removeProfile(platform, profile)}
|
| 399 |
+
className="ml-1 p-0.5 rounded hover:bg-destructive/20"
|
| 400 |
+
>
|
| 401 |
+
<X className="w-3 h-3" />
|
| 402 |
+
</button>
|
| 403 |
+
</Badge>
|
| 404 |
+
</motion.div>
|
| 405 |
+
))}
|
| 406 |
+
</AnimatePresence>
|
| 407 |
+
{config.user_profiles[platform].length === 0 && (
|
| 408 |
+
<span className="text-xs text-muted-foreground italic">None</span>
|
| 409 |
+
)}
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
))}
|
| 413 |
+
</div>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
{/* Info Box */}
|
| 417 |
+
<div className="p-3 bg-primary/10 border border-primary/20 rounded-lg">
|
| 418 |
+
<p className="text-xs text-primary">
|
| 419 |
+
<strong>Note:</strong> Your custom targets will be monitored in addition to the default
|
| 420 |
+
competitor profiles (Dialog, SLT, Mobitel, Hutch). Changes take effect on the next
|
| 421 |
+
intelligence collection cycle.
|
| 422 |
+
</p>
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
</Card>
|
| 426 |
+
);
|
| 427 |
+
};
|
| 428 |
+
|
| 429 |
+
export default IntelligenceSettings;
|
frontend/app/components/map/DistrictInfoPanel.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card } from "../ui/card";
|
| 2 |
+
import { Badge } from "../ui/badge";
|
| 3 |
+
import { Separator } from "../ui/separator";
|
| 4 |
+
import { Cloud, Newspaper, TrendingUp, Users, AlertTriangle, MapPin } from "lucide-react";
|
| 5 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 6 |
+
import { useRogerData } from "../../hooks/use-roger-data";
|
| 7 |
+
|
| 8 |
+
interface DistrictInfoPanelProps {
|
| 9 |
+
district: string | null;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const DistrictInfoPanel = ({ district }: DistrictInfoPanelProps) => {
|
| 13 |
+
const { events } = useRogerData();
|
| 14 |
+
|
| 15 |
+
if (!district) {
|
| 16 |
+
return (
|
| 17 |
+
<Card className="p-6 bg-card border-border h-full flex items-center justify-center">
|
| 18 |
+
<div className="text-center text-muted-foreground">
|
| 19 |
+
<MapPin className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
| 20 |
+
<p className="text-sm font-mono">Select a district to view intelligence</p>
|
| 21 |
+
</div>
|
| 22 |
+
</Card>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// FIXED: Filter events that relate to this district (with null-safe check)
|
| 27 |
+
const districtEvents = events.filter(e =>
|
| 28 |
+
e.summary?.toLowerCase().includes(district.toLowerCase())
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
// FIXED: Categorize events - include ALL relevant domains
|
| 32 |
+
const alerts = districtEvents.filter(e => e.impact_type === 'risk');
|
| 33 |
+
|
| 34 |
+
// News includes all domains that have district-specific content
|
| 35 |
+
const news = districtEvents.filter(e =>
|
| 36 |
+
['social', 'intelligence', 'political', 'economical'].includes(e.domain)
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
// FIXED: Weather events should also be filtered by district
|
| 40 |
+
const weatherEvents = districtEvents.filter(e =>
|
| 41 |
+
e.domain === 'weather' || e.domain === 'meteorological'
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
// Calculate risk level
|
| 45 |
+
const criticalAlerts = alerts.filter(e => e.severity === 'critical' || e.severity === 'high');
|
| 46 |
+
const riskLevel = criticalAlerts.length > 0 ? 'high' : alerts.length > 0 ? 'medium' : 'low';
|
| 47 |
+
|
| 48 |
+
// District population data (static for demo)
|
| 49 |
+
const districtData: Record<string, any> = {
|
| 50 |
+
"Colombo": { population: "2.3M", businesses: "15,234", growth: "+5.2%" },
|
| 51 |
+
"Gampaha": { population: "2.4M", businesses: "8,456", growth: "+4.1%" },
|
| 52 |
+
"Kandy": { population: "1.4M", businesses: "5,678", growth: "+3.8%" },
|
| 53 |
+
"Jaffna": { population: "0.6M", businesses: "2,345", growth: "+6.2%" },
|
| 54 |
+
"Galle": { population: "1.1M", businesses: "4,567", growth: "+4.5%" },
|
| 55 |
+
"Kurunegala": { population: "1.6M", businesses: "3,800", growth: "+3.5%" },
|
| 56 |
+
"Matara": { population: "0.8M", businesses: "2,100", growth: "+2.8%" },
|
| 57 |
+
"Ratnapura": { population: "1.1M", businesses: "2,400", growth: "+3.1%" },
|
| 58 |
+
"Badulla": { population: "0.8M", businesses: "1,900", growth: "+2.5%" },
|
| 59 |
+
"Trincomalee": { population: "0.4M", businesses: "1,200", growth: "+4.8%" },
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const info = districtData[district] || { population: "N/A", businesses: "N/A", growth: "N/A" };
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<AnimatePresence mode="wait">
|
| 66 |
+
<motion.div
|
| 67 |
+
key={district}
|
| 68 |
+
initial={{ opacity: 0, x: 20 }}
|
| 69 |
+
animate={{ opacity: 1, x: 0 }}
|
| 70 |
+
exit={{ opacity: 0, x: -20 }}
|
| 71 |
+
transition={{ duration: 0.3 }}
|
| 72 |
+
>
|
| 73 |
+
<Card className="p-6 bg-card border-border space-y-4">
|
| 74 |
+
{/* Header */}
|
| 75 |
+
<div>
|
| 76 |
+
<div className="flex items-center justify-between mb-2">
|
| 77 |
+
<h3 className="text-xl font-bold text-primary">{district}</h3>
|
| 78 |
+
<Badge className={`font-mono border ${riskLevel === 'high' ? 'border-destructive text-destructive' :
|
| 79 |
+
riskLevel === 'medium' ? 'border-warning text-warning' :
|
| 80 |
+
'border-success text-success'
|
| 81 |
+
}`}>
|
| 82 |
+
{riskLevel.toUpperCase()} RISK
|
| 83 |
+
</Badge>
|
| 84 |
+
</div>
|
| 85 |
+
<p className="text-xs text-muted-foreground font-mono">
|
| 86 |
+
Population: {info.population} | Events: {districtEvents.length}
|
| 87 |
+
</p>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<Separator className="bg-border" />
|
| 91 |
+
|
| 92 |
+
{/* Live Weather */}
|
| 93 |
+
<div>
|
| 94 |
+
<div className="flex items-center gap-2 mb-2">
|
| 95 |
+
<Cloud className="w-4 h-4 text-info" />
|
| 96 |
+
<h4 className="font-semibold text-sm">WEATHER STATUS</h4>
|
| 97 |
+
</div>
|
| 98 |
+
{weatherEvents.length > 0 ? (
|
| 99 |
+
<div className="space-y-1">
|
| 100 |
+
{weatherEvents.slice(0, 2).map((event, idx) => (
|
| 101 |
+
<div key={idx} className="text-sm bg-muted/30 rounded p-2">
|
| 102 |
+
<p className="font-semibold leading-relaxed">{event.summary || 'No summary'}</p>
|
| 103 |
+
<Badge className="text-xs mt-1">{event.severity}</Badge>
|
| 104 |
+
</div>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
) : (
|
| 108 |
+
<p className="text-sm text-muted-foreground">No weather alerts for {district}</p>
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<Separator className="bg-border" />
|
| 113 |
+
|
| 114 |
+
{/* Active Alerts */}
|
| 115 |
+
<div>
|
| 116 |
+
<div className="flex items-center gap-2 mb-2">
|
| 117 |
+
<AlertTriangle className="w-4 h-4 text-warning" />
|
| 118 |
+
<h4 className="font-semibold text-sm">ACTIVE ALERTS</h4>
|
| 119 |
+
<Badge className="ml-auto text-xs">{alerts.length}</Badge>
|
| 120 |
+
</div>
|
| 121 |
+
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
| 122 |
+
{alerts.length > 0 ? (
|
| 123 |
+
alerts.slice(0, 5).map((alert, idx) => (
|
| 124 |
+
<div key={idx} className="bg-muted/30 rounded p-2">
|
| 125 |
+
<p className="text-xs font-semibold leading-relaxed">{alert.summary || 'Alert'}</p>
|
| 126 |
+
<div className="flex items-center gap-2 mt-1">
|
| 127 |
+
<Badge
|
| 128 |
+
className={`text-xs ${alert.severity === 'high' || alert.severity === 'critical'
|
| 129 |
+
? "bg-destructive text-destructive-foreground"
|
| 130 |
+
: "bg-secondary text-secondary-foreground"
|
| 131 |
+
}`}
|
| 132 |
+
>
|
| 133 |
+
{alert.severity?.toUpperCase() || 'MEDIUM'}
|
| 134 |
+
</Badge>
|
| 135 |
+
<span className="text-xs text-muted-foreground">
|
| 136 |
+
{alert.timestamp ? new Date(alert.timestamp).toLocaleTimeString() : 'N/A'}
|
| 137 |
+
</span>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
))
|
| 141 |
+
) : (
|
| 142 |
+
<p className="text-xs text-muted-foreground">No active alerts for {district}</p>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<Separator className="bg-border" />
|
| 148 |
+
|
| 149 |
+
{/* Recent News */}
|
| 150 |
+
<div>
|
| 151 |
+
<div className="flex items-center gap-2 mb-2">
|
| 152 |
+
<Newspaper className="w-4 h-4 text-primary" />
|
| 153 |
+
<h4 className="font-semibold text-sm">RECENT NEWS</h4>
|
| 154 |
+
</div>
|
| 155 |
+
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
| 156 |
+
{news.length > 0 ? (
|
| 157 |
+
news.slice(0, 3).map((item, idx) => (
|
| 158 |
+
<div key={idx} className="bg-muted/30 rounded p-2">
|
| 159 |
+
<p className="text-xs font-semibold mb-1 leading-relaxed">{item.summary || 'News'}</p>
|
| 160 |
+
<div className="flex items-center justify-between">
|
| 161 |
+
<span className="text-xs text-muted-foreground">{item.domain}</span>
|
| 162 |
+
<span className="text-xs font-mono text-muted-foreground">
|
| 163 |
+
{item.timestamp ? new Date(item.timestamp).toLocaleTimeString() : 'N/A'}
|
| 164 |
+
</span>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
))
|
| 168 |
+
) : (
|
| 169 |
+
<p className="text-xs text-muted-foreground">No recent news for {district}</p>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<Separator className="bg-border" />
|
| 175 |
+
|
| 176 |
+
{/* Economic */}
|
| 177 |
+
<div>
|
| 178 |
+
<div className="flex items-center gap-2 mb-2">
|
| 179 |
+
<TrendingUp className="w-4 h-4 text-success" />
|
| 180 |
+
<h4 className="font-semibold text-sm">ECONOMIC</h4>
|
| 181 |
+
</div>
|
| 182 |
+
<div className="grid grid-cols-2 gap-3">
|
| 183 |
+
<div className="bg-muted/30 rounded p-2">
|
| 184 |
+
<p className="text-xs text-muted-foreground">Businesses</p>
|
| 185 |
+
<p className="text-lg font-bold">{info.businesses}</p>
|
| 186 |
+
</div>
|
| 187 |
+
<div className="bg-muted/30 rounded p-2">
|
| 188 |
+
<p className="text-xs text-muted-foreground">Growth</p>
|
| 189 |
+
<p className="text-lg font-bold text-success">{info.growth}</p>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
</Card>
|
| 194 |
+
</motion.div>
|
| 195 |
+
</AnimatePresence>
|
| 196 |
+
);
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
export default DistrictInfoPanel;
|
frontend/app/components/map/MapView.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import SriLankaMap from "./SriLankaMap";
|
| 4 |
+
import DistrictInfoPanel from "./DistrictInfoPanel";
|
| 5 |
+
import { Card } from "../ui/card";
|
| 6 |
+
import { MapPin, Activity } from "lucide-react";
|
| 7 |
+
import { useRogerData } from "../../hooks/use-roger-data";
|
| 8 |
+
import { Badge } from "../ui/badge";
|
| 9 |
+
|
| 10 |
+
const MapView = () => {
|
| 11 |
+
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
|
| 12 |
+
const { events, isConnected } = useRogerData();
|
| 13 |
+
|
| 14 |
+
// Count alerts per district (simplified - matches district names in event summaries)
|
| 15 |
+
const districtAlertCounts: Record<string, number> = {};
|
| 16 |
+
|
| 17 |
+
(events ?? []).forEach(event => {
|
| 18 |
+
const summary = (event.summary ?? '').toLowerCase();
|
| 19 |
+
// Check if district name is mentioned in the event
|
| 20 |
+
['colombo', 'gampaha', 'kandy', 'jaffna', 'galle', 'matara', 'hambantota',
|
| 21 |
+
'anuradhapura', 'polonnaruwa', 'batticaloa', 'ampara', 'trincomalee',
|
| 22 |
+
'kurunegala', 'puttalam', 'kalutara', 'ratnapura', 'kegalle', 'nuwara eliya',
|
| 23 |
+
'badulla', 'monaragala', 'kilinochchi', 'mannar', 'vavuniya', 'mullaitivu', 'matale'
|
| 24 |
+
].forEach(district => {
|
| 25 |
+
if (summary.includes(district)) {
|
| 26 |
+
const capitalizedDistrict = district.charAt(0).toUpperCase() + district.slice(1);
|
| 27 |
+
districtAlertCounts[capitalizedDistrict] = (districtAlertCounts[capitalizedDistrict] || 0) + 1;
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// Count critical events
|
| 33 |
+
const criticalEvents = events.filter(e => e.severity === 'critical' || e.severity === 'high');
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="space-y-4">
|
| 37 |
+
<Card className="p-4 sm:p-6 bg-card border-border">
|
| 38 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
| 39 |
+
<div className="flex items-center gap-2">
|
| 40 |
+
<MapPin className="w-5 h-5 text-primary" />
|
| 41 |
+
<h2 className="text-base sm:text-lg font-bold">TERRITORY MAP</h2>
|
| 42 |
+
</div>
|
| 43 |
+
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
| 44 |
+
{isConnected ? (
|
| 45 |
+
<Badge className="bg-success/20 text-success flex items-center gap-2 text-xs">
|
| 46 |
+
<span className="w-2 h-2 rounded-full bg-success animate-pulse"></span>
|
| 47 |
+
Live
|
| 48 |
+
</Badge>
|
| 49 |
+
) : (
|
| 50 |
+
<Badge className="bg-warning/20 text-warning text-xs">Reconnecting...</Badge>
|
| 51 |
+
)}
|
| 52 |
+
<Badge className="border border-border flex items-center gap-2 text-xs">
|
| 53 |
+
<Activity className="w-3 h-3" />
|
| 54 |
+
{criticalEvents.length} Critical
|
| 55 |
+
</Badge>
|
| 56 |
+
<span className="text-xs font-mono text-muted-foreground hidden sm:inline">
|
| 57 |
+
Click any district for detailed intelligence
|
| 58 |
+
</span>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
| 63 |
+
<div className="lg:col-span-2 order-1">
|
| 64 |
+
<div className="h-[350px] sm:h-[450px] lg:h-[550px] w-full">
|
| 65 |
+
<SriLankaMap
|
| 66 |
+
selectedDistrict={selectedDistrict}
|
| 67 |
+
onDistrictSelect={setSelectedDistrict}
|
| 68 |
+
alertCounts={districtAlertCounts}
|
| 69 |
+
className="w-full h-full"
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
<div className="lg:col-span-1 order-2">
|
| 76 |
+
<DistrictInfoPanel district={selectedDistrict} />
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</Card>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
export default MapView;
|
frontend/app/components/map/SatelliteView.tsx
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import { Card } from '../ui/card';
|
| 5 |
+
import { Badge } from '../ui/badge';
|
| 6 |
+
import { Satellite, Cloud, Wind, Thermometer, Droplets, Gauge, Waves, ExternalLink, RefreshCw, Maximize2 } from 'lucide-react';
|
| 7 |
+
|
| 8 |
+
type LayerType = 'wind' | 'rain' | 'temp' | 'clouds' | 'waves' | 'pressure';
|
| 9 |
+
|
| 10 |
+
interface LayerConfig {
|
| 11 |
+
id: LayerType;
|
| 12 |
+
label: string;
|
| 13 |
+
icon: React.ComponentType<{ className?: string }>;
|
| 14 |
+
overlay: string; // Windy overlay parameter
|
| 15 |
+
description: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const LAYERS: LayerConfig[] = [
|
| 19 |
+
{
|
| 20 |
+
id: 'wind',
|
| 21 |
+
label: 'Wind',
|
| 22 |
+
icon: Wind,
|
| 23 |
+
overlay: 'wind',
|
| 24 |
+
description: 'Wind speed and direction'
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
id: 'rain',
|
| 28 |
+
label: 'Rain',
|
| 29 |
+
icon: Cloud,
|
| 30 |
+
overlay: 'rain',
|
| 31 |
+
description: 'Precipitation forecast'
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: 'temp',
|
| 35 |
+
label: 'Temperature',
|
| 36 |
+
icon: Thermometer,
|
| 37 |
+
overlay: 'temp',
|
| 38 |
+
description: 'Air temperature'
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
id: 'clouds',
|
| 42 |
+
label: 'Clouds',
|
| 43 |
+
icon: Satellite,
|
| 44 |
+
overlay: 'clouds',
|
| 45 |
+
description: 'Cloud cover'
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
id: 'waves',
|
| 49 |
+
label: 'Waves',
|
| 50 |
+
icon: Waves,
|
| 51 |
+
overlay: 'waves',
|
| 52 |
+
description: 'Ocean wave height'
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
id: 'pressure',
|
| 56 |
+
label: 'Pressure',
|
| 57 |
+
icon: Gauge,
|
| 58 |
+
overlay: 'pressure',
|
| 59 |
+
description: 'Atmospheric pressure'
|
| 60 |
+
}
|
| 61 |
+
];
|
| 62 |
+
|
| 63 |
+
// Sri Lanka coordinates
|
| 64 |
+
const SRI_LANKA = {
|
| 65 |
+
lat: 7.87,
|
| 66 |
+
lon: 80.77,
|
| 67 |
+
zoom: 7
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
export default function SatelliteView() {
|
| 71 |
+
const [activeLayer, setActiveLayer] = useState<LayerType>('wind');
|
| 72 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 73 |
+
const [key, setKey] = useState(0);
|
| 74 |
+
|
| 75 |
+
const activeConfig = LAYERS.find(l => l.id === activeLayer) || LAYERS[0];
|
| 76 |
+
|
| 77 |
+
// Windy.com embed URL - officially supported!
|
| 78 |
+
const iframeSrc = `https://embed.windy.com/embed.html?type=map&location=coordinates&metricRain=mm&metricTemp=°C&metricWind=km/h&zoom=${SRI_LANKA.zoom}&overlay=${activeConfig.overlay}&product=ecmwf&level=surface&lat=${SRI_LANKA.lat}&lon=${SRI_LANKA.lon}&detailLat=${SRI_LANKA.lat}&detailLon=${SRI_LANKA.lon}&marker=true&message=true`;
|
| 79 |
+
|
| 80 |
+
const handleRefresh = () => {
|
| 81 |
+
setKey(prev => prev + 1);
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const handleFullscreen = () => {
|
| 85 |
+
setIsFullscreen(!isFullscreen);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handleOpenExternal = () => {
|
| 89 |
+
window.open(`https://www.windy.com/?${SRI_LANKA.lat},${SRI_LANKA.lon},${SRI_LANKA.zoom}`, '_blank');
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<div className={`space-y-4 ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
|
| 94 |
+
{/* Header */}
|
| 95 |
+
<Card className="p-4 bg-card border-border">
|
| 96 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 97 |
+
<div className="flex items-center gap-3">
|
| 98 |
+
<div className="p-2 rounded-lg bg-primary/20">
|
| 99 |
+
<Satellite className="w-5 h-5 text-primary" />
|
| 100 |
+
</div>
|
| 101 |
+
<div>
|
| 102 |
+
<h2 className="font-bold text-lg">Live Weather Map</h2>
|
| 103 |
+
<p className="text-xs text-muted-foreground">
|
| 104 |
+
Powered by Windy.com • ECMWF Model • Real-time data
|
| 105 |
+
</p>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div className="flex items-center gap-2">
|
| 110 |
+
<button
|
| 111 |
+
onClick={handleRefresh}
|
| 112 |
+
className="p-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
| 113 |
+
title="Refresh"
|
| 114 |
+
>
|
| 115 |
+
<RefreshCw className="w-4 h-4" />
|
| 116 |
+
</button>
|
| 117 |
+
<button
|
| 118 |
+
onClick={handleFullscreen}
|
| 119 |
+
className="p-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
| 120 |
+
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
| 121 |
+
>
|
| 122 |
+
<Maximize2 className="w-4 h-4" />
|
| 123 |
+
</button>
|
| 124 |
+
<button
|
| 125 |
+
onClick={handleOpenExternal}
|
| 126 |
+
className="p-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
| 127 |
+
title="Open in Windy.com"
|
| 128 |
+
>
|
| 129 |
+
<ExternalLink className="w-4 h-4" />
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Layer Selector */}
|
| 135 |
+
<div className="mt-4 flex flex-wrap gap-2">
|
| 136 |
+
{LAYERS.map((layer) => {
|
| 137 |
+
const Icon = layer.icon;
|
| 138 |
+
const isActive = activeLayer === layer.id;
|
| 139 |
+
return (
|
| 140 |
+
<button
|
| 141 |
+
key={layer.id}
|
| 142 |
+
onClick={() => setActiveLayer(layer.id)}
|
| 143 |
+
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all ${isActive
|
| 144 |
+
? 'bg-primary text-primary-foreground'
|
| 145 |
+
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
| 146 |
+
}`}
|
| 147 |
+
title={layer.description}
|
| 148 |
+
>
|
| 149 |
+
<Icon className="w-4 h-4" />
|
| 150 |
+
<span className="hidden sm:inline">{layer.label}</span>
|
| 151 |
+
</button>
|
| 152 |
+
);
|
| 153 |
+
})}
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
{/* Active Layer Info */}
|
| 157 |
+
<div className="mt-3 flex items-center gap-2">
|
| 158 |
+
<Badge className="bg-primary/20 text-primary">
|
| 159 |
+
{activeConfig.label}
|
| 160 |
+
</Badge>
|
| 161 |
+
<span className="text-xs text-muted-foreground">
|
| 162 |
+
{activeConfig.description}
|
| 163 |
+
</span>
|
| 164 |
+
</div>
|
| 165 |
+
</Card>
|
| 166 |
+
|
| 167 |
+
{/* Map Container */}
|
| 168 |
+
<Card className={`overflow-hidden bg-card border-border ${isFullscreen ? 'flex-1' : ''}`}>
|
| 169 |
+
<div className={`relative ${isFullscreen ? 'h-[calc(100vh-200px)]' : 'h-[500px] sm:h-[600px]'}`}>
|
| 170 |
+
<iframe
|
| 171 |
+
key={key}
|
| 172 |
+
src={iframeSrc}
|
| 173 |
+
className="w-full h-full border-0"
|
| 174 |
+
title="Windy Weather Map - Sri Lanka"
|
| 175 |
+
loading="lazy"
|
| 176 |
+
allowFullScreen
|
| 177 |
+
/>
|
| 178 |
+
</div>
|
| 179 |
+
</Card>
|
| 180 |
+
|
| 181 |
+
{/* Data Attribution */}
|
| 182 |
+
<div className="text-xs text-muted-foreground text-center">
|
| 183 |
+
Data: ECMWF • GFS • ICON • NAM Models
|
| 184 |
+
<span className="mx-2">•</span>
|
| 185 |
+
Powered by{' '}
|
| 186 |
+
<a
|
| 187 |
+
href="https://www.windy.com"
|
| 188 |
+
target="_blank"
|
| 189 |
+
rel="noopener noreferrer"
|
| 190 |
+
className="text-primary hover:underline"
|
| 191 |
+
>
|
| 192 |
+
Windy.com
|
| 193 |
+
</a>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
);
|
| 197 |
+
}
|
frontend/app/components/map/SriLankaMap.tsx
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useMemo } from "react";
|
| 4 |
+
import { motion } from "framer-motion";
|
| 5 |
+
import sriLanka from "@svg-maps/sri-lanka";
|
| 6 |
+
|
| 7 |
+
// ------------ Types ------------
|
| 8 |
+
|
| 9 |
+
interface LocationShape {
|
| 10 |
+
id?: string;
|
| 11 |
+
name?: string;
|
| 12 |
+
centroid?: [number, number];
|
| 13 |
+
path?: string;
|
| 14 |
+
paths?: string;
|
| 15 |
+
d?: string;
|
| 16 |
+
geometry?: {
|
| 17 |
+
coordinates?: any;
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface SriLankaMapData {
|
| 22 |
+
locations?: LocationShape[];
|
| 23 |
+
features?: LocationShape[];
|
| 24 |
+
viewBox?: string;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface Centroid {
|
| 28 |
+
id: string;
|
| 29 |
+
x: number;
|
| 30 |
+
y: number;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface SriLankaMapProps {
|
| 34 |
+
selectedDistrict: string | null;
|
| 35 |
+
onDistrictSelect: (district: string) => void;
|
| 36 |
+
alertCounts?: Record<string, number>;
|
| 37 |
+
className?: string;
|
| 38 |
+
width?: number | string;
|
| 39 |
+
height?: number | string;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// ------------ Component ------------
|
| 43 |
+
|
| 44 |
+
const SriLankaMap: React.FC<SriLankaMapProps> = ({
|
| 45 |
+
selectedDistrict,
|
| 46 |
+
onDistrictSelect,
|
| 47 |
+
alertCounts = {},
|
| 48 |
+
className = "",
|
| 49 |
+
width = "100%",
|
| 50 |
+
height = "100%",
|
| 51 |
+
}) => {
|
| 52 |
+
const mapData = sriLanka as unknown as SriLankaMapData;
|
| 53 |
+
|
| 54 |
+
const safeAlerts = alertCounts || {};
|
| 55 |
+
const totalAlerts = Object.values(safeAlerts).reduce((a, b) => a + (b || 0), 0);
|
| 56 |
+
|
| 57 |
+
// Extract locations safely
|
| 58 |
+
const locations: LocationShape[] = Array.isArray(mapData.locations)
|
| 59 |
+
? mapData.locations
|
| 60 |
+
: Array.isArray(mapData.features)
|
| 61 |
+
? mapData.features
|
| 62 |
+
: [];
|
| 63 |
+
|
| 64 |
+
// ------------ Compute Centroids (safe + typed) ------------
|
| 65 |
+
|
| 66 |
+
const centroids = useMemo<Centroid[]>(() => {
|
| 67 |
+
return locations.map((loc) => {
|
| 68 |
+
const id = loc.id ?? loc.name ?? "unknown";
|
| 69 |
+
|
| 70 |
+
// Use existing centroid
|
| 71 |
+
if (
|
| 72 |
+
Array.isArray(loc.centroid) &&
|
| 73 |
+
loc.centroid.length === 2 &&
|
| 74 |
+
typeof loc.centroid[0] === "number" &&
|
| 75 |
+
typeof loc.centroid[1] === "number"
|
| 76 |
+
) {
|
| 77 |
+
return { id, x: loc.centroid[0], y: loc.centroid[1] };
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Compute centroid from SVG path
|
| 81 |
+
const d = loc.path ?? loc.paths ?? loc.d ?? "";
|
| 82 |
+
const nums = d.match(/-?\d+\.?\d*/g)?.map(Number) ?? [];
|
| 83 |
+
|
| 84 |
+
const xs = nums.filter((_, i) => i % 2 === 0);
|
| 85 |
+
const ys = nums.filter((_, i) => i % 2 === 1);
|
| 86 |
+
|
| 87 |
+
const x = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
|
| 88 |
+
const y = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : 0;
|
| 89 |
+
|
| 90 |
+
return { id, x, y };
|
| 91 |
+
});
|
| 92 |
+
}, [locations]);
|
| 93 |
+
|
| 94 |
+
// Helper to find a centroid
|
| 95 |
+
const findCentroid = (id: string): [number, number] => {
|
| 96 |
+
const c = centroids.find((c) => c.id === id);
|
| 97 |
+
return c ? [c.x, c.y] : [0, 0];
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const viewBox = mapData.viewBox ?? "0 0 600 900";
|
| 101 |
+
|
| 102 |
+
// ------------ Render ------------
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div className={`relative ${className}`} style={{ width, height }}>
|
| 106 |
+
<svg
|
| 107 |
+
viewBox={viewBox}
|
| 108 |
+
width="100%"
|
| 109 |
+
height="100%"
|
| 110 |
+
preserveAspectRatio="xMidYMid meet"
|
| 111 |
+
role="img"
|
| 112 |
+
aria-label="Sri Lanka map by district"
|
| 113 |
+
>
|
| 114 |
+
<g id="base">
|
| 115 |
+
<rect width="100%" height="100%" fill="transparent" />
|
| 116 |
+
</g>
|
| 117 |
+
|
| 118 |
+
<g id="districts">
|
| 119 |
+
{locations.map((loc) => {
|
| 120 |
+
const id = loc.id ?? loc.name ?? "unknown";
|
| 121 |
+
const name = loc.name ?? id;
|
| 122 |
+
|
| 123 |
+
const pathData =
|
| 124 |
+
loc.path ??
|
| 125 |
+
loc.paths ??
|
| 126 |
+
loc.d ??
|
| 127 |
+
(loc.geometry?.coordinates ? "" : "");
|
| 128 |
+
|
| 129 |
+
const alertCount = safeAlerts[name] ?? 0;
|
| 130 |
+
const isSelected = selectedDistrict === name;
|
| 131 |
+
const hasAlerts = alertCount > 0;
|
| 132 |
+
|
| 133 |
+
const fill = isSelected
|
| 134 |
+
? "hsl(var(--primary) / 0.95)"
|
| 135 |
+
: hasAlerts
|
| 136 |
+
? "hsl(var(--destructive) / 0.28)"
|
| 137 |
+
: "hsl(var(--muted) / 0.22)";
|
| 138 |
+
|
| 139 |
+
const stroke = isSelected
|
| 140 |
+
? "hsl(var(--primary))"
|
| 141 |
+
: hasAlerts
|
| 142 |
+
? "hsl(var(--destructive))"
|
| 143 |
+
: "hsl(var(--border))";
|
| 144 |
+
|
| 145 |
+
const strokeWidth = isSelected ? 2.2 : hasAlerts ? 1.5 : 0.9;
|
| 146 |
+
|
| 147 |
+
const [cx, cy] = findCentroid(id);
|
| 148 |
+
|
| 149 |
+
return (
|
| 150 |
+
<g key={id} id={`grp-${id}`} data-name={name}>
|
| 151 |
+
{pathData ? (
|
| 152 |
+
<motion.path
|
| 153 |
+
id={id}
|
| 154 |
+
d={pathData}
|
| 155 |
+
fill={fill}
|
| 156 |
+
stroke={stroke}
|
| 157 |
+
strokeWidth={strokeWidth}
|
| 158 |
+
className="cursor-pointer"
|
| 159 |
+
onClick={() => onDistrictSelect(name)}
|
| 160 |
+
whileHover={{ opacity: 0.85 }}
|
| 161 |
+
whileTap={{ scale: 0.995 }}
|
| 162 |
+
initial={{ opacity: 0.98 }}
|
| 163 |
+
transition={{ duration: 0.18 }}
|
| 164 |
+
/>
|
| 165 |
+
) : (
|
| 166 |
+
<motion.circle
|
| 167 |
+
cx={cx}
|
| 168 |
+
cy={cy}
|
| 169 |
+
r={10}
|
| 170 |
+
fill={fill}
|
| 171 |
+
stroke={stroke}
|
| 172 |
+
strokeWidth={1}
|
| 173 |
+
onClick={() => onDistrictSelect(name)}
|
| 174 |
+
className="cursor-pointer"
|
| 175 |
+
/>
|
| 176 |
+
)}
|
| 177 |
+
|
| 178 |
+
{alertCount > 0 && (
|
| 179 |
+
<motion.g
|
| 180 |
+
initial={{ scale: 0 }}
|
| 181 |
+
animate={{ scale: 1 }}
|
| 182 |
+
transition={{ type: "spring", stiffness: 200 }}
|
| 183 |
+
>
|
| 184 |
+
<circle
|
| 185 |
+
cx={cx}
|
| 186 |
+
cy={cy}
|
| 187 |
+
r={10}
|
| 188 |
+
fill="hsl(var(--destructive))"
|
| 189 |
+
stroke="white"
|
| 190 |
+
strokeWidth={1.2}
|
| 191 |
+
/>
|
| 192 |
+
<text
|
| 193 |
+
x={cx}
|
| 194 |
+
y={cy + 4}
|
| 195 |
+
textAnchor="middle"
|
| 196 |
+
fontSize={9}
|
| 197 |
+
fontWeight={700}
|
| 198 |
+
fill="#fff"
|
| 199 |
+
pointerEvents="none"
|
| 200 |
+
>
|
| 201 |
+
{alertCount}
|
| 202 |
+
</text>
|
| 203 |
+
</motion.g>
|
| 204 |
+
)}
|
| 205 |
+
</g>
|
| 206 |
+
);
|
| 207 |
+
})}
|
| 208 |
+
</g>
|
| 209 |
+
</svg>
|
| 210 |
+
|
| 211 |
+
{/* Legend */}
|
| 212 |
+
<div className="absolute top-3 right-3 bg-card/95 border border-border rounded p-2 text-xs text-center">
|
| 213 |
+
<div className="text-lg font-bold text-destructive">{totalAlerts}</div>
|
| 214 |
+
<div className="text-[10px] text-muted-foreground uppercase">
|
| 215 |
+
Total Alerts
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<style>{`
|
| 220 |
+
svg .cursor-pointer { cursor: pointer; }
|
| 221 |
+
`}</style>
|
| 222 |
+
</div>
|
| 223 |
+
);
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
export default SriLankaMap;
|
frontend/app/components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
| 3 |
+
import { ChevronDown } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "../../lib/utils";
|
| 6 |
+
|
| 7 |
+
const Accordion = AccordionPrimitive.Root;
|
| 8 |
+
|
| 9 |
+
const AccordionItem = React.forwardRef<
|
| 10 |
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
| 11 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
| 12 |
+
>(({ className, ...props }, ref) => (
|
| 13 |
+
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
| 14 |
+
));
|
| 15 |
+
AccordionItem.displayName = "AccordionItem";
|
| 16 |
+
|
| 17 |
+
const AccordionTrigger = React.forwardRef<
|
| 18 |
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
| 19 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
| 20 |
+
>(({ className, children, ...props }, ref) => (
|
| 21 |
+
<AccordionPrimitive.Header className="flex">
|
| 22 |
+
<AccordionPrimitive.Trigger
|
| 23 |
+
ref={ref}
|
| 24 |
+
className={cn(
|
| 25 |
+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
| 26 |
+
className,
|
| 27 |
+
)}
|
| 28 |
+
{...props}
|
| 29 |
+
>
|
| 30 |
+
{children}
|
| 31 |
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
| 32 |
+
</AccordionPrimitive.Trigger>
|
| 33 |
+
</AccordionPrimitive.Header>
|
| 34 |
+
));
|
| 35 |
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
| 36 |
+
|
| 37 |
+
const AccordionContent = React.forwardRef<
|
| 38 |
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
| 39 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
| 40 |
+
>(({ className, children, ...props }, ref) => (
|
| 41 |
+
<AccordionPrimitive.Content
|
| 42 |
+
ref={ref}
|
| 43 |
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
| 44 |
+
{...props}
|
| 45 |
+
>
|
| 46 |
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
| 47 |
+
</AccordionPrimitive.Content>
|
| 48 |
+
));
|
| 49 |
+
|
| 50 |
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
| 51 |
+
|
| 52 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
frontend/app/components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
| 3 |
+
|
| 4 |
+
import { cn } from "../../lib/utils";
|
| 5 |
+
import { buttonVariants } from "../ui/button";
|
| 6 |
+
|
| 7 |
+
const AlertDialog = AlertDialogPrimitive.Root;
|
| 8 |
+
|
| 9 |
+
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
| 10 |
+
|
| 11 |
+
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
| 12 |
+
|
| 13 |
+
const AlertDialogOverlay = React.forwardRef<
|
| 14 |
+
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
| 15 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
| 16 |
+
>(({ className, ...props }, ref) => (
|
| 17 |
+
<AlertDialogPrimitive.Overlay
|
| 18 |
+
className={cn(
|
| 19 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
| 20 |
+
className,
|
| 21 |
+
)}
|
| 22 |
+
{...props}
|
| 23 |
+
ref={ref}
|
| 24 |
+
/>
|
| 25 |
+
));
|
| 26 |
+
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
| 27 |
+
|
| 28 |
+
const AlertDialogContent = React.forwardRef<
|
| 29 |
+
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
| 30 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
| 31 |
+
>(({ className, ...props }, ref) => (
|
| 32 |
+
<AlertDialogPortal>
|
| 33 |
+
<AlertDialogOverlay />
|
| 34 |
+
<AlertDialogPrimitive.Content
|
| 35 |
+
ref={ref}
|
| 36 |
+
className={cn(
|
| 37 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
| 38 |
+
className,
|
| 39 |
+
)}
|
| 40 |
+
{...props}
|
| 41 |
+
/>
|
| 42 |
+
</AlertDialogPortal>
|
| 43 |
+
));
|
| 44 |
+
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
| 45 |
+
|
| 46 |
+
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
| 47 |
+
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
| 48 |
+
);
|
| 49 |
+
AlertDialogHeader.displayName = "AlertDialogHeader";
|
| 50 |
+
|
| 51 |
+
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
| 52 |
+
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
| 53 |
+
);
|
| 54 |
+
AlertDialogFooter.displayName = "AlertDialogFooter";
|
| 55 |
+
|
| 56 |
+
const AlertDialogTitle = React.forwardRef<
|
| 57 |
+
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
| 58 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
| 59 |
+
>(({ className, ...props }, ref) => (
|
| 60 |
+
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
| 61 |
+
));
|
| 62 |
+
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
| 63 |
+
|
| 64 |
+
const AlertDialogDescription = React.forwardRef<
|
| 65 |
+
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
| 66 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
| 67 |
+
>(({ className, ...props }, ref) => (
|
| 68 |
+
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
| 69 |
+
));
|
| 70 |
+
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
| 71 |
+
|
| 72 |
+
const AlertDialogAction = React.forwardRef<
|
| 73 |
+
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
| 74 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
| 75 |
+
>(({ className, ...props }, ref) => (
|
| 76 |
+
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
| 77 |
+
));
|
| 78 |
+
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
| 79 |
+
|
| 80 |
+
const AlertDialogCancel = React.forwardRef<
|
| 81 |
+
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
| 82 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
| 83 |
+
>(({ className, ...props }, ref) => (
|
| 84 |
+
<AlertDialogPrimitive.Cancel
|
| 85 |
+
ref={ref}
|
| 86 |
+
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
| 87 |
+
{...props}
|
| 88 |
+
/>
|
| 89 |
+
));
|
| 90 |
+
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
| 91 |
+
|
| 92 |
+
export {
|
| 93 |
+
AlertDialog,
|
| 94 |
+
AlertDialogPortal,
|
| 95 |
+
AlertDialogOverlay,
|
| 96 |
+
AlertDialogTrigger,
|
| 97 |
+
AlertDialogContent,
|
| 98 |
+
AlertDialogHeader,
|
| 99 |
+
AlertDialogFooter,
|
| 100 |
+
AlertDialogTitle,
|
| 101 |
+
AlertDialogDescription,
|
| 102 |
+
AlertDialogAction,
|
| 103 |
+
AlertDialogCancel,
|
| 104 |
+
};
|
frontend/app/components/ui/alert.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
|
| 4 |
+
import { cn } from "../../lib/utils";
|
| 5 |
+
|
| 6 |
+
const alertVariants = cva(
|
| 7 |
+
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-background text-foreground",
|
| 12 |
+
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
| 13 |
+
},
|
| 14 |
+
},
|
| 15 |
+
defaultVariants: {
|
| 16 |
+
variant: "default",
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
const Alert = React.forwardRef<
|
| 22 |
+
HTMLDivElement,
|
| 23 |
+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
| 24 |
+
>(({ className, variant, ...props }, ref) => (
|
| 25 |
+
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
| 26 |
+
));
|
| 27 |
+
Alert.displayName = "Alert";
|
| 28 |
+
|
| 29 |
+
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
| 30 |
+
({ className, ...props }, ref) => (
|
| 31 |
+
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
| 32 |
+
),
|
| 33 |
+
);
|
| 34 |
+
AlertTitle.displayName = "AlertTitle";
|
| 35 |
+
|
| 36 |
+
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
| 37 |
+
({ className, ...props }, ref) => (
|
| 38 |
+
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
| 39 |
+
),
|
| 40 |
+
);
|
| 41 |
+
AlertDescription.displayName = "AlertDescription";
|
| 42 |
+
|
| 43 |
+
export { Alert, AlertTitle, AlertDescription };
|
frontend/app/components/ui/aspect-ratio.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
| 2 |
+
|
| 3 |
+
const AspectRatio = AspectRatioPrimitive.Root;
|
| 4 |
+
|
| 5 |
+
export { AspectRatio };
|
frontend/app/components/ui/avatar.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
| 3 |
+
|
| 4 |
+
import { cn } from "../../lib/utils";
|
| 5 |
+
|
| 6 |
+
const Avatar = React.forwardRef<
|
| 7 |
+
React.ElementRef<typeof AvatarPrimitive.Root>,
|
| 8 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
| 9 |
+
>(({ className, ...props }, ref) => (
|
| 10 |
+
<AvatarPrimitive.Root
|
| 11 |
+
ref={ref}
|
| 12 |
+
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
));
|
| 16 |
+
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
| 17 |
+
|
| 18 |
+
const AvatarImage = React.forwardRef<
|
| 19 |
+
React.ElementRef<typeof AvatarPrimitive.Image>,
|
| 20 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
| 21 |
+
>(({ className, ...props }, ref) => (
|
| 22 |
+
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
|
| 23 |
+
));
|
| 24 |
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
| 25 |
+
|
| 26 |
+
const AvatarFallback = React.forwardRef<
|
| 27 |
+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
| 28 |
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
| 29 |
+
>(({ className, ...props }, ref) => (
|
| 30 |
+
<AvatarPrimitive.Fallback
|
| 31 |
+
ref={ref}
|
| 32 |
+
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
|
| 33 |
+
{...props}
|
| 34 |
+
/>
|
| 35 |
+
));
|
| 36 |
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
| 37 |
+
|
| 38 |
+
export { Avatar, AvatarImage, AvatarFallback };
|
frontend/app/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
|
| 4 |
+
import { cn } from "../../lib/utils";
|
| 5 |
+
|
| 6 |
+
const badgeVariants = cva(
|
| 7 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
| 12 |
+
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 13 |
+
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
| 14 |
+
outline: "text-foreground",
|
| 15 |
+
},
|
| 16 |
+
},
|
| 17 |
+
defaultVariants: {
|
| 18 |
+
variant: "default",
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
| 24 |
+
|
| 25 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 26 |
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export { Badge, badgeVariants };
|
frontend/app/components/ui/breadcrumb.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "../../lib/utils";
|
| 6 |
+
|
| 7 |
+
const Breadcrumb = React.forwardRef<
|
| 8 |
+
HTMLElement,
|
| 9 |
+
React.ComponentPropsWithoutRef<"nav"> & {
|
| 10 |
+
separator?: React.ReactNode;
|
| 11 |
+
}
|
| 12 |
+
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
| 13 |
+
Breadcrumb.displayName = "Breadcrumb";
|
| 14 |
+
|
| 15 |
+
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
| 16 |
+
({ className, ...props }, ref) => (
|
| 17 |
+
<ol
|
| 18 |
+
ref={ref}
|
| 19 |
+
className={cn(
|
| 20 |
+
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
| 21 |
+
className,
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
),
|
| 26 |
+
);
|
| 27 |
+
BreadcrumbList.displayName = "BreadcrumbList";
|
| 28 |
+
|
| 29 |
+
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
| 30 |
+
({ className, ...props }, ref) => (
|
| 31 |
+
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
|
| 32 |
+
),
|
| 33 |
+
);
|
| 34 |
+
BreadcrumbItem.displayName = "BreadcrumbItem";
|
| 35 |
+
|
| 36 |
+
const BreadcrumbLink = React.forwardRef<
|
| 37 |
+
HTMLAnchorElement,
|
| 38 |
+
React.ComponentPropsWithoutRef<"a"> & {
|
| 39 |
+
asChild?: boolean;
|
| 40 |
+
}
|
| 41 |
+
>(({ asChild, className, ...props }, ref) => {
|
| 42 |
+
const Comp = asChild ? Slot : "a";
|
| 43 |
+
|
| 44 |
+
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
|
| 45 |
+
});
|
| 46 |
+
BreadcrumbLink.displayName = "BreadcrumbLink";
|
| 47 |
+
|
| 48 |
+
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
| 49 |
+
({ className, ...props }, ref) => (
|
| 50 |
+
<span
|
| 51 |
+
ref={ref}
|
| 52 |
+
role="link"
|
| 53 |
+
aria-disabled="true"
|
| 54 |
+
aria-current="page"
|
| 55 |
+
className={cn("font-normal text-foreground", className)}
|
| 56 |
+
{...props}
|
| 57 |
+
/>
|
| 58 |
+
),
|
| 59 |
+
);
|
| 60 |
+
BreadcrumbPage.displayName = "BreadcrumbPage";
|
| 61 |
+
|
| 62 |
+
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
| 63 |
+
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
| 64 |
+
{children ?? <ChevronRight />}
|
| 65 |
+
</li>
|
| 66 |
+
);
|
| 67 |
+
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
| 68 |
+
|
| 69 |
+
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
| 70 |
+
<span
|
| 71 |
+
role="presentation"
|
| 72 |
+
aria-hidden="true"
|
| 73 |
+
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
>
|
| 76 |
+
<MoreHorizontal className="h-4 w-4" />
|
| 77 |
+
<span className="sr-only">More</span>
|
| 78 |
+
</span>
|
| 79 |
+
);
|
| 80 |
+
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
| 81 |
+
|
| 82 |
+
export {
|
| 83 |
+
Breadcrumb,
|
| 84 |
+
BreadcrumbList,
|
| 85 |
+
BreadcrumbItem,
|
| 86 |
+
BreadcrumbLink,
|
| 87 |
+
BreadcrumbPage,
|
| 88 |
+
BreadcrumbSeparator,
|
| 89 |
+
BreadcrumbEllipsis,
|
| 90 |
+
};
|
frontend/app/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 4 |
+
|
| 5 |
+
import { cn } from "../../lib/utils";
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 13 |
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 14 |
+
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
| 15 |
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 16 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 17 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 18 |
+
},
|
| 19 |
+
size: {
|
| 20 |
+
default: "h-10 px-4 py-2",
|
| 21 |
+
sm: "h-9 rounded-md px-3",
|
| 22 |
+
lg: "h-11 rounded-md px-8",
|
| 23 |
+
icon: "h-10 w-10",
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
defaultVariants: {
|
| 27 |
+
variant: "default",
|
| 28 |
+
size: "default",
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
export interface ButtonProps
|
| 34 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 35 |
+
VariantProps<typeof buttonVariants> {
|
| 36 |
+
asChild?: boolean;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 40 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 41 |
+
const Comp = asChild ? Slot : "button";
|
| 42 |
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
| 43 |
+
},
|
| 44 |
+
);
|
| 45 |
+
Button.displayName = "Button";
|
| 46 |
+
|
| 47 |
+
export { Button, buttonVariants };
|
frontend/app/components/ui/calendar.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
| 3 |
+
import { DayPicker } from "react-day-picker";
|
| 4 |
+
|
| 5 |
+
import { cn } from "../../lib/utils";
|
| 6 |
+
import { buttonVariants } from "../ui/button";
|
| 7 |
+
|
| 8 |
+
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
| 9 |
+
|
| 10 |
+
function Calendar({
|
| 11 |
+
className,
|
| 12 |
+
classNames,
|
| 13 |
+
showOutsideDays = true,
|
| 14 |
+
...props
|
| 15 |
+
}: CalendarProps) {
|
| 16 |
+
return (
|
| 17 |
+
<DayPicker
|
| 18 |
+
showOutsideDays={showOutsideDays}
|
| 19 |
+
className={cn("p-3", className)}
|
| 20 |
+
classNames={{
|
| 21 |
+
months:
|
| 22 |
+
"flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
| 23 |
+
month: "space-y-4",
|
| 24 |
+
caption: "flex justify-center pt-1 relative items-center",
|
| 25 |
+
caption_label: "text-sm font-medium",
|
| 26 |
+
nav: "space-x-1 flex items-center",
|
| 27 |
+
nav_button: cn(
|
| 28 |
+
buttonVariants({ variant: "outline" }),
|
| 29 |
+
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
| 30 |
+
),
|
| 31 |
+
nav_button_previous: "absolute left-1",
|
| 32 |
+
nav_button_next: "absolute right-1",
|
| 33 |
+
table: "w-full border-collapse space-y-1",
|
| 34 |
+
head_row: "flex",
|
| 35 |
+
head_cell:
|
| 36 |
+
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
| 37 |
+
row: "flex w-full mt-2",
|
| 38 |
+
cell:
|
| 39 |
+
"h-9 w-9 text-center text-sm p-0 relative " +
|
| 40 |
+
"[&:has([aria-selected].day-range-end)]:rounded-r-md " +
|
| 41 |
+
"[&:has([aria-selected].day-outside)]:bg-accent/50 " +
|
| 42 |
+
"[&:has([aria-selected])]:bg-accent " +
|
| 43 |
+
"first:[&:has([aria-selected])]:rounded-l-md " +
|
| 44 |
+
"last:[&:has([aria-selected])]:rounded-r-md " +
|
| 45 |
+
"focus-within:relative focus-within:z-20",
|
| 46 |
+
day: cn(
|
| 47 |
+
buttonVariants({ variant: "ghost" }),
|
| 48 |
+
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
| 49 |
+
),
|
| 50 |
+
day_range_end: "day-range-end",
|
| 51 |
+
day_selected:
|
| 52 |
+
"bg-primary text-primary-foreground hover:bg-primary " +
|
| 53 |
+
"hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
| 54 |
+
day_today: "bg-accent text-accent-foreground",
|
| 55 |
+
day_outside:
|
| 56 |
+
"day-outside text-muted-foreground opacity-50 " +
|
| 57 |
+
"aria-selected:bg-accent/50 aria-selected:text-muted-foreground " +
|
| 58 |
+
"aria-selected:opacity-30",
|
| 59 |
+
day_disabled: "text-muted-foreground opacity-50",
|
| 60 |
+
day_range_middle:
|
| 61 |
+
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
| 62 |
+
day_hidden: "invisible",
|
| 63 |
+
...classNames,
|
| 64 |
+
}}
|
| 65 |
+
components={{
|
| 66 |
+
Chevron: (props) => {
|
| 67 |
+
return props.orientation === "left" ? (
|
| 68 |
+
<ChevronLeft className="h-4 w-4" {...props} />
|
| 69 |
+
) : (
|
| 70 |
+
<ChevronRight className="h-4 w-4" {...props} />
|
| 71 |
+
);
|
| 72 |
+
},
|
| 73 |
+
}}
|
| 74 |
+
{...props}
|
| 75 |
+
/>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
Calendar.displayName = "Calendar";
|
| 80 |
+
|
| 81 |
+
export { Calendar };
|
frontend/app/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
|
| 3 |
+
import { cn } from "../../lib/utils";
|
| 4 |
+
|
| 5 |
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
| 6 |
+
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
| 7 |
+
));
|
| 8 |
+
Card.displayName = "Card";
|
| 9 |
+
|
| 10 |
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 11 |
+
({ className, ...props }, ref) => (
|
| 12 |
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
| 13 |
+
),
|
| 14 |
+
);
|
| 15 |
+
CardHeader.displayName = "CardHeader";
|
| 16 |
+
|
| 17 |
+
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
| 18 |
+
({ className, ...props }, ref) => (
|
| 19 |
+
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
| 20 |
+
),
|
| 21 |
+
);
|
| 22 |
+
CardTitle.displayName = "CardTitle";
|
| 23 |
+
|
| 24 |
+
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
| 25 |
+
({ className, ...props }, ref) => (
|
| 26 |
+
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
| 27 |
+
),
|
| 28 |
+
);
|
| 29 |
+
CardDescription.displayName = "CardDescription";
|
| 30 |
+
|
| 31 |
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 32 |
+
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
| 33 |
+
);
|
| 34 |
+
CardContent.displayName = "CardContent";
|
| 35 |
+
|
| 36 |
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 37 |
+
({ className, ...props }, ref) => (
|
| 38 |
+
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
| 39 |
+
),
|
| 40 |
+
);
|
| 41 |
+
CardFooter.displayName = "CardFooter";
|
| 42 |
+
|
| 43 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
frontend/app/components/ui/carousel.tsx
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
| 3 |
+
import { ArrowLeft, ArrowRight } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "../../lib/utils";
|
| 6 |
+
import { Button } from "../../components/ui/button";
|
| 7 |
+
|
| 8 |
+
type CarouselApi = UseEmblaCarouselType[1];
|
| 9 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
| 10 |
+
type CarouselOptions = UseCarouselParameters[0];
|
| 11 |
+
type CarouselPlugin = UseCarouselParameters[1];
|
| 12 |
+
|
| 13 |
+
type CarouselProps = {
|
| 14 |
+
opts?: CarouselOptions;
|
| 15 |
+
plugins?: CarouselPlugin;
|
| 16 |
+
orientation?: "horizontal" | "vertical";
|
| 17 |
+
setApi?: (api: CarouselApi) => void;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
type CarouselContextProps = {
|
| 21 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
| 22 |
+
api: ReturnType<typeof useEmblaCarousel>[1];
|
| 23 |
+
scrollPrev: () => void;
|
| 24 |
+
scrollNext: () => void;
|
| 25 |
+
canScrollPrev: boolean;
|
| 26 |
+
canScrollNext: boolean;
|
| 27 |
+
} & CarouselProps;
|
| 28 |
+
|
| 29 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
| 30 |
+
|
| 31 |
+
function useCarousel() {
|
| 32 |
+
const context = React.useContext(CarouselContext);
|
| 33 |
+
|
| 34 |
+
if (!context) {
|
| 35 |
+
throw new Error("useCarousel must be used within a <Carousel />");
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return context;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
|
| 42 |
+
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
|
| 43 |
+
const [carouselRef, api] = useEmblaCarousel(
|
| 44 |
+
{
|
| 45 |
+
...opts,
|
| 46 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
| 47 |
+
},
|
| 48 |
+
plugins,
|
| 49 |
+
);
|
| 50 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
| 51 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
| 52 |
+
|
| 53 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
| 54 |
+
if (!api) {
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
setCanScrollPrev(api.canScrollPrev());
|
| 59 |
+
setCanScrollNext(api.canScrollNext());
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
const scrollPrev = React.useCallback(() => {
|
| 63 |
+
api?.scrollPrev();
|
| 64 |
+
}, [api]);
|
| 65 |
+
|
| 66 |
+
const scrollNext = React.useCallback(() => {
|
| 67 |
+
api?.scrollNext();
|
| 68 |
+
}, [api]);
|
| 69 |
+
|
| 70 |
+
const handleKeyDown = React.useCallback(
|
| 71 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
| 72 |
+
if (event.key === "ArrowLeft") {
|
| 73 |
+
event.preventDefault();
|
| 74 |
+
scrollPrev();
|
| 75 |
+
} else if (event.key === "ArrowRight") {
|
| 76 |
+
event.preventDefault();
|
| 77 |
+
scrollNext();
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
[scrollPrev, scrollNext],
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
React.useEffect(() => {
|
| 84 |
+
if (!api || !setApi) {
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
setApi(api);
|
| 89 |
+
}, [api, setApi]);
|
| 90 |
+
|
| 91 |
+
React.useEffect(() => {
|
| 92 |
+
if (!api) {
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
onSelect(api);
|
| 97 |
+
api.on("reInit", onSelect);
|
| 98 |
+
api.on("select", onSelect);
|
| 99 |
+
|
| 100 |
+
return () => {
|
| 101 |
+
api?.off("select", onSelect);
|
| 102 |
+
};
|
| 103 |
+
}, [api, onSelect]);
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<CarouselContext.Provider
|
| 107 |
+
value={{
|
| 108 |
+
carouselRef,
|
| 109 |
+
api: api,
|
| 110 |
+
opts,
|
| 111 |
+
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
| 112 |
+
scrollPrev,
|
| 113 |
+
scrollNext,
|
| 114 |
+
canScrollPrev,
|
| 115 |
+
canScrollNext,
|
| 116 |
+
}}
|
| 117 |
+
>
|
| 118 |
+
<div
|
| 119 |
+
ref={ref}
|
| 120 |
+
onKeyDownCapture={handleKeyDown}
|
| 121 |
+
className={cn("relative", className)}
|
| 122 |
+
role="region"
|
| 123 |
+
aria-roledescription="carousel"
|
| 124 |
+
{...props}
|
| 125 |
+
>
|
| 126 |
+
{children}
|
| 127 |
+
</div>
|
| 128 |
+
</CarouselContext.Provider>
|
| 129 |
+
);
|
| 130 |
+
},
|
| 131 |
+
);
|
| 132 |
+
Carousel.displayName = "Carousel";
|
| 133 |
+
|
| 134 |
+
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 135 |
+
({ className, ...props }, ref) => {
|
| 136 |
+
const { carouselRef, orientation } = useCarousel();
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div ref={carouselRef} className="overflow-hidden">
|
| 140 |
+
<div
|
| 141 |
+
ref={ref}
|
| 142 |
+
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
|
| 143 |
+
{...props}
|
| 144 |
+
/>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
},
|
| 148 |
+
);
|
| 149 |
+
CarouselContent.displayName = "CarouselContent";
|
| 150 |
+
|
| 151 |
+
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 152 |
+
({ className, ...props }, ref) => {
|
| 153 |
+
const { orientation } = useCarousel();
|
| 154 |
+
|
| 155 |
+
return (
|
| 156 |
+
<div
|
| 157 |
+
ref={ref}
|
| 158 |
+
role="group"
|
| 159 |
+
aria-roledescription="slide"
|
| 160 |
+
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
|
| 161 |
+
{...props}
|
| 162 |
+
/>
|
| 163 |
+
);
|
| 164 |
+
},
|
| 165 |
+
);
|
| 166 |
+
CarouselItem.displayName = "CarouselItem";
|
| 167 |
+
|
| 168 |
+
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
| 169 |
+
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
| 170 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
| 171 |
+
|
| 172 |
+
return (
|
| 173 |
+
<Button
|
| 174 |
+
ref={ref}
|
| 175 |
+
variant={variant}
|
| 176 |
+
size={size}
|
| 177 |
+
className={cn(
|
| 178 |
+
"absolute h-8 w-8 rounded-full",
|
| 179 |
+
orientation === "horizontal"
|
| 180 |
+
? "-left-12 top-1/2 -translate-y-1/2"
|
| 181 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 182 |
+
className,
|
| 183 |
+
)}
|
| 184 |
+
disabled={!canScrollPrev}
|
| 185 |
+
onClick={scrollPrev}
|
| 186 |
+
{...props}
|
| 187 |
+
>
|
| 188 |
+
<ArrowLeft className="h-4 w-4" />
|
| 189 |
+
<span className="sr-only">Previous slide</span>
|
| 190 |
+
</Button>
|
| 191 |
+
);
|
| 192 |
+
},
|
| 193 |
+
);
|
| 194 |
+
CarouselPrevious.displayName = "CarouselPrevious";
|
| 195 |
+
|
| 196 |
+
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
| 197 |
+
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
| 198 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
| 199 |
+
|
| 200 |
+
return (
|
| 201 |
+
<Button
|
| 202 |
+
ref={ref}
|
| 203 |
+
variant={variant}
|
| 204 |
+
size={size}
|
| 205 |
+
className={cn(
|
| 206 |
+
"absolute h-8 w-8 rounded-full",
|
| 207 |
+
orientation === "horizontal"
|
| 208 |
+
? "-right-12 top-1/2 -translate-y-1/2"
|
| 209 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 210 |
+
className,
|
| 211 |
+
)}
|
| 212 |
+
disabled={!canScrollNext}
|
| 213 |
+
onClick={scrollNext}
|
| 214 |
+
{...props}
|
| 215 |
+
>
|
| 216 |
+
<ArrowRight className="h-4 w-4" />
|
| 217 |
+
<span className="sr-only">Next slide</span>
|
| 218 |
+
</Button>
|
| 219 |
+
);
|
| 220 |
+
},
|
| 221 |
+
);
|
| 222 |
+
CarouselNext.displayName = "CarouselNext";
|
| 223 |
+
|
| 224 |
+
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
|
frontend/app/components/ui/chart.tsx
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as RechartsPrimitive from "recharts";
|
| 3 |
+
|
| 4 |
+
import { cn } from "../../lib/utils";
|
| 5 |
+
|
| 6 |
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
| 7 |
+
const THEMES = { light: "", dark: ".dark" } as const;
|
| 8 |
+
|
| 9 |
+
export type ChartConfig = {
|
| 10 |
+
[k: string]: {
|
| 11 |
+
label?: React.ReactNode;
|
| 12 |
+
icon?: React.ComponentType;
|
| 13 |
+
} & (
|
| 14 |
+
| { color?: string; theme?: never }
|
| 15 |
+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
| 16 |
+
);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
type ChartContextProps = {
|
| 20 |
+
config: ChartConfig;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
| 24 |
+
|
| 25 |
+
function useChart() {
|
| 26 |
+
const context = React.useContext(ChartContext);
|
| 27 |
+
if (!context) {
|
| 28 |
+
throw new Error("useChart must be used within a <ChartContainer />");
|
| 29 |
+
}
|
| 30 |
+
return context;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const ChartContainer = React.forwardRef<
|
| 34 |
+
HTMLDivElement,
|
| 35 |
+
React.ComponentProps<"div"> & {
|
| 36 |
+
config: ChartConfig;
|
| 37 |
+
children: React.ComponentProps<
|
| 38 |
+
typeof RechartsPrimitive.ResponsiveContainer
|
| 39 |
+
>["children"];
|
| 40 |
+
}
|
| 41 |
+
>(({ id, className, children, config, ...props }, ref) => {
|
| 42 |
+
const uniqueId = React.useId();
|
| 43 |
+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<ChartContext.Provider value={{ config }}>
|
| 47 |
+
<div
|
| 48 |
+
data-chart={chartId}
|
| 49 |
+
ref={ref}
|
| 50 |
+
className={cn(
|
| 51 |
+
"flex aspect-video justify-center text-xs " +
|
| 52 |
+
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground " +
|
| 53 |
+
"[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 " +
|
| 54 |
+
"[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border " +
|
| 55 |
+
"[&_.recharts-dot[stroke='#fff']]:stroke-transparent " +
|
| 56 |
+
"[&_.recharts-layer]:outline-none " +
|
| 57 |
+
"[&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border " +
|
| 58 |
+
"[&_.recharts-radial-bar-background-sector]:fill-muted " +
|
| 59 |
+
"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted " +
|
| 60 |
+
"[&_.recharts-reference-line_[stroke='#ccc']]:stroke-border " +
|
| 61 |
+
"[&_.recharts-sector[stroke='#fff']]:stroke-transparent " +
|
| 62 |
+
"[&_.recharts-sector]:outline-none " +
|
| 63 |
+
"[&_.recharts-surface]:outline-none",
|
| 64 |
+
className
|
| 65 |
+
)}
|
| 66 |
+
{...props}
|
| 67 |
+
>
|
| 68 |
+
<ChartStyle id={chartId} config={config} />
|
| 69 |
+
<RechartsPrimitive.ResponsiveContainer>
|
| 70 |
+
{children}
|
| 71 |
+
</RechartsPrimitive.ResponsiveContainer>
|
| 72 |
+
</div>
|
| 73 |
+
</ChartContext.Provider>
|
| 74 |
+
);
|
| 75 |
+
});
|
| 76 |
+
ChartContainer.displayName = "Chart";
|
| 77 |
+
|
| 78 |
+
const ChartStyle = ({
|
| 79 |
+
id,
|
| 80 |
+
config,
|
| 81 |
+
}: {
|
| 82 |
+
id: string;
|
| 83 |
+
config: ChartConfig;
|
| 84 |
+
}) => {
|
| 85 |
+
const colorConfig = Object.entries(config).filter(
|
| 86 |
+
([_, cfg]) => cfg.theme || cfg.color
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
if (!colorConfig.length) return null;
|
| 90 |
+
|
| 91 |
+
return (
|
| 92 |
+
<style
|
| 93 |
+
dangerouslySetInnerHTML={{
|
| 94 |
+
__html: Object.entries(THEMES)
|
| 95 |
+
.map(
|
| 96 |
+
([theme, prefix]) => `
|
| 97 |
+
${prefix} [data-chart=${id}] {
|
| 98 |
+
${colorConfig
|
| 99 |
+
.map(([key, itemConfig]) => {
|
| 100 |
+
const color =
|
| 101 |
+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
| 102 |
+
itemConfig.color;
|
| 103 |
+
return color ? ` --color-${key}: ${color};` : "";
|
| 104 |
+
})
|
| 105 |
+
.join("\n")}
|
| 106 |
+
}
|
| 107 |
+
`
|
| 108 |
+
)
|
| 109 |
+
.join("\n"),
|
| 110 |
+
}}
|
| 111 |
+
/>
|
| 112 |
+
);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Expose the raw Recharts Tooltip component to be used in charts.
|
| 117 |
+
* (We keep the raw component export for parity with shadcn patterns.)
|
| 118 |
+
*/
|
| 119 |
+
const ChartTooltip = RechartsPrimitive.Tooltip;
|
| 120 |
+
|
| 121 |
+
/* ---------------------------
|
| 122 |
+
Custom Tooltip Content
|
| 123 |
+
- Compatible with Recharts v3
|
| 124 |
+
- Avoids importing removed types (TooltipPayload)
|
| 125 |
+
- Uses a small local type describing what we need from Recharts
|
| 126 |
+
--------------------------- */
|
| 127 |
+
|
| 128 |
+
type RechartsPayloadItem = {
|
| 129 |
+
dataKey?: string | number;
|
| 130 |
+
name?: string;
|
| 131 |
+
value?: any;
|
| 132 |
+
payload?: Record<string, any> | undefined;
|
| 133 |
+
// Recharts provides 'color' at runtime for many series; unknown to types,
|
| 134 |
+
// so treat as optional/any when reading.
|
| 135 |
+
color?: string;
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
type ChartTooltipContentProps = {
|
| 139 |
+
active?: boolean;
|
| 140 |
+
label?: string | number | React.ReactNode;
|
| 141 |
+
payload?: RechartsPayloadItem[];
|
| 142 |
+
labelFormatter?: (label: any, payload?: RechartsPayloadItem[]) => React.ReactNode;
|
| 143 |
+
formatter?: (value: any, name: any, item?: RechartsPayloadItem, index?: number, fullPayload?: any) => React.ReactNode;
|
| 144 |
+
className?: string;
|
| 145 |
+
labelClassName?: string;
|
| 146 |
+
hideLabel?: boolean;
|
| 147 |
+
hideIndicator?: boolean;
|
| 148 |
+
indicator?: "line" | "dot" | "dashed";
|
| 149 |
+
nameKey?: string;
|
| 150 |
+
labelKey?: string;
|
| 151 |
+
// any other props may exist; we don't depend on them
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
|
| 155 |
+
(props, ref) => {
|
| 156 |
+
const {
|
| 157 |
+
active,
|
| 158 |
+
className,
|
| 159 |
+
indicator = "dot",
|
| 160 |
+
hideLabel = false,
|
| 161 |
+
hideIndicator = false,
|
| 162 |
+
label,
|
| 163 |
+
labelFormatter,
|
| 164 |
+
labelClassName,
|
| 165 |
+
formatter,
|
| 166 |
+
nameKey,
|
| 167 |
+
labelKey,
|
| 168 |
+
payload = [],
|
| 169 |
+
} = props;
|
| 170 |
+
|
| 171 |
+
const { config } = useChart();
|
| 172 |
+
|
| 173 |
+
const tooltipLabel = React.useMemo(() => {
|
| 174 |
+
if (hideLabel || !payload?.length) return null;
|
| 175 |
+
|
| 176 |
+
const item = payload[0];
|
| 177 |
+
if (!item) return null;
|
| 178 |
+
|
| 179 |
+
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
| 180 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 181 |
+
|
| 182 |
+
const value =
|
| 183 |
+
!labelKey && typeof label === "string"
|
| 184 |
+
? (config[label as string]?.label ?? label)
|
| 185 |
+
: itemConfig?.label;
|
| 186 |
+
|
| 187 |
+
if (!value) return null;
|
| 188 |
+
|
| 189 |
+
if (labelFormatter) {
|
| 190 |
+
return (
|
| 191 |
+
<div className={cn("font-medium", labelClassName)}>
|
| 192 |
+
{labelFormatter(value, payload)}
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
| 198 |
+
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
| 199 |
+
|
| 200 |
+
if (!active || !payload?.length) return null;
|
| 201 |
+
|
| 202 |
+
const nestLabel = payload.length === 1 && indicator !== "dot";
|
| 203 |
+
|
| 204 |
+
return (
|
| 205 |
+
<div
|
| 206 |
+
ref={ref}
|
| 207 |
+
className={cn(
|
| 208 |
+
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
| 209 |
+
className
|
| 210 |
+
)}
|
| 211 |
+
>
|
| 212 |
+
{!nestLabel && tooltipLabel}
|
| 213 |
+
<div className="grid gap-1.5">
|
| 214 |
+
{payload.map((item, index) => {
|
| 215 |
+
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
| 216 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 217 |
+
|
| 218 |
+
// Recharts v3 exposes `color` at runtime for many series, but types may not.
|
| 219 |
+
// Read it defensively:
|
| 220 |
+
const indicatorColor = (item && (item.color ?? item.payload?.fill)) ?? undefined;
|
| 221 |
+
|
| 222 |
+
return (
|
| 223 |
+
<div
|
| 224 |
+
key={String(item.dataKey ?? index)}
|
| 225 |
+
className={cn(
|
| 226 |
+
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
| 227 |
+
indicator === "dot" && "items-center"
|
| 228 |
+
)}
|
| 229 |
+
>
|
| 230 |
+
{formatter && item.value !== undefined && item.name ? (
|
| 231 |
+
formatter(item.value, item.name, item, index, item.payload)
|
| 232 |
+
) : (
|
| 233 |
+
<>
|
| 234 |
+
{itemConfig?.icon ? (
|
| 235 |
+
<itemConfig.icon />
|
| 236 |
+
) : (
|
| 237 |
+
!hideIndicator && (
|
| 238 |
+
<div
|
| 239 |
+
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
| 240 |
+
"h-2.5 w-2.5": indicator === "dot",
|
| 241 |
+
"w-1": indicator === "line",
|
| 242 |
+
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
| 243 |
+
"my-0.5": nestLabel && indicator === "dashed",
|
| 244 |
+
})}
|
| 245 |
+
style={
|
| 246 |
+
{
|
| 247 |
+
"--color-bg": indicatorColor,
|
| 248 |
+
"--color-border": indicatorColor,
|
| 249 |
+
} as React.CSSProperties
|
| 250 |
+
}
|
| 251 |
+
/>
|
| 252 |
+
)
|
| 253 |
+
)}
|
| 254 |
+
<div
|
| 255 |
+
className={cn("flex flex-1 justify-between leading-none", nestLabel ? "items-end" : "items-center")}
|
| 256 |
+
>
|
| 257 |
+
<div className="grid gap-1.5">
|
| 258 |
+
{nestLabel && tooltipLabel}
|
| 259 |
+
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
{item.value !== undefined && (
|
| 263 |
+
<span className="font-mono font-medium tabular-nums text-foreground">
|
| 264 |
+
{typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)}
|
| 265 |
+
</span>
|
| 266 |
+
)}
|
| 267 |
+
</div>
|
| 268 |
+
</>
|
| 269 |
+
)}
|
| 270 |
+
</div>
|
| 271 |
+
);
|
| 272 |
+
})}
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
);
|
| 276 |
+
}
|
| 277 |
+
);
|
| 278 |
+
ChartTooltipContent.displayName = "ChartTooltip";
|
| 279 |
+
|
| 280 |
+
/* ---------------------------
|
| 281 |
+
Legend
|
| 282 |
+
- For Recharts v3 the Legend payload shape can differ; be defensive and use any[]
|
| 283 |
+
--------------------------- */
|
| 284 |
+
|
| 285 |
+
const ChartLegend = RechartsPrimitive.Legend;
|
| 286 |
+
|
| 287 |
+
const ChartLegendContent = React.forwardRef<
|
| 288 |
+
HTMLDivElement,
|
| 289 |
+
React.ComponentProps<"div"> & {
|
| 290 |
+
payload?: any[]; // Recharts v3 payload shape varies; use any[]
|
| 291 |
+
hideIcon?: boolean;
|
| 292 |
+
nameKey?: string;
|
| 293 |
+
verticalAlign?: "top" | "bottom";
|
| 294 |
+
}
|
| 295 |
+
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
| 296 |
+
const { config } = useChart();
|
| 297 |
+
|
| 298 |
+
if (!payload?.length) return null;
|
| 299 |
+
|
| 300 |
+
return (
|
| 301 |
+
<div
|
| 302 |
+
ref={ref}
|
| 303 |
+
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
|
| 304 |
+
>
|
| 305 |
+
{payload.map((item: any) => {
|
| 306 |
+
const key = `${nameKey || item.dataKey || "value"}`;
|
| 307 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
| 308 |
+
|
| 309 |
+
return (
|
| 310 |
+
<div
|
| 311 |
+
key={String(item.value ?? item.dataKey ?? Math.random())}
|
| 312 |
+
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
|
| 313 |
+
>
|
| 314 |
+
{itemConfig?.icon && !hideIcon ? (
|
| 315 |
+
<itemConfig.icon />
|
| 316 |
+
) : (
|
| 317 |
+
<div
|
| 318 |
+
className="h-2 w-2 shrink-0 rounded-[2px]"
|
| 319 |
+
style={{ backgroundColor: item.color ?? undefined }}
|
| 320 |
+
/>
|
| 321 |
+
)}
|
| 322 |
+
{itemConfig?.label ?? item.value ?? item.dataKey}
|
| 323 |
+
</div>
|
| 324 |
+
);
|
| 325 |
+
})}
|
| 326 |
+
</div>
|
| 327 |
+
);
|
| 328 |
+
});
|
| 329 |
+
ChartLegendContent.displayName = "ChartLegend";
|
| 330 |
+
|
| 331 |
+
/* ---------------------------
|
| 332 |
+
Helpers
|
| 333 |
+
--------------------------- */
|
| 334 |
+
|
| 335 |
+
function getPayloadConfigFromPayload(config: ChartConfig, payload: any, key: string) {
|
| 336 |
+
if (typeof payload !== "object" || payload === null) return undefined;
|
| 337 |
+
|
| 338 |
+
const payloadPayload =
|
| 339 |
+
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null ? payload.payload : undefined;
|
| 340 |
+
|
| 341 |
+
let configLabelKey: string = key;
|
| 342 |
+
|
| 343 |
+
if (key in payload && typeof payload[key] === "string") {
|
| 344 |
+
configLabelKey = payload[key];
|
| 345 |
+
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key] === "string") {
|
| 346 |
+
configLabelKey = payloadPayload[key];
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
return (config as any)[configLabelKey] ?? (config as any)[key];
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/* ---------------------------
|
| 353 |
+
Exports
|
| 354 |
+
--------------------------- */
|
| 355 |
+
|
| 356 |
+
export {
|
| 357 |
+
ChartContainer,
|
| 358 |
+
ChartTooltip,
|
| 359 |
+
ChartTooltipContent,
|
| 360 |
+
ChartLegend,
|
| 361 |
+
ChartLegendContent,
|
| 362 |
+
ChartStyle,
|
| 363 |
+
};
|
frontend/app/components/ui/checkbox.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
| 3 |
+
import { Check } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
import { cn } from "../../lib/utils";
|
| 6 |
+
|
| 7 |
+
const Checkbox = React.forwardRef<
|
| 8 |
+
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
| 9 |
+
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
| 10 |
+
>(({ className, ...props }, ref) => (
|
| 11 |
+
<CheckboxPrimitive.Root
|
| 12 |
+
ref={ref}
|
| 13 |
+
className={cn(
|
| 14 |
+
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 15 |
+
className,
|
| 16 |
+
)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
| 20 |
+
<Check className="h-4 w-4" />
|
| 21 |
+
</CheckboxPrimitive.Indicator>
|
| 22 |
+
</CheckboxPrimitive.Root>
|
| 23 |
+
));
|
| 24 |
+
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
| 25 |
+
|
| 26 |
+
export { Checkbox };
|