initial commit
This commit is contained in:
24
dashboard/.gitignore
vendored
Normal file
24
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
dashboard/README.md
Normal file
16
dashboard/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
dashboard/eslint.config.js
Normal file
29
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
16
dashboard/index.html
Normal file
16
dashboard/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>Apophis SOC | Security Operations Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3852
dashboard/package-lock.json
generated
Normal file
3852
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
dashboard/package.json
Normal file
31
dashboard/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
BIN
dashboard/public/logo.png
Normal file
BIN
dashboard/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 MiB |
1
dashboard/public/vite.svg
Normal file
1
dashboard/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
dashboard/src/App.css
Normal file
1
dashboard/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Styles handled by Tailwind + index.css */
|
||||
100
dashboard/src/App.jsx
Normal file
100
dashboard/src/App.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Header from './components/Header';
|
||||
import ThreatFeed from './components/ThreatFeed';
|
||||
import NetworkTraffic from './components/NetworkTraffic';
|
||||
import SystemHealth from './components/SystemHealth';
|
||||
import MitreHeatmap from './components/MitreHeatmap';
|
||||
import TopThreats from './components/TopThreats';
|
||||
import IncidentTracker from './components/IncidentTracker';
|
||||
import VulnSummary from './components/VulnSummary';
|
||||
import {
|
||||
generateAlertBatch,
|
||||
generateAlert,
|
||||
generateTrafficData,
|
||||
generateSystemHealth,
|
||||
generateMitreData,
|
||||
generateTopThreats,
|
||||
generateIncidents,
|
||||
generateVulnSummary,
|
||||
getOverallThreatLevel,
|
||||
} from './data/mockData';
|
||||
|
||||
function App() {
|
||||
const [alerts, setAlerts] = useState(() => generateAlertBatch(25));
|
||||
const [traffic, setTraffic] = useState(() => generateTrafficData(24));
|
||||
const [systems, setSystems] = useState(() => generateSystemHealth());
|
||||
const [mitre, setMitre] = useState(() => generateMitreData());
|
||||
const [threats, setThreats] = useState(() => generateTopThreats(8));
|
||||
const [incidents, setIncidents] = useState(() => generateIncidents());
|
||||
const [vulns, setVulns] = useState(() => generateVulnSummary());
|
||||
|
||||
const threatLevel = getOverallThreatLevel(alerts);
|
||||
|
||||
// Simulate live alert feed - new alert every 3-6 seconds
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setAlerts((prev) => {
|
||||
const newAlert = generateAlert();
|
||||
const updated = [newAlert, ...prev];
|
||||
return updated.slice(0, 50);
|
||||
});
|
||||
}, 3000 + Math.random() * 3000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Refresh slower-changing data periodically
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setSystems(generateSystemHealth());
|
||||
setTraffic(generateTrafficData(24));
|
||||
}, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setThreats(generateTopThreats(8));
|
||||
setVulns(generateVulnSummary());
|
||||
setMitre(generateMitreData());
|
||||
setIncidents(generateIncidents());
|
||||
}, 30000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-ap-dark">
|
||||
<Header threatLevel={threatLevel} />
|
||||
|
||||
<div className="flex-1 grid grid-cols-12 grid-rows-[1fr_1fr_1fr] gap-px bg-white/5 min-h-0 overflow-hidden">
|
||||
{/* Row 1: Threat Feed | Network Traffic | System Health */}
|
||||
<div className="col-span-3 min-h-0 overflow-hidden">
|
||||
<ThreatFeed alerts={alerts} />
|
||||
</div>
|
||||
<div className="col-span-6 min-h-0 overflow-hidden">
|
||||
<NetworkTraffic data={traffic} />
|
||||
</div>
|
||||
<div className="col-span-3 min-h-0 overflow-hidden">
|
||||
<SystemHealth systems={systems} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: MITRE ATT&CK Heatmap | Top Threats Table */}
|
||||
<div className="col-span-6 min-h-0 overflow-hidden">
|
||||
<MitreHeatmap data={mitre} />
|
||||
</div>
|
||||
<div className="col-span-6 min-h-0 overflow-hidden">
|
||||
<TopThreats threats={threats} />
|
||||
</div>
|
||||
|
||||
{/* Row 3: Incident Tracker | Vulnerability Summary */}
|
||||
<div className="col-span-7 min-h-0 overflow-hidden">
|
||||
<IncidentTracker incidents={incidents} />
|
||||
</div>
|
||||
<div className="col-span-5 min-h-0 overflow-hidden">
|
||||
<VulnSummary data={vulns} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
57
dashboard/src/components/Header.jsx
Normal file
57
dashboard/src/components/Header.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const THREAT_LEVEL_STYLES = {
|
||||
CRITICAL: 'bg-ap-red text-white animate-pulse-red',
|
||||
HIGH: 'bg-ap-orange text-white',
|
||||
ELEVATED: 'bg-ap-yellow text-ap-black',
|
||||
GUARDED: 'bg-ap-green text-ap-black',
|
||||
};
|
||||
|
||||
function formatTime(date) {
|
||||
const h = String(date.getHours()).padStart(2, '0');
|
||||
const m = String(date.getMinutes()).padStart(2, '0');
|
||||
const s = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
export default function Header({ threatLevel = 'GUARDED' }) {
|
||||
const [time, setTime] = useState(() => formatTime(new Date()));
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setTime(formatTime(new Date()));
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const badgeStyle = THREAT_LEVEL_STYLES[threatLevel] || THREAT_LEVEL_STYLES.GUARDED;
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between bg-ap-black border-b border-white/10 px-4 py-2">
|
||||
{/* Left -- Logo + Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/logo.png" alt="Apophis" className="h-9 w-auto" />
|
||||
<span className="font-mono font-bold tracking-wider text-ap-red text-sm select-none">
|
||||
APOPHIS SOC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center -- System Clock */}
|
||||
<div className="font-mono text-ap-silver-dim text-sm tabular-nums tracking-wide select-none">
|
||||
{time}
|
||||
</div>
|
||||
|
||||
{/* Right -- Threat Level Indicator */}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span className="text-[10px] text-ap-silver-dim font-mono uppercase tracking-widest select-none">
|
||||
Threat Level
|
||||
</span>
|
||||
<span
|
||||
className={`px-3 py-1 font-mono text-xs font-bold uppercase select-none ${badgeStyle}`}
|
||||
>
|
||||
{threatLevel}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
140
dashboard/src/components/IncidentTracker.jsx
Normal file
140
dashboard/src/components/IncidentTracker.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const PRIORITY_STYLE = {
|
||||
P1: 'bg-ap-red/20 text-ap-red',
|
||||
P2: 'bg-ap-orange/20 text-ap-orange',
|
||||
P3: 'bg-ap-yellow/20 text-ap-yellow',
|
||||
P4: 'bg-ap-blue/20 text-ap-blue',
|
||||
};
|
||||
|
||||
const STATUS_COLOR = {
|
||||
Open: 'text-ap-red',
|
||||
Investigating: 'text-ap-orange',
|
||||
Contained: 'text-ap-yellow',
|
||||
Resolved: 'text-ap-green',
|
||||
};
|
||||
|
||||
const STATUS_DOT = {
|
||||
Open: 'bg-ap-red',
|
||||
Investigating: 'bg-ap-orange',
|
||||
Contained: 'bg-ap-yellow',
|
||||
Resolved: 'bg-ap-green',
|
||||
};
|
||||
|
||||
const STATUS_ORDER = ['Open', 'Investigating', 'Contained', 'Resolved'];
|
||||
|
||||
function getRelativeTime(created) {
|
||||
if (!created) return '--';
|
||||
const date = created instanceof Date ? created : new Date(created);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority }) {
|
||||
const style = PRIORITY_STYLE[priority] || PRIORITY_STYLE.P4;
|
||||
return (
|
||||
<span className={`font-mono text-[10px] font-bold px-1.5 py-0.5 select-none ${style}`}>
|
||||
{priority}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const color = STATUS_COLOR[status] || STATUS_COLOR.Open;
|
||||
return (
|
||||
<span className={`font-mono text-[10px] uppercase select-none ${color}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCard({ incident }) {
|
||||
const { id, description, status, priority, assignee, created } = incident;
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-2 p-3 bg-ap-dark border border-white/5">
|
||||
{/* Top row: priority + ID + status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PriorityBadge priority={priority} />
|
||||
<span className="font-mono text-xs text-ap-silver">
|
||||
{id}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0">
|
||||
<StatusBadge status={status} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-ap-silver mt-1 truncate">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Bottom row: assignee + relative time */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{assignee && (
|
||||
<span className="font-mono text-[10px] text-ap-silver-dim px-1.5 py-0.5 bg-white/5">
|
||||
{assignee}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-[10px] text-ap-silver-dim tabular-nums">
|
||||
{getRelativeTime(created)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsBar({ incidents }) {
|
||||
const counts = STATUS_ORDER.reduce((acc, status) => {
|
||||
acc[status] = incidents.filter((i) => i.status === status).length;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-3 py-2 border-b border-white/5">
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-1.5 h-1.5 ${STATUS_DOT[status]}`} />
|
||||
<span className="font-mono text-[10px] text-ap-silver-dim">
|
||||
{status}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] font-bold text-ap-silver tabular-nums">
|
||||
{counts[status]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IncidentTracker({ incidents = [] }) {
|
||||
const activeCount = incidents.filter((i) => i.status !== 'Resolved').length;
|
||||
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Panel header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>Active Incidents</span>
|
||||
<span className="ml-auto px-1.5 py-0.5 text-[9px] font-mono font-bold tabular-nums bg-ap-red-dim text-ap-red select-none">
|
||||
{activeCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<StatsBar incidents={incidents} />
|
||||
|
||||
{/* Scrollable incident list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{incidents.length === 0 && (
|
||||
<div className="px-3 py-6 text-center font-mono text-xs text-ap-silver-dim">
|
||||
No incidents
|
||||
</div>
|
||||
)}
|
||||
{incidents.map((incident) => (
|
||||
<IncidentCard key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
dashboard/src/components/MitreHeatmap.jsx
Normal file
128
dashboard/src/components/MitreHeatmap.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const TACTIC_ORDER = [
|
||||
'Reconnaissance',
|
||||
'Resource Dev',
|
||||
'Initial Access',
|
||||
'Execution',
|
||||
'Persistence',
|
||||
'Priv Escalation',
|
||||
'Defense Evasion',
|
||||
'Credential Access',
|
||||
'Discovery',
|
||||
'Lateral Movement',
|
||||
'Collection',
|
||||
'C2',
|
||||
'Exfiltration',
|
||||
'Impact',
|
||||
];
|
||||
|
||||
const CELL_STYLES = [
|
||||
'bg-white/5', // 0 — not seen
|
||||
'bg-ap-blue/40', // 1 — low
|
||||
'bg-ap-yellow/50', // 2 — medium
|
||||
'bg-ap-red/70', // 3 — high
|
||||
];
|
||||
|
||||
const LEGEND = [
|
||||
{ label: 'None', style: 'bg-white/5' },
|
||||
{ label: 'Low', style: 'bg-ap-blue/40' },
|
||||
{ label: 'Med', style: 'bg-ap-yellow/50' },
|
||||
{ label: 'High', style: 'bg-ap-red/70' },
|
||||
];
|
||||
|
||||
function HeatCell({ technique, value }) {
|
||||
const bg = CELL_STYLES[value] ?? CELL_STYLES[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-7 h-7 ${bg} border border-white/5 transition-colors hover:border-ap-silver-dim cursor-default`}
|
||||
title={`${technique} (${value})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MitreHeatmap({ data = {} }) {
|
||||
// Build a stable ordered structure from the data prop
|
||||
const { tactics, maxRows } = useMemo(() => {
|
||||
const ordered = TACTIC_ORDER
|
||||
.filter((t) => data[t] != null)
|
||||
.map((tactic) => ({
|
||||
tactic,
|
||||
techniques: Object.entries(data[tactic]).map(([name, value]) => ({
|
||||
name,
|
||||
value: Math.max(0, Math.min(3, Number(value) || 0)),
|
||||
})),
|
||||
}));
|
||||
|
||||
const max = ordered.reduce(
|
||||
(m, t) => Math.max(m, t.techniques.length),
|
||||
0,
|
||||
);
|
||||
|
||||
return { tactics: ordered, maxRows: max };
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Panel header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>MITRE ATT&CK Coverage</span>
|
||||
</div>
|
||||
|
||||
{/* Scrollable heatmap area */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-3">
|
||||
{tactics.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center font-mono text-xs text-ap-silver-dim">
|
||||
No MITRE data
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex gap-px">
|
||||
{tactics.map(({ tactic, techniques }) => (
|
||||
<div key={tactic} className="flex flex-col items-center gap-px">
|
||||
{/* Tactic header — vertical text */}
|
||||
<div className="h-24 flex items-end justify-center pb-1">
|
||||
<span
|
||||
className="font-mono text-[9px] text-ap-silver-dim uppercase tracking-wide whitespace-nowrap select-none"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
transform: 'rotate(180deg)',
|
||||
}}
|
||||
>
|
||||
{tactic}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Technique cells */}
|
||||
{techniques.map(({ name, value }) => (
|
||||
<HeatCell key={name} technique={name} value={value} />
|
||||
))}
|
||||
|
||||
{/* Pad empty rows so columns align */}
|
||||
{Array.from(
|
||||
{ length: maxRows - techniques.length },
|
||||
(_, i) => (
|
||||
<div key={`pad-${i}`} className="w-7 h-7" />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 px-3 py-2 border-t border-white/10">
|
||||
{LEGEND.map(({ label, style }) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-3 h-3 ${style} border border-white/10`} />
|
||||
<span className="font-mono text-[9px] text-ap-silver-dim uppercase select-none">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
dashboard/src/components/NetworkTraffic.jsx
Normal file
112
dashboard/src/components/NetworkTraffic.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
const AREA_CONFIG = [
|
||||
{ dataKey: 'inbound', stroke: '#0056B3', fill: '#0056B344', name: 'Inbound' },
|
||||
{ dataKey: 'outbound', stroke: '#E0E0E2', fill: '#E0E0E222', name: 'Outbound' },
|
||||
{ dataKey: 'blocked', stroke: '#D72638', fill: '#D7263844', name: 'Blocked' },
|
||||
];
|
||||
|
||||
const TICK_STYLE = {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.75rem',
|
||||
fill: '#E0E0E266',
|
||||
};
|
||||
|
||||
function CustomTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#1B1B1E',
|
||||
border: '1px solid #E0E0E220',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.75rem',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: '#E0E0E266', marginBottom: 4 }}>{label}</p>
|
||||
{payload.map((entry) => (
|
||||
<p key={entry.dataKey} style={{ color: entry.stroke }}>
|
||||
{entry.name}: {entry.value} Mbps
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkTraffic({ data }) {
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>Network Traffic</span>
|
||||
<span
|
||||
className="ml-auto font-mono text-[10px] tracking-widest px-1.5 py-0.5 border border-ap-red/30 text-ap-red"
|
||||
>
|
||||
24H
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1 min-h-0 p-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="#E0E0E210" strokeDasharray="3 3" />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={TICK_STYLE}
|
||||
axisLine={{ stroke: '#E0E0E210' }}
|
||||
tickLine={{ stroke: '#E0E0E210' }}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
tick={TICK_STYLE}
|
||||
unit=" Mbps"
|
||||
axisLine={{ stroke: '#E0E0E210' }}
|
||||
tickLine={{ stroke: '#E0E0E210' }}
|
||||
width={72}
|
||||
/>
|
||||
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ stroke: '#E0E0E220' }} />
|
||||
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
iconType="rect"
|
||||
wrapperStyle={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.75rem',
|
||||
paddingTop: 8,
|
||||
}}
|
||||
/>
|
||||
|
||||
{AREA_CONFIG.map(({ dataKey, stroke, fill, name }) => (
|
||||
<Area
|
||||
key={dataKey}
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={stroke}
|
||||
fill={fill}
|
||||
strokeWidth={1.5}
|
||||
name={name}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0, fill: stroke }}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
dashboard/src/components/SystemHealth.jsx
Normal file
78
dashboard/src/components/SystemHealth.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
const STATUS_COLOR = {
|
||||
online: 'bg-ap-green',
|
||||
warning: 'bg-ap-yellow',
|
||||
offline: 'bg-ap-red',
|
||||
};
|
||||
|
||||
function barColor(pct) {
|
||||
if (pct > 80) return 'bg-ap-red';
|
||||
if (pct >= 50) return 'bg-ap-yellow';
|
||||
return 'bg-ap-green';
|
||||
}
|
||||
|
||||
function MetricBar({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-ap-silver-dim w-7 shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-1 bg-white/10">
|
||||
<div
|
||||
className={`h-full ${barColor(value)}`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-ap-silver-dim w-8 text-right shrink-0">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemCard({ system }) {
|
||||
const { name, status, cpu, mem, uptime } = system;
|
||||
|
||||
return (
|
||||
<div className="bg-ap-dark p-2.5 border border-white/5">
|
||||
{/* Name + status dot */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="font-mono text-xs font-semibold truncate text-ap-silver">
|
||||
{name}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 shrink-0 ${STATUS_COLOR[status] ?? 'bg-ap-silver-dim'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<MetricBar label="CPU" value={cpu} />
|
||||
<MetricBar label="MEM" value={mem} />
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<p className="font-mono text-[10px] text-ap-silver-dim mt-2">
|
||||
UP: {uptime}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemHealth({ systems }) {
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>System Health</span>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 p-3 flex-1 content-start">
|
||||
{systems.map((system) => (
|
||||
<SystemCard key={system.name} system={system} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
dashboard/src/components/ThreatFeed.jsx
Normal file
129
dashboard/src/components/ThreatFeed.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
const SEVERITY_COLOR = {
|
||||
critical: 'bg-ap-red',
|
||||
high: 'bg-ap-orange',
|
||||
medium: 'bg-ap-yellow',
|
||||
low: 'bg-ap-blue',
|
||||
info: 'bg-ap-silver-dim',
|
||||
};
|
||||
|
||||
const SEVERITY_TEXT = {
|
||||
critical: 'text-ap-red',
|
||||
high: 'text-ap-orange',
|
||||
medium: 'text-ap-yellow',
|
||||
low: 'text-ap-blue',
|
||||
info: 'text-ap-silver-dim',
|
||||
};
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '--:--:--';
|
||||
const d = ts instanceof Date ? ts : new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return '--:--:--';
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
function SeverityDot({ severity }) {
|
||||
const bg = SEVERITY_COLOR[severity] || SEVERITY_COLOR.info;
|
||||
return <span className={`inline-block w-1.5 h-1.5 ${bg} shrink-0`} />;
|
||||
}
|
||||
|
||||
function BlockedBadge({ blocked }) {
|
||||
if (blocked) {
|
||||
return (
|
||||
<span className="px-1.5 py-0.5 text-[9px] font-mono font-bold uppercase bg-ap-green/20 text-ap-green select-none">
|
||||
Blocked
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="px-1.5 py-0.5 text-[9px] font-mono font-bold uppercase bg-ap-red/20 text-ap-red select-none">
|
||||
Allowed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertRow({ alert }) {
|
||||
const {
|
||||
timestamp,
|
||||
severity = 'info',
|
||||
attackType,
|
||||
sourceIP,
|
||||
targetIP,
|
||||
source,
|
||||
country,
|
||||
port,
|
||||
blocked,
|
||||
} = alert;
|
||||
|
||||
const sevText = SEVERITY_TEXT[severity] || SEVERITY_TEXT.info;
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 border-b border-white/5 hover:bg-white/5 transition-colors cursor-default">
|
||||
{/* Top line */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SeverityDot severity={severity} />
|
||||
<span className={`font-mono text-xs font-semibold ${sevText}`}>
|
||||
{severity.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ap-silver truncate">
|
||||
{attackType}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[11px] text-ap-silver-dim tabular-nums shrink-0">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom line */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="font-mono text-[11px] text-ap-silver-dim truncate">
|
||||
{sourceIP}
|
||||
{port != null ? `:${port}` : ''}
|
||||
<span className="text-ap-silver-dim/50 mx-1">{'->'}</span>
|
||||
{targetIP}
|
||||
</span>
|
||||
{country && (
|
||||
<span className="font-mono text-[9px] text-ap-silver-dim uppercase shrink-0">
|
||||
[{country}]
|
||||
</span>
|
||||
)}
|
||||
{source && (
|
||||
<span className="font-mono text-[10px] text-ap-silver-dim/70 truncate">
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0">
|
||||
<BlockedBadge blocked={blocked} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThreatFeed({ alerts = [] }) {
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Panel header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>Threat Feed</span>
|
||||
<span className="ml-auto px-1.5 py-0.5 text-[9px] font-mono font-bold tabular-nums bg-ap-red-dim text-ap-red select-none">
|
||||
{alerts.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scrollable alert list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{alerts.length === 0 && (
|
||||
<div className="px-3 py-6 text-center font-mono text-xs text-ap-silver-dim">
|
||||
No alerts
|
||||
</div>
|
||||
)}
|
||||
{alerts.map((alert) => (
|
||||
<AlertRow key={alert.id} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
dashboard/src/components/TopThreats.jsx
Normal file
115
dashboard/src/components/TopThreats.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
function CountCell({ count }) {
|
||||
let color = 'text-ap-silver';
|
||||
if (count > 200) color = 'text-ap-red font-bold';
|
||||
else if (count > 100) color = 'text-ap-orange font-bold';
|
||||
|
||||
return (
|
||||
<td className="px-3 py-2 border-b border-white/5">
|
||||
<span className={`font-mono text-xs tabular-nums ${color}`}>
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ blocked }) {
|
||||
if (blocked) {
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 font-mono font-bold uppercase bg-ap-green/20 text-ap-green select-none">
|
||||
Blocked
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 font-mono font-bold uppercase bg-ap-red/20 text-ap-red select-none">
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopThreats({ threats = [] }) {
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Panel header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>Top Threat Sources</span>
|
||||
</div>
|
||||
|
||||
{/* Table area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{threats.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center font-mono text-xs text-ap-silver-dim">
|
||||
No threat data
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full font-mono text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 text-ap-silver-dim text-[10px] uppercase tracking-wider border-b border-white/10 bg-ap-dark sticky top-0">
|
||||
IP
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 text-ap-silver-dim text-[10px] uppercase tracking-wider border-b border-white/10 bg-ap-dark sticky top-0">
|
||||
Country
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 text-ap-silver-dim text-[10px] uppercase tracking-wider border-b border-white/10 bg-ap-dark sticky top-0">
|
||||
Attack Type
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 text-ap-silver-dim text-[10px] uppercase tracking-wider border-b border-white/10 bg-ap-dark sticky top-0">
|
||||
Count
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 text-ap-silver-dim text-[10px] uppercase tracking-wider border-b border-white/10 bg-ap-dark sticky top-0">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{threats.map((threat) => (
|
||||
<tr
|
||||
key={threat.ip}
|
||||
className="hover:bg-white/5 transition-colors cursor-default"
|
||||
>
|
||||
{/* IP */}
|
||||
<td className="px-3 py-2 border-b border-white/5">
|
||||
<span className="text-ap-silver font-medium whitespace-nowrap">
|
||||
{threat.ip}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Country */}
|
||||
<td className="px-3 py-2 border-b border-white/5">
|
||||
<span
|
||||
className="text-ap-silver-dim uppercase"
|
||||
title={threat.countryName}
|
||||
>
|
||||
{threat.country}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Attack Type */}
|
||||
<td className="px-3 py-2 border-b border-white/5">
|
||||
<span
|
||||
className="block max-w-[140px] truncate text-ap-silver"
|
||||
title={threat.attackType}
|
||||
>
|
||||
{threat.attackType}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Count */}
|
||||
<CountCell count={threat.count} />
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-3 py-2 border-b border-white/5">
|
||||
<StatusBadge blocked={threat.blocked} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
dashboard/src/components/VulnSummary.jsx
Normal file
110
dashboard/src/components/VulnSummary.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
const SEVERITY_CONFIG = [
|
||||
{ key: 'critical', label: 'Critical', color: '#D72638' },
|
||||
{ key: 'high', label: 'High', color: '#FF6D00' },
|
||||
{ key: 'medium', label: 'Medium', color: '#FFD600' },
|
||||
{ key: 'low', label: 'Low', color: '#0056B3' },
|
||||
{ key: 'info', label: 'Info', color: '#E0E0E244' },
|
||||
];
|
||||
|
||||
function CustomTooltip({ active, payload }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const { name, value } = payload[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#1B1B1E',
|
||||
border: '1px solid #E0E0E220',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.75rem',
|
||||
padding: '6px 10px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: payload[0].payload.fill }}>{name}</span>
|
||||
<span style={{ color: '#E0E0E2', marginLeft: 8 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VulnSummary({ data = {} }) {
|
||||
const {
|
||||
critical = 0,
|
||||
high = 0,
|
||||
medium = 0,
|
||||
low = 0,
|
||||
info = 0,
|
||||
} = data;
|
||||
|
||||
const total = critical + high + medium + low + info;
|
||||
|
||||
const chartData = SEVERITY_CONFIG.map(({ key, label, color }) => ({
|
||||
name: label,
|
||||
value: data[key] || 0,
|
||||
fill: color,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="panel h-full flex flex-col">
|
||||
{/* Panel header */}
|
||||
<div className="panel-header">
|
||||
<span className="dot" />
|
||||
<span>Vulnerability Summary</span>
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="relative flex-shrink-0" style={{ height: 180 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={75}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
paddingAngle={1}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={SEVERITY_CONFIG[index].key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Center label */}
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"
|
||||
>
|
||||
<span className="font-mono text-2xl font-bold text-ap-silver tabular-nums leading-none">
|
||||
{total}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-ap-silver-dim uppercase tracking-widest mt-1">
|
||||
Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 px-4 pb-4 pt-2">
|
||||
{SEVERITY_CONFIG.map(({ key, label, color }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-2 h-2 shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="font-mono text-xs text-ap-silver-dim">
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-xs font-bold text-ap-silver tabular-nums">
|
||||
{data[key] || 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
dashboard/src/data/mockData.js
Normal file
236
dashboard/src/data/mockData.js
Normal file
@@ -0,0 +1,236 @@
|
||||
// =============================================================================
|
||||
// APOPHIS SOC - Mock Data Generators
|
||||
// Generates realistic-looking security operations data for the dashboard
|
||||
// =============================================================================
|
||||
|
||||
const ATTACK_TYPES = [
|
||||
'SSH Brute Force', 'SQL Injection', 'XSS Attempt', 'Port Scan',
|
||||
'DNS Tunneling', 'C2 Beacon', 'Credential Stuffing', 'DDoS SYN Flood',
|
||||
'Malware Download', 'Privilege Escalation', 'Lateral Movement',
|
||||
'Data Exfiltration', 'Phishing Link Click', 'RDP Brute Force',
|
||||
'SMB Exploit', 'ARP Spoofing', 'ICMP Flood', 'Directory Traversal',
|
||||
'Buffer Overflow', 'Zero-Day Exploit'
|
||||
];
|
||||
|
||||
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
const SEVERITY_WEIGHTS = [0.05, 0.15, 0.35, 0.30, 0.15];
|
||||
|
||||
const SOURCES = [
|
||||
'Suricata IDS', 'pfSense Firewall', 'Wazuh SIEM', 'ClamAV',
|
||||
'Zeek Network Monitor', 'OSSEC HIDS', 'Fail2Ban', 'Snort IPS',
|
||||
'ModSecurity WAF', 'OpenVAS Scanner'
|
||||
];
|
||||
|
||||
const COUNTRIES = [
|
||||
{ code: 'CN', name: 'China', weight: 0.20 },
|
||||
{ code: 'RU', name: 'Russia', weight: 0.18 },
|
||||
{ code: 'US', name: 'United States', weight: 0.12 },
|
||||
{ code: 'KP', name: 'North Korea', weight: 0.08 },
|
||||
{ code: 'IR', name: 'Iran', weight: 0.07 },
|
||||
{ code: 'BR', name: 'Brazil', weight: 0.07 },
|
||||
{ code: 'IN', name: 'India', weight: 0.06 },
|
||||
{ code: 'DE', name: 'Germany', weight: 0.05 },
|
||||
{ code: 'NL', name: 'Netherlands', weight: 0.05 },
|
||||
{ code: 'RO', name: 'Romania', weight: 0.04 },
|
||||
{ code: 'UA', name: 'Ukraine', weight: 0.04 },
|
||||
{ code: 'VN', name: 'Vietnam', weight: 0.04 },
|
||||
];
|
||||
|
||||
const MITRE_TACTICS = [
|
||||
'Reconnaissance', 'Resource Dev', 'Initial Access', 'Execution',
|
||||
'Persistence', 'Priv Escalation', 'Defense Evasion', 'Credential Access',
|
||||
'Discovery', 'Lateral Movement', 'Collection', 'C2', 'Exfiltration', 'Impact'
|
||||
];
|
||||
|
||||
const MITRE_TECHNIQUES = {
|
||||
'Reconnaissance': ['Active Scanning', 'Search Open Websites', 'Gather Victim Info', 'Phishing for Info'],
|
||||
'Resource Dev': ['Acquire Infrastructure', 'Develop Capabilities', 'Stage Capabilities', 'Compromise Accounts'],
|
||||
'Initial Access': ['Phishing', 'Exploit Public App', 'Valid Accounts', 'Drive-by Compromise'],
|
||||
'Execution': ['Command & Script', 'Scheduled Task', 'User Execution', 'WMI'],
|
||||
'Persistence': ['Boot Autostart', 'Create Account', 'Scheduled Task', 'Server Software'],
|
||||
'Priv Escalation': ['Abuse Elevation', 'Access Token', 'Boot Autostart', 'Exploitation'],
|
||||
'Defense Evasion': ['Obfuscation', 'Masquerading', 'Rootkit', 'Process Injection'],
|
||||
'Credential Access': ['Brute Force', 'OS Credential Dump', 'Keylogging', 'Network Sniffing'],
|
||||
'Discovery': ['Account Discovery', 'Network Scan', 'System Info', 'Process Discovery'],
|
||||
'Lateral Movement': ['Remote Services', 'SMB/Admin Share', 'Pass the Hash', 'RDP'],
|
||||
'Collection': ['Data from Local', 'Screen Capture', 'Clipboard Data', 'Email Collection'],
|
||||
'C2': ['Application Layer', 'Encrypted Channel', 'Proxy', 'Web Service'],
|
||||
'Exfiltration': ['Exfil Over C2', 'Exfil Over Web', 'Automated Exfil', 'Transfer Size Limits'],
|
||||
'Impact': ['Data Destruction', 'Data Encrypted', 'Service Stop', 'Defacement'],
|
||||
};
|
||||
|
||||
// --- Utility functions ---
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function randomFloat(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
function randomChoice(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function weightedChoice(items, weights) {
|
||||
const r = Math.random();
|
||||
let sum = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
sum += weights[i];
|
||||
if (r <= sum) return items[i];
|
||||
}
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
function randomIP() {
|
||||
// Generate realistic-looking external IPs (avoiding private ranges)
|
||||
const firstOctet = randomChoice([1, 2, 5, 14, 23, 31, 37, 41, 45, 46, 49, 58, 59, 60, 61, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 91, 92, 93, 94, 95, 103, 104, 106, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223]);
|
||||
return `${firstOctet}.${randomInt(1, 254)}.${randomInt(1, 254)}.${randomInt(1, 254)}`;
|
||||
}
|
||||
|
||||
let alertIdCounter = 1000;
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function generateAlert() {
|
||||
const severity = weightedChoice(SEVERITIES, SEVERITY_WEIGHTS);
|
||||
const country = weightedChoice(
|
||||
COUNTRIES.map(c => c),
|
||||
COUNTRIES.map(c => c.weight)
|
||||
);
|
||||
return {
|
||||
id: `APH-${String(alertIdCounter++).padStart(5, '0')}`,
|
||||
timestamp: new Date(),
|
||||
severity,
|
||||
attackType: randomChoice(ATTACK_TYPES),
|
||||
sourceIP: randomIP(),
|
||||
targetIP: `10.0.${randomInt(1, 5)}.${randomInt(1, 254)}`,
|
||||
source: randomChoice(SOURCES),
|
||||
country: country.code,
|
||||
countryName: country.name,
|
||||
port: randomChoice([22, 80, 443, 445, 3389, 8080, 8443, 3306, 5432, 53, 25, 110, 143, 993, 995]),
|
||||
blocked: Math.random() > 0.3,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateAlertBatch(count = 20) {
|
||||
const alerts = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const alert = generateAlert();
|
||||
alert.timestamp = new Date(Date.now() - randomInt(0, 3600000));
|
||||
alerts.push(alert);
|
||||
}
|
||||
return alerts.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
export function generateTrafficData(points = 24) {
|
||||
const data = [];
|
||||
const now = new Date();
|
||||
for (let i = points - 1; i >= 0; i--) {
|
||||
const time = new Date(now.getTime() - i * 3600000);
|
||||
const hour = time.getHours();
|
||||
// Simulate higher traffic during business hours
|
||||
const multiplier = (hour >= 8 && hour <= 18) ? 1.5 : (hour >= 0 && hour <= 5) ? 0.4 : 1.0;
|
||||
data.push({
|
||||
time: time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
inbound: Math.round(randomFloat(120, 450) * multiplier),
|
||||
outbound: Math.round(randomFloat(80, 320) * multiplier),
|
||||
blocked: Math.round(randomFloat(5, 60) * multiplier),
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function generateSystemHealth() {
|
||||
return [
|
||||
{ name: 'pfSense Firewall', status: Math.random() > 0.05 ? 'online' : 'warning', cpu: randomInt(8, 45), mem: randomInt(30, 70), uptime: `${randomInt(10, 90)}d` },
|
||||
{ name: 'Suricata IDS', status: Math.random() > 0.08 ? 'online' : 'warning', cpu: randomInt(15, 65), mem: randomInt(40, 80), uptime: `${randomInt(5, 60)}d` },
|
||||
{ name: 'Wazuh SIEM', status: Math.random() > 0.05 ? 'online' : 'warning', cpu: randomInt(20, 55), mem: randomInt(50, 85), uptime: `${randomInt(3, 45)}d` },
|
||||
{ name: 'ClamAV Scanner', status: Math.random() > 0.10 ? 'online' : 'offline', cpu: randomInt(5, 30), mem: randomInt(20, 50), uptime: `${randomInt(1, 30)}d` },
|
||||
{ name: 'Zeek Monitor', status: Math.random() > 0.05 ? 'online' : 'warning', cpu: randomInt(10, 50), mem: randomInt(35, 75), uptime: `${randomInt(7, 60)}d` },
|
||||
{ name: 'OpenVAS', status: Math.random() > 0.12 ? 'online' : 'offline', cpu: randomInt(5, 25), mem: randomInt(15, 45), uptime: `${randomInt(2, 20)}d` },
|
||||
];
|
||||
}
|
||||
|
||||
export function generateMitreData() {
|
||||
const data = {};
|
||||
for (const tactic of MITRE_TACTICS) {
|
||||
data[tactic] = {};
|
||||
for (const technique of MITRE_TECHNIQUES[tactic]) {
|
||||
// 0 = not seen, 1 = low, 2 = medium, 3 = high detection count
|
||||
data[tactic][technique] = Math.random() > 0.4 ? randomInt(0, 3) : 0;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function generateTopThreats(count = 8) {
|
||||
const threats = [];
|
||||
const usedIPs = new Set();
|
||||
for (let i = 0; i < count; i++) {
|
||||
let ip;
|
||||
do { ip = randomIP(); } while (usedIPs.has(ip));
|
||||
usedIPs.add(ip);
|
||||
const country = weightedChoice(
|
||||
COUNTRIES.map(c => c),
|
||||
COUNTRIES.map(c => c.weight)
|
||||
);
|
||||
threats.push({
|
||||
ip,
|
||||
country: country.code,
|
||||
countryName: country.name,
|
||||
attackType: randomChoice(ATTACK_TYPES),
|
||||
count: randomInt(15, 500),
|
||||
lastSeen: new Date(Date.now() - randomInt(0, 7200000)),
|
||||
blocked: Math.random() > 0.2,
|
||||
});
|
||||
}
|
||||
return threats.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export function generateIncidents() {
|
||||
const statuses = ['Open', 'Investigating', 'Contained', 'Resolved'];
|
||||
const priorities = ['P1', 'P2', 'P3', 'P4'];
|
||||
const descriptions = [
|
||||
'Suspicious outbound C2 traffic detected on endpoint WS-04',
|
||||
'Multiple failed SSH logins from external IP on DMZ server',
|
||||
'Malware signature detected in email attachment',
|
||||
'Unauthorized port scan from internal host 10.0.2.15',
|
||||
'Privilege escalation attempt on domain controller',
|
||||
'Data exfiltration alert: large DNS TXT queries',
|
||||
'Brute force attack on VPN gateway',
|
||||
'Phishing campaign targeting finance department',
|
||||
'Anomalous lateral movement between VLANs',
|
||||
'Ransomware indicator detected on file server',
|
||||
];
|
||||
return descriptions.slice(0, randomInt(4, 8)).map((desc, i) => ({
|
||||
id: `INC-${String(2024001 + i).padStart(7, '0')}`,
|
||||
description: desc,
|
||||
status: i === 0 ? 'Open' : randomChoice(statuses),
|
||||
priority: i < 2 ? priorities[i] : randomChoice(priorities),
|
||||
assignee: randomChoice(['analyst-1', 'analyst-2', 'lead-soc', 'ir-team']),
|
||||
created: new Date(Date.now() - randomInt(3600000, 86400000 * 3)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateVulnSummary() {
|
||||
return {
|
||||
critical: randomInt(2, 8),
|
||||
high: randomInt(10, 35),
|
||||
medium: randomInt(25, 80),
|
||||
low: randomInt(40, 120),
|
||||
info: randomInt(50, 200),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOverallThreatLevel(alerts) {
|
||||
const critCount = alerts.filter(a => a.severity === 'critical').length;
|
||||
const highCount = alerts.filter(a => a.severity === 'high').length;
|
||||
if (critCount >= 3) return 'CRITICAL';
|
||||
if (critCount >= 1 || highCount >= 5) return 'HIGH';
|
||||
if (highCount >= 2) return 'ELEVATED';
|
||||
return 'GUARDED';
|
||||
}
|
||||
|
||||
export { MITRE_TACTICS, MITRE_TECHNIQUES, SEVERITIES };
|
||||
89
dashboard/src/index.css
Normal file
89
dashboard/src/index.css
Normal file
@@ -0,0 +1,89 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ap-red: #D72638;
|
||||
--color-ap-red-dim: #D7263844;
|
||||
--color-ap-black: #1B1B1E;
|
||||
--color-ap-dark: #121214;
|
||||
--color-ap-panel: #222226;
|
||||
--color-ap-silver: #E0E0E2;
|
||||
--color-ap-silver-dim: #E0E0E266;
|
||||
--color-ap-blue: #0056B3;
|
||||
--color-ap-blue-dim: #0056B344;
|
||||
--color-ap-green: #00C853;
|
||||
--color-ap-yellow: #FFD600;
|
||||
--color-ap-orange: #FF6D00;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-ap-dark);
|
||||
color: var(--color-ap-silver);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-ap-black);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-ap-red);
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-pulse-red {
|
||||
animation: pulse-red 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: var(--color-ap-panel);
|
||||
border: 1px solid #E0E0E215;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #E0E0E288;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid #E0E0E210;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-header .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--color-ap-red);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.severity-critical { color: #D72638; }
|
||||
.severity-high { color: #FF6D00; }
|
||||
.severity-medium { color: #FFD600; }
|
||||
.severity-low { color: #0056B3; }
|
||||
.severity-info { color: #E0E0E266; }
|
||||
10
dashboard/src/main.jsx
Normal file
10
dashboard/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
7
dashboard/vite.config.js
Normal file
7
dashboard/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
Reference in New Issue
Block a user