Remove study count showing on Activity column. Collapse Navigation bar by default. Show Active and Idle for router activity. Fixed card view count. Fixed card view router row info as per correct status. Show only one recent study on Activity column. Show router details on click of router id to expanded row.

This commit is contained in:
shankar 2024-11-28 20:08:10 +05:30
parent 8630a3e2c5
commit dfa974fe6b
24 changed files with 586 additions and 322 deletions

25
db-scripts/00-init-db.sh Normal file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# Wait for MySQL to become available
echo "Waiting for MySQL to become healthy..."
until mysql -h "localhost" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SHOW DATABASES;" &>/dev/null; do
echo "Waiting for MySQL..."
sleep 2
done
echo "MySQL is up and running!"
# Run the initial common setup (always executed)
echo "Running 01-init.sql"
mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < /docker-entrypoint-initdb.d/01-init.sql
echo "Running 02-seed_common_data.sql"
mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < /docker-entrypoint-initdb.d/02-seed_common_data.sql
# Check the environment variable and execute environment-specific scripts
if [ "$ENVIRONMENT" != "production" ]; then
echo "Running 03-seed_router_data_qa.sql for QA (non-production)"
mysql -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < /docker-entrypoint-initdb.d/03-seed_router_data_qa.sql
else
echo "Production environment detected. Skipping seeding data for QA (non-production)."
fi

View File

@ -8,6 +8,9 @@ CREATE TABLE IF NOT EXISTS routers (
router_id VARCHAR(10) UNIQUE NOT NULL, -- Unique router identifier
facility VARCHAR(50) NOT NULL,
router_alias VARCHAR(50) NOT NULL,
facility_aet VARCHAR(50) NOT NULL,
openvpn_ip VARCHAR(15) NOT NULL,
router_vm_primary_ip VARCHAR(15) NOT NULL,
last_seen TIMESTAMP NOT NULL,
vpn_status_code VARCHAR(50) NOT NULL,
disk_status_code VARCHAR(50) NOT NULL,
@ -76,24 +79,24 @@ CREATE TABLE IF NOT EXISTS container_status (
);
-- DICOM study overview table with router_id as a string reference
CREATE TABLE IF NOT EXISTS dicom_study_overview (
id INT AUTO_INCREMENT PRIMARY KEY,
router_id VARCHAR(10) NOT NULL, -- Matching VARCHAR(10) with the routers table
study_instance_uid VARCHAR(100) UNIQUE NOT NULL,
patient_id VARCHAR(50) NOT NULL,
patient_name VARCHAR(100) NOT NULL,
accession_number VARCHAR(50) NOT NULL,
study_date DATE NOT NULL,
modality VARCHAR(20) NOT NULL,
study_description VARCHAR(255),
series_instance_uid VARCHAR(100) NOT NULL,
procedure_code VARCHAR(50),
referring_physician_name VARCHAR(100),
study_status_code VARCHAR(50) NOT NULL DEFAULT 'NEW', -- Default value, ensure 'NEW' exists in status_type
association_id VARCHAR(50) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS dicom_study_overview (
id INT AUTO_INCREMENT PRIMARY KEY,
router_id VARCHAR(10) NOT NULL, -- Matching VARCHAR(10) with the routers table
study_instance_uid VARCHAR(100) UNIQUE NOT NULL,
patient_id VARCHAR(50) NOT NULL,
patient_name VARCHAR(100) NOT NULL,
accession_number VARCHAR(50) NOT NULL,
study_date DATE NOT NULL,
modality VARCHAR(20) NOT NULL,
study_description VARCHAR(255),
series_instance_uid VARCHAR(100) NOT NULL,
procedure_code VARCHAR(50),
referring_physician_name VARCHAR(100),
study_status_code VARCHAR(50) NOT NULL DEFAULT 'NEW', -- Default value, ensure 'NEW' exists in status_type
association_id VARCHAR(50) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Create tables if they don't exist
CREATE TABLE IF NOT EXISTS status_type (

View File

@ -0,0 +1,34 @@
-- Check if the procedure exists, and create it only if it does not
DROP PROCEDURE IF EXISTS `seed_common_router_data`;
DELIMITER //
CREATE PROCEDURE seed_common_router_data()
BEGIN
-- Insert Status Categories
INSERT INTO status_category (name, description)
VALUES
('Network', 'Network related statuses'),
('Disk', 'Disk related statuses'),
('VPN', 'VPN connection statuses'),
('License', 'License statuses'),
('Container', 'Container related statuses')
ON DUPLICATE KEY UPDATE id = id;
-- Insert Status Types
INSERT INTO status_type (category_id, name, code, description, severity)
VALUES
(1, 'Online', 'NET_ONLINE', 'System is online', 1),
(1, 'Offline', 'NET_OFFLINE', 'System is offline', 5),
(2, 'Normal', 'DISK_NORMAL', 'Disk usage is normal', 1),
(2, 'Warning', 'DISK_WARNING', 'Disk usage is high', 3),
(2, 'Critical', 'DISK_CRITICAL', 'Disk usage is critical', 5),
(3, 'Connected', 'VPN_CONNECTED', 'VPN is connected', 1),
(3, 'Disconnected', 'VPN_DISCONNECTED', 'VPN is disconnected', 5),
(5, 'Running', 'CONTAINER_RUNNING', 'Container is running', 1),
(5, 'Stopped', 'CONTAINER_STOPPED', 'Container is stopped', 5)
ON DUPLICATE KEY UPDATE id = id;
END //
DELIMITER ;

View File

@ -0,0 +1,49 @@
-- Check if the procedure exists, and create it only if it does not
DROP PROCEDURE IF EXISTS `seed_router_data`;
DELIMITER //
CREATE PROCEDURE seed_router_data()
BEGIN
-- Insert Routers
INSERT INTO routers (router_id, facility, router_alias, facility_aet, openvpn_ip, router_vm_primary_ip,
last_seen, vpn_status_code, disk_status_code, app_status_code, license_status, free_disk, total_disk, disk_usage)
VALUES
('RTR001', 'Main Hospital', 'MAIN_RAD', 'RTR_1', '10.8.0.101', '192.168.1.101', NOW(), 'VPN_CONNECTED', 'DISK_NORMAL', 'CONTAINER_RUNNING', 'active', 500000000000, 1000000000000, 50.00),
('RTR002', 'Emergency Center', 'ER_RAD', 'RTR_2', '10.8.0.102', '192.168.1.102', NOW(), 'VPN_DISCONNECTED', 'DISK_WARNING', 'CONTAINER_RUNNING', 'active', 400000000000, 1000000000000, 60.00),
('RTR003', 'Imaging Center', 'IMG_CENTER', 'RTR_3', '10.8.0.103', '192.168.1.103', NOW(), 'VPN_CONNECTED', 'DISK_NORMAL', 'CONTAINER_RUNNING', 'active', 600000000000, 1000000000000, 40.00)
ON DUPLICATE KEY UPDATE id = id;
-- Store Router IDs
SET @router1_id = (SELECT id FROM routers WHERE router_id = 'RTR001');
SET @router2_id = (SELECT id FROM routers WHERE router_id = 'RTR002');
SET @router3_id = (SELECT id FROM routers WHERE router_id = 'RTR003');
-- Insert Container Status
INSERT INTO container_status (router_id, container_name, status_code, created_at, updated_at)
VALUES
(@router1_id, 'router-cstore-scp', 'CONTAINER_RUNNING', NOW(), NOW()),
(@router1_id, 'router-cstore-scu', 'CONTAINER_RUNNING', NOW(), NOW()),
(@router2_id, 'router-cstore-scp', 'CONTAINER_RUNNING', NOW(), NOW()),
(@router2_id, 'router-cstore-scu', 'CONTAINER_RUNNING', NOW(), NOW()),
(@router3_id, 'router-cstore-scp', 'CONTAINER_RUNNING', NOW(), NOW())
ON DUPLICATE KEY UPDATE id = id;
-- Insert DICOM Study Overview
INSERT INTO dicom_study_overview (
router_id, study_instance_uid, patient_id, patient_name,
accession_number, study_date, modality, study_description,
series_instance_uid, procedure_code, referring_physician_name,
study_status_code
)
VALUES
(@router1_id, '1.2.840.113619.2.55.3.283116435.276.1543707218.134', 'P1', 'John Doe', 'ACC1234', '2024-03-15', 'CT', 'Chest CT', '1.2.840.113619.2.55.3.283116435.276.1543707219.135', 'CT001', 'Dr. Smith', 'idle'),
(@router2_id, '1.2.840.113619.2.55.3.283116435.276.1543707218.136', 'P2', 'Jane Doe', 'ACC1235', '2024-03-15', 'MR', 'Brain MRI', '1.2.840.113619.2.55.3.283116435.276.1543707219.137', 'MR001', 'Dr. Johnson', 'idle')
ON DUPLICATE KEY UPDATE id = id;
END //
DELIMITER ;
-- Automatically call the procedure after creation
CALL seed_router_data();

142
deploy.sh Normal file
View File

@ -0,0 +1,142 @@
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored messages
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# Function to check if command was successful
check_status() {
if [ $? -eq 0 ]; then
print_message "$GREEN" "✔ Success: $1"
else
print_message "$RED" "✘ Error: $1"
exit 1
fi
}
# Default values
ENV="production"
SERVER_IP=""
FRONTEND_IP=""
DB_HOST="mysql"
# Help message
show_help() {
echo "Usage: ./deploy.sh [OPTIONS]"
echo "Deploy the router dashboard application"
echo
echo "Options:"
echo " -s, --server-ip Server IP address (required)"
echo " -f, --frontend-ip Frontend IP address (defaults to server IP if not provided)"
echo " -d, --db-host Database host (defaults to mysql)"
echo " -e, --environment Environment (development/staging/production, defaults to production)"
echo " -h, --help Show this help message"
echo
echo "Example:"
echo " ./deploy.sh -s 192.168.1.100 -f 192.168.1.101 -e production"
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-s|--server-ip)
SERVER_IP="$2"
shift 2
;;
-f|--frontend-ip)
FRONTEND_IP="$2"
shift 2
;;
-d|--db-host)
DB_HOST="$2"
shift 2
;;
-e|--environment)
ENV="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
*)
print_message "$RED" "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Validate required parameters
if [ -z "$SERVER_IP" ]; then
print_message "$RED" "Error: Server IP is required"
show_help
exit 1
fi
# If frontend IP is not provided, use server IP
if [ -z "$FRONTEND_IP" ]; then
FRONTEND_IP=$SERVER_IP
print_message "$YELLOW" "Frontend IP not provided, using Server IP: $FRONTEND_IP"
fi
# Export environment variables
export SERVER_IP
export FRONTEND_IP
export DB_HOST
export NODE_ENV=$ENV
# Display deployment information
print_message "$GREEN" "\nDeployment Configuration:"
echo "Server IP: $SERVER_IP"
echo "Frontend IP: $FRONTEND_IP"
echo "Database Host: $DB_HOST"
echo "Environment: $ENV"
echo
# Confirm deployment
read -p "Do you want to continue with deployment? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_message "$YELLOW" "Deployment cancelled"
exit 1
fi
# Start deployment
print_message "$GREEN" "\nStarting deployment..."
# Pull latest changes
print_message "$YELLOW" "Pulling latest changes..."
git pull
check_status "Git pull"
# Stop existing containers
print_message "$YELLOW" "Stopping existing containers..."
docker-compose down
check_status "Stopping containers"
# Build containers
print_message "$YELLOW" "Building containers..."
docker-compose build
check_status "Building containers"
# Start containers
print_message "$YELLOW" "Starting containers..."
docker-compose up -d
check_status "Starting containers"
# Check container status
print_message "$YELLOW" "Checking container status..."
docker-compose ps
check_status "Container status check"
print_message "$GREEN" "\nDeployment completed successfully!"

View File

@ -5,61 +5,86 @@ services:
build:
context: ./router-dashboard
dockerfile: dockerfile
args:
- SERVER_IP=${SERVER_IP:-localhost}
ports:
- "5173:5173"
- "${FRONTEND_PORT:-5173}:5173"
environment:
- VITE_API_URL=${VITE_API_URL}
- SERVER_IP=${SERVER_IP:-localhost}
- FRONTEND_IP=${FRONTEND_IP:-localhost}
- VITE_API_URL=http://${SERVER_IP:-localhost}:${BACKEND_PORT:-3000}/api/v1
- VITE_NODE_ENV=${NODE_ENV:-development}
restart: always
depends_on:
backend:
condition: service_healthy
container_name: router_dashboard_frontend
networks:
- app_network
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://${SERVER_IP:-localhost}:5173"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
backend:
build:
context: ./ve-router-backend
dockerfile: dockerfile
ports:
- "3000:3000"
- "${BACKEND_PORT:-3000}:3000"
environment:
- NODE_ENV=${NODE_ENV}
- DB_HOST=host.docker.internal
- DB_PORT=3306
- DB_USER=ve_router_user
- DB_PASSWORD=ve_router_password
- DB_NAME=${DB_NAME}
- NODE_ENV=${NODE_ENV:-development}
- DB_HOST=${DB_HOST:-mysql}
- DB_PORT=${DB_PORT:-3306}
- DB_USER=${DB_USER:-ve_router_user}
- DB_PASSWORD=${DB_PASSWORD:-ve_router_password}
- DB_NAME=${DB_NAME:-ve_router_db}
- CORS_ORIGIN=http://${FRONTEND_IP:-localhost}:${FRONTEND_PORT:-5173},http://${SERVER_IP:-localhost}:${BACKEND_PORT:-3000}
restart: always
depends_on:
mysql:
condition: service_healthy
condition: service_healthy
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "3000"] # Netcat check to see if port 3000 is open
interval: 30s # Check every 30 seconds
retries: 3 # Retry 3 times before marking unhealthy
start_period: 30s # Wait 30 seconds before starting health checks
timeout: 10s # Wait for 10 seconds for each health check to respond
test: ["CMD", "nc", "-z", "${SERVER_IP:-localhost}", "${BACKEND_PORT:-3000}"]
interval: 30s
retries: 3
start_period: 30s
timeout: 10s
networks:
- app_network
container_name: router_dashboard_backend
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: ve_router_db
MYSQL_USER: ve_router_user
MYSQL_PASSWORD: ve_router_password
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${DB_NAME:-ve_router_db}
MYSQL_USER: ${DB_USER:-ve_router_user}
MYSQL_PASSWORD: ${DB_PASSWORD:-ve_router_password}
ENVIRONMENT: ${NODE_ENV:-development}
volumes:
- mysql_data:/var/lib/mysql
# Correct paths for init scripts
- ./sql:/docker-entrypoint-initdb.d
- ./db-scripts:/docker-entrypoint-initdb.d:ro
ports:
- "3306:3306"
- "${DB_PORT:-3306}:3306"
command: --default-authentication-plugin=mysql_native_password
restart: always
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "ve_router_user", "-pve_router_password"]
test: ["CMD", "mysqladmin", "ping", "-h", "${DB_HOST:-mysql}", "-u${DB_USER:-ve_router_user}", "-p${DB_PASSWORD:-ve_router_password}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- app_network
container_name: router_dashboard_mysql
networks:
app_network:
driver: bridge
volumes:
mysql_data:
name: router_dashboard_mysql_data

View File

@ -16,3 +16,23 @@ docker-compose down
7. Run below command to stop, remove and delete all the volumes
Caution : if mysql has volumes then all the existing data will be erasesd
docker-compose down -v
Deploy Instructions:
1. Basic usage with just server IP:
./deploy.sh -s 192.168.1.100
2.Specify different IPs for frontend and backend:
./deploy.sh -s 192.168.1.100 -f 192.168.1.101
3.Specify environment:
./deploy.sh -s 192.168.1.100 -e staging
4.Full configuration:
./deploy.sh -s 192.168.1.100 -f 192.168.1.101 -d mysql -e production
5.Run it with the help flag to see options:
./deploy.sh --help
Important notes:
In production, you should use HTTPS instead of HTTP
Make sure your firewall rules allow the necessary ports (3000, 5173, 3306)
Consider using a reverse proxy like Nginx in front of your services
The MySQL container is accessible to other containers through the service name "mysql" when using Docker networks

View File

@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:3000/api/v1
VITE_API_URL=http://${SERVER_IP:-localhost}:3000/api/v1
VITE_NODE_ENV=development

View File

@ -0,0 +1,2 @@
VITE_API_URL=http://${SERVER_IP}:3000/api/v1
VITE_NODE_ENV=production

View File

@ -36,11 +36,10 @@ const DashboardLayout: React.FC = () => {
data = data.filter(router => {
switch (activeFilter) {
case 'active':
return router.routerActivity?.studies &&
router.routerActivity.studies.length > 0;
return router.systemStatus.routerStatus === 'CONNECTED' &&
router.routerActivity?.studies?.some(study => study.studyStatusCode === 'Active');
case 'critical':
return router.systemStatus.vpnStatus === 'VPN_DISCONNECTED' ||
router.systemStatus.appStatus === 'DISK_CRITICAL' ||
return router.systemStatus.routerStatus === 'DISCONNECTED' ||
router.diskStatus === 'DISK_CRITICAL';
case 'diskAlert':
return router.diskUsage > 80;
@ -74,18 +73,19 @@ const DashboardLayout: React.FC = () => {
return {
total: routerData.length,
active: routerData.filter(r =>
r.routerActivity?.studies &&
r.routerActivity.studies.length > 0
r.systemStatus.routerStatus === 'CONNECTED' && // Router (VM, app, VPN) is up
r.routerActivity?.studies?.some(study => study.studyStatusCode === 'Active') // At least one study is active
).length,
critical: routerData.filter(r =>
r.systemStatus.vpnStatus === 'VPN_DISCONNECTED' ||
r.systemStatus.appStatus === 'DISK_CRITICAL' ||
r.diskStatus === 'DISK_CRITICAL'
r.systemStatus.routerStatus === 'DISCONNECTED' || // Router (VM, app, VPN) is down
r.diskStatus === 'DISK_CRITICAL' // Disk is critical
).length,
diskAlert: routerData.filter(r => r.diskUsage > 80).length
diskAlert: routerData.filter(r => r.diskUsage > 80).length // Disk usage alert
};
};
const renderContent = () => {
if (loading) {
return (
@ -128,6 +128,7 @@ const DashboardLayout: React.FC = () => {
activeFilter === 'active' ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => setActiveFilter('active')}
title="Study in transmit currently"
>
<h3 className="text-sm font-medium text-gray-500">Active Routers</h3>
<p className="text-2xl font-semibold text-green-600 mt-1">{summary.active}</p>

View File

@ -22,8 +22,8 @@ interface Tab {
}
const Navbar: React.FC<NavbarProps> = ({ activeTab, onTabChange }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isPinned, setIsPinned] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(true);
const [isPinned, setIsPinned] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const tabs: Tab[] = [

View File

@ -2,13 +2,13 @@
import React from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { RouterData } from '../../types';
import { STATUS_COLORS, formatStatus, getStatusColor, getVMStatus, getSystemStatus } from '../../utils/statusHelpers';
import { STATUS_COLORS, formatStatus, getStatusColor} from '../../utils/statusHelpers';
interface RouterTableRowProps {
router: RouterData;
expandedRows: Set<string>;
timeLeft: { [key: string]: number };
onToggleExpansion: (id: number, section: 'activity' | 'status' | 'disk') => void;
onToggleExpansion: (id: number, section: 'router_details' |'activity' | 'status' | 'disk') => void;
onExpandedContentHover: (id: number, section: 'activity' | 'status' | 'disk') => void;
}
@ -41,6 +41,32 @@ export const RouterTableRow: React.FC<RouterTableRowProps> = ({
return 'bg-green-500';
};
const renderRouterDetailsPanel = () => (
<div
className="bg-gray-50 p-4 relative"
onMouseEnter={() => onExpandedContentHover(router.id, 'router_details')}
>
<div className="h-1 bg-gray-200 absolute top-0 left-0 right-0">
<div
className="h-1 bg-blue-500 transition-all duration-200"
style={{ width: `${timeLeft[`${router.id}-router_details`] || 0}%` }}
/>
</div>
<h3 className="font-semibold mb-3">Router Details</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white p-3 rounded shadow-sm">
<div className="grid gap-2">
<div><span className="font-medium">Facility AET:</span> {router.facilityAET}</div>
<div><span className="font-medium">OpenVPN IP:</span> {router.openvpnIp}</div>
<div><span className="font-medium">Router VM Primary IP:</span> {router.routerVmPrimaryIp}</div>
</div>
</div>
</div>
</div>
);
const renderActivityPanel = () => (
<div
className="bg-gray-50 p-4 relative"
@ -90,8 +116,8 @@ export const RouterTableRow: React.FC<RouterTableRowProps> = ({
<div className="grid grid-cols-3 gap-4">
<div>
<h4 className="font-semibold mb-2">VM Status</h4>
<span className={`px-2 py-1 rounded-full text-sm ${getStatusColor(getVMStatus(router.lastSeen))}`}>
{formatStatus(getVMStatus(router.lastSeen))}
<span className={`px-2 py-1 rounded-full text-sm ${getStatusColor(router.systemStatus.vmStatus)}`}>
{formatStatus(router.systemStatus.vmStatus)}
</span>
</div>
<div>
@ -156,7 +182,19 @@ export const RouterTableRow: React.FC<RouterTableRowProps> = ({
<React.Fragment>
<tr className="border-b hover:bg-gray-50">
<td className="px-4 py-2">{router.slNo}</td>
<td className="px-4 py-2">{router.routerId}</td>
<td className="px-4 py-2">
<button
onClick={() => onToggleExpansion(router.id, 'router_details')}
className="flex items-center gap-1 text-blue-500 hover:text-blue-700"
>
{expandedRows.has(`${router.id}-router_details`) ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
<span>{router.routerId}</span>
</button>
</td>
<td className="px-4 py-2">{router.routerAlias}</td>
<td className="px-4 py-2">{router.facility}</td>
<td className="px-4 py-2">
@ -169,7 +207,8 @@ export const RouterTableRow: React.FC<RouterTableRowProps> = ({
) : (
<ChevronRight size={16} />
)}
View Activity ({router.routerActivity.studies.length} studies)
<span>{router?.routerActivity?.studies?.[0]?.studyStatusCode || "Idle"}</span>
</button>
</td>
<td className="px-4 py-2">
@ -182,8 +221,8 @@ export const RouterTableRow: React.FC<RouterTableRowProps> = ({
) : (
<ChevronRight size={16} />
)}
<span className={`ml-2 px-2 py-1 rounded-full text-sm ${getStatusColor(router.systemStatus.vpnStatus)}`}>
{formatStatus(router.systemStatus.vpnStatus)}
<span className={`ml-2 px-2 py-1 rounded-full text-sm ${getStatusColor(router.systemStatus.routerStatus)}`}>
{formatStatus(router.systemStatus.routerStatus)}
</span>
</button>
</td>
@ -212,6 +251,13 @@ export const RouterTableRow: React.FC<RouterTableRowProps> = ({
</tr>
{/* Expandable Panels */}
{expandedRows.has(`${router.id}-router_details`) && (
<tr>
<td colSpan={8}>
{renderRouterDetailsPanel()}
</td>
</tr>
)}
{expandedRows.has(`${router.id}-activity`) && (
<tr>
<td colSpan={8}>

View File

@ -1,12 +1,12 @@
// src/config/env.ts
interface Config {
apiUrl: string;
environment: string;
}
apiUrl: string;
environment: string;
}
const config: Config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
environment: import.meta.env.VITE_NODE_ENV || 'development',
};
const config: Config = {
apiUrl: import.meta.env.VITE_API_URL,
environment: import.meta.env.VITE_NODE_ENV || 'development',
};
export default config;
export default config;

View File

@ -21,6 +21,9 @@ export const MOCK_ROUTERS: RouterData[] = [
routerId: 'RTR001',
facility: 'City Hospital',
routerAlias: 'Main-Router-1',
facilityAET: 'RTR_1',
openvpnIp: '10.8.0.101',
routerVmPrimaryIp: '192.168.0.101',
lastSeen: '2024-03-07T14:30:00Z',
diskStatus: 'Normal',
diskUsage: 45,
@ -64,6 +67,9 @@ export const MOCK_ROUTERS: RouterData[] = [
routerId: 'RTR002',
facility: 'Medical Center',
routerAlias: 'Emergency-Router',
facilityAET: 'RTR_2',
openvpnIp: '10.8.0.102',
routerVmPrimaryIp: '192.168.0.102',
lastSeen: '2024-03-07T14:25:00Z',
diskStatus: 'Critical',
diskUsage: 92,
@ -97,6 +103,9 @@ export const MOCK_ROUTERS: RouterData[] = [
routerId: 'RTR003',
facility: 'Imaging Center',
routerAlias: 'Radiology-Router',
facilityAET: 'RTR_3',
openvpnIp: '10.8.0.103',
routerVmPrimaryIp: '192.168.0.103',
lastSeen: '2024-03-07T14:20:00Z',
diskStatus: 'Warning',
diskUsage: 78,

View File

@ -58,6 +58,9 @@ class ApiService {
routerId: router.routerId, // Changed from router.router_id
facility: router.facility,
routerAlias: router.routerAlias, // Changed from router.router_alias
facilityAET: router.facilityAET, // Changed from router.facility_aet
openvpnIp: router.openvpnIp, // Changed from router.openvpn_ip
routerVmPrimaryIp: router.routerVmPrimaryIp, // Changed from router.router_vm_primary_ip
lastSeen: router.lastSeen, // Changed from router.last_seen
diskStatus: router.diskStatus, // Changed from router.disk_status_code
diskUsage: router.diskUsage || 0, // Changed from router.disk_usage
@ -72,13 +75,17 @@ class ApiService {
patientName: study.patientName,
studyDate: study.studyDate,
modality: study.modality,
studyDescription: study.studyDescription
studyDescription: study.studyDescription,
studyStatusCode: study.studyStatusCode
}))
: []
},
systemStatus: {
vpnStatus: router.systemStatus?.vpnStatus || 'unknown',
appStatus: router.systemStatus?.appStatus || 'unknown',
vmStatus: router.systemStatus?.vmStatus || 'unknown',
routerStatus: router.systemStatus?.routerStatus || 'unknown',
vms: Array.isArray(router.systemStatus?.vms)
? router.systemStatus.vms.map((vm: any) => ({
id: vm.id,

View File

@ -7,6 +7,7 @@ export interface BackendStudy {
study_date: string;
modality: string;
study_description: string;
study_status_code: string;
}
export interface BackendVM {
@ -21,6 +22,9 @@ export interface BackendStudy {
router_id: string;
facility: string;
router_alias: string;
facility_aet: string;
openvpn_ip: string;
router_vm_primary_ip: string;
last_seen: string;
disk_status_code: string;
disk_usage: number;

View File

@ -7,6 +7,7 @@ export interface Study {
studyDate: string;
modality: string;
studyDescription: string;
studyStatusCode: string;
}
export interface VM {
@ -27,6 +28,9 @@ export interface RouterData {
routerId: string;
facility: string;
routerAlias: string;
facilityAET: string;
openvpnIp: string;
routerVmPrimaryIp: string;
lastSeen: string;
diskStatus: string;
diskUsage: number;
@ -38,6 +42,8 @@ export interface RouterData {
systemStatus: {
vpnStatus: string;
appStatus: string;
vmStatus: string;
routerStatus: string;
vms: VM[];
containers: Container[];
};

View File

@ -49,32 +49,3 @@ export const formatStatus = (status: string): string => {
return getStatus(getStatusAfterUnderscore(status));
};
export const getVMStatus = (lastSeen: string | number | Date) => {
const currentTime = new Date();
const lastSeenTime = new Date(lastSeen);
// Use getTime() to get timestamps in milliseconds
const diffInMinutes = (currentTime.getTime() - lastSeenTime.getTime()) / (1000 * 60);
return diffInMinutes > 1 ? 'NET_ONLINE' : 'NET_ONLINE'; //demo purpose returning only online
};
export const getSystemStatus = (lastSeen: string | number | Date, vpnStatus: string, appStatus:string) => {
const vmStatus = getVMStatus(lastSeen);
const expectedStatuses = {
VPN_CONNECTED: 'VPN_CONNECTED',
CONTAINER_RUNNING: 'CONTAINER_RUNNING',
NET_ONLINE: 'NET_ONLINE',
};
if (
vpnStatus === expectedStatuses.VPN_CONNECTED &&
appStatus === expectedStatuses.CONTAINER_RUNNING &&
vmStatus === expectedStatuses.NET_ONLINE
) {
return 'CONNECTED';
}
return 'DISCONNECTED';
};

View File

@ -1,105 +0,0 @@
DELIMITER //
CREATE PROCEDURE seed_complete_router_system()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE table_name VARCHAR(64);
DECLARE table_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'auth_log', 'user_sessions', 'user_router_access', 'users',
'container_status_history', 'router_status_history',
'container_status', 'dicom_study_overview',
'router_settings_history', 'router_settings',
'routers', 'status_type', 'status_category'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
-- Disable foreign key checks
SET FOREIGN_KEY_CHECKS=0;
-- Truncate all tables dynamically
OPEN table_cursor;
truncate_loop: LOOP
FETCH table_cursor INTO table_name;
IF done THEN
LEAVE truncate_loop;
END IF;
SET @query = CONCAT('TRUNCATE TABLE ', table_name);
PREPARE stmt FROM @query;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE table_cursor;
-- Re-enable foreign key checks
SET FOREIGN_KEY_CHECKS=1;
-- Insert Status Categories
INSERT INTO status_category (name, description)
VALUES
('Network', 'Network related statuses'),
('Disk', 'Disk related statuses'),
('VPN', 'VPN connection statuses'),
('License', 'License statuses'),
('Container', 'Container related statuses')
ON DUPLICATE KEY UPDATE id = id;
-- Insert Status Types
INSERT INTO status_type (category_id, name, code, description, severity)
VALUES
(1, 'Online', 'NET_ONLINE', 'System is online', 1),
(1, 'Offline', 'NET_OFFLINE', 'System is offline', 5),
(2, 'Normal', 'DISK_NORMAL', 'Disk usage is normal', 1),
(2, 'Warning', 'DISK_WARNING', 'Disk usage is high', 3),
(2, 'Critical', 'DISK_CRITICAL', 'Disk usage is critical', 5),
(3, 'Connected', 'VPN_CONNECTED', 'VPN is connected', 1),
(3, 'Disconnected', 'VPN_DISCONNECTED', 'VPN is disconnected', 5),
(5, 'Running', 'CONTAINER_RUNNING', 'Container is running', 1),
(5, 'Stopped', 'CONTAINER_STOPPED', 'Container is stopped', 5)
ON DUPLICATE KEY UPDATE id = id;
-- Insert Routers
INSERT INTO routers (router_id, facility, router_alias, last_seen, vpn_status_code, disk_status_code, app_status_code, license_status, free_disk, total_disk, disk_usage)
VALUES
('RTR001', 'Main Hospital', 'MAIN_RAD', NOW(), 'VPN_CONNECTED', 'DISK_NORMAL', 'CONTAINER_RUNNING', 'active', 500000000000, 1000000000000, 50.00),
('RTR002', 'Emergency Center', 'ER_RAD', NOW(), 'VPN_CONNECTED', 'DISK_WARNING', 'CONTAINER_RUNNING', 'active', 400000000000, 1000000000000, 60.00),
('RTR003', 'Imaging Center', 'IMG_CENTER', NOW(), 'VPN_CONNECTED', 'DISK_NORMAL', 'CONTAINER_RUNNING', 'active', 600000000000, 1000000000000, 40.00)
ON DUPLICATE KEY UPDATE id = id;
-- Store Router IDs
SET @router1_id = (SELECT id FROM routers WHERE router_id = 'RTR001');
SET @router2_id = (SELECT id FROM routers WHERE router_id = 'RTR002');
SET @router3_id = (SELECT id FROM routers WHERE router_id = 'RTR003');
-- Insert Container Status
INSERT INTO container_status (router_id, container_name, status_code, created_at, updated_at)
VALUES
(1, 'router-cstore-scp', 'CONTAINER_RUNNING', NOW(), NOW()),
(1, 'router-cstore-scu', 'CONTAINER_RUNNING', NOW(), NOW()),
(2, 'router-cstore-scp', 'CONTAINER_RUNNING', NOW(), NOW()),
(2, 'router-cstore-scu', 'CONTAINER_RUNNING', NOW(), NOW()),
(3, 'router-cstore-scp', 'CONTAINER_RUNNING', NOW(), NOW())
ON DUPLICATE KEY UPDATE id = id;
-- Insert DICOM Study Overview
INSERT INTO dicom_study_overview (
router_id, study_instance_uid, patient_id, patient_name,
accession_number, study_date, modality, study_description,
series_instance_uid, procedure_code, referring_physician_name
)
VALUES
(@router1_id, '1.2.840.113619.2.55.3.283116435.276.1543707218.134', 'P1', 'John Doe', 'ACC1234', '2024-03-15', 'CT', 'Chest CT', '1.2.840.113619.2.55.3.283116435.276.1543707219.135', 'CT001', 'Dr. Smith'),
(@router2_id, '1.2.840.113619.2.55.3.283116435.276.1543707218.136', 'P2', 'Jane Doe', 'ACC1235', '2024-03-15', 'MR', 'Brain MRI', '1.2.840.113619.2.55.3.283116435.276.1543707219.137', 'MR001', 'Dr. Johnson')
ON DUPLICATE KEY UPDATE id = id;
END //
DELIMITER ;
-- Automatically call the procedure after creation
CALL seed_complete_router_system();

View File

@ -1,15 +1,13 @@
# Server Configuration
NODE_ENV=development
NODE_ENV=production
PORT=3000
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
CORS_ORIGIN=http://${FRONTEND_IP}:5173,http://${SERVER_IP}:3000
# Database Configuration
DB_HOST=localhost
DB_HOST=${DB_HOST}
DB_PORT=3306
DB_USER=root
DB_PASSWORD=rootpassword
DB_USER=ve_router_user
DB_PASSWORD=ve_router_password
DB_NAME=ve_router_db
DB_CONNECTION_LIMIT=10

View File

@ -1,22 +1,20 @@
// src/config/config.ts
import dotenv from 'dotenv';
// Initialize dotenv at the very start
dotenv.config();
const config = {
env: process.env.NODE_ENV || 'development',
server: {
port: parseInt(process.env.PORT || '3000', 10),
// Update this to include your frontend URL
corsOrigin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173', 'http://localhost:3000'],
corsOrigin: process.env.CORS_ORIGIN?.split(',') || ['*'], // In production, replace with actual domains
apiPrefix: '/api/v1'
},
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306', 10),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '', // Make sure this is getting the password
user: process.env.DB_USER || 've_router_user',
password: process.env.DB_PASSWORD || 've_router_password',
database: process.env.DB_NAME || 've_router_db',
connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10', 10)
},
@ -26,13 +24,4 @@ const config = {
}
};
// Add this for debugging
console.log('Database Config:', {
host: config.db.host,
user: config.db.user,
database: config.db.database,
// Don't log the password
});
export default config;

View File

@ -11,65 +11,65 @@ export class RouterRepository {
// src/repositories/RouterRepository.ts
// src/repositories/RouterRepository.ts
async updateVMs(routerId: string, vms: VMUpdate[]): Promise<any> {
console.log('Repository: Starting VM update for router:', routerId);
const connection = await this.pool.getConnection();
async updateVMs(routerId: string, vms: VMUpdate[]): Promise<any> {
console.log('Repository: Starting VM update for router:', routerId);
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
console.log('Started transaction');
try {
await connection.beginTransaction();
console.log('Started transaction');
// First verify router exists
const [routers] = await connection.query(
'SELECT id FROM routers WHERE router_id = ?',
[routerId]
);
console.log('Router query result:', routers);
// First verify router exists
const [routers] = await connection.query(
'SELECT id FROM routers WHERE router_id = ?',
[routerId]
);
console.log('Router query result:', routers);
if (!Array.isArray(routers) || routers.length === 0) {
throw new Error(`Router ${routerId} not found`);
}
if (!Array.isArray(routers) || routers.length === 0) {
throw new Error(`Router ${routerId} not found`);
}
// Log each VM update
for (const vm of vms) {
console.log(`Processing VM ${vm.vm_number}`);
const [result]: any = await connection.query(
`UPDATE vm_details
SET status_code = ?,
updated_at = CURRENT_TIMESTAMP
WHERE router_id = ? AND vm_number = ?`,
[vm.status_code, routerId, vm.vm_number]
);
console.log('Update result:', result);
// Log each VM update
for (const vm of vms) {
console.log(`Processing VM ${vm.vm_number}`);
const [result]: any = await connection.query(
`UPDATE vm_details
SET status_code = ?,
updated_at = CURRENT_TIMESTAMP
WHERE router_id = ? AND vm_number = ?`,
[vm.status_code, routerId, vm.vm_number]
);
console.log('Update result:', result);
if (!result.affectedRows) {
console.log('No rows affected, attempting insert');
await connection.query(
`INSERT INTO vm_details (router_id, vm_number, status_code)
VALUES (?, ?, ?)`,
[routerId, vm.vm_number, vm.status_code]
);
}
}
if (!result.affectedRows) {
console.log('No rows affected, attempting insert');
await connection.query(
`INSERT INTO vm_details (router_id, vm_number, status_code)
VALUES (?, ?, ?)`,
[routerId, vm.vm_number, vm.status_code]
);
}
}
await connection.commit();
console.log('Transaction committed');
await connection.commit();
console.log('Transaction committed');
const [updatedVMs] = await connection.query(
`SELECT * FROM vm_details WHERE router_id = ? ORDER BY vm_number`,
[routerId]
);
return updatedVMs;
const [updatedVMs] = await connection.query(
`SELECT * FROM vm_details WHERE router_id = ? ORDER BY vm_number`,
[routerId]
);
return updatedVMs;
} catch (err) {
console.error('Repository error:', err);
await connection.rollback();
throw err;
} finally {
connection.release();
console.log('Connection released');
} catch (err) {
console.error('Repository error:', err);
await connection.rollback();
throw err;
} finally {
connection.release();
console.log('Connection released');
}
}
}
private async getRouterStudies(routerId: number): Promise<Study[]> {
try {
@ -81,10 +81,12 @@ async updateVMs(routerId: string, vms: VMUpdate[]): Promise<any> {
patient_name as patientName,
DATE_FORMAT(study_date, '%Y-%m-%d') as studyDate,
modality,
study_description as studyDescription
study_description as studyDescription,
CONCAT(UPPER(SUBSTRING(study_status_code, 1, 1)), LOWER(SUBSTRING(study_status_code, 2))) as studyStatusCode
FROM dicom_study_overview
WHERE router_id = ?
ORDER BY study_date DESC`,
ORDER BY updated_at DESC
LIMIT 1`,
[routerId]
);
return rows as Study[];
@ -126,32 +128,52 @@ async updateVMs(routerId: string, vms: VMUpdate[]): Promise<any> {
}
}
private async getRouterContainers(routerId: number): Promise<Container[]> {
try {
private async getRouterContainers(routerId: number): Promise<Container[]> {
try {
// Then use this to query vm_details
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT
container_name,
status_code
FROM container_status
WHERE router_id = ?`,
[routerId]
);
// Then use this to query vm_details
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT
container_name,
status_code
FROM container_status
WHERE router_id = ?`,
[routerId]
);
logger.info(`Containers for router ${routerId}:`, rows);
return rows as Container[];
} catch (error) {
logger.error(`Error fetching Containers for router ${routerId}:`, error);
return [];
logger.info(`Containers for router ${routerId}:`, rows);
return rows as Container[];
} catch (error) {
logger.error(`Error fetching Containers for router ${routerId}:`, error);
return [];
}
}
}
private async calculateVMStatus (lastSeen: string | number | Date): Promise<string> {
const currentTime = new Date();
const lastSeenTime = new Date(lastSeen);
// Use getTime() to get timestamps in milliseconds
const diffInMinutes = (currentTime.getTime() - lastSeenTime.getTime()) / (1000 * 60);
return diffInMinutes > 1 ? 'NET_OFFLINE' : 'NET_ONLINE';
};
private async calculateSystemStatus (vpnStatus: string, appStatus: string, vmStatus: string): Promise<string> {
return vpnStatus === 'VPN_CONNECTED' && appStatus === 'CONTAINER_RUNNING' && vmStatus === 'NET_ONLINE' ? 'CONNECTED' : 'DISCONNECTED';
};
private async transformDatabaseRouter(dbRouter: any, index: number): Promise<RouterData> {
try {
const studies = await this.getRouterStudies(dbRouter.id);
const vms = await this.getRouterVMs(dbRouter.id);
//const vms = await this.getRouterVMs(dbRouter.id);
const vms:VM[] = [];
const containers = await this.getRouterContainers(dbRouter.id);
const lastSeen = new Date(dbRouter.last_seen).toISOString();
const vpnStatus = dbRouter.vpn_status_code.toString();
const appStatus = dbRouter.app_status_code.toString();
const vmStatus = await this.calculateVMStatus(lastSeen);
const routerStatus = await this.calculateSystemStatus(vpnStatus, appStatus, vmStatus);
return {
id: dbRouter.id,
@ -159,7 +181,10 @@ private async getRouterContainers(routerId: number): Promise<Container[]> {
routerId: dbRouter.router_id,
facility: dbRouter.facility,
routerAlias: dbRouter.router_alias,
lastSeen: new Date(dbRouter.last_seen).toISOString(),
facilityAET: dbRouter.facility_aet,
openvpnIp: dbRouter.openvpn_ip,
routerVmPrimaryIp: dbRouter.router_vm_primary_ip,
lastSeen: lastSeen,
diskStatus: dbRouter.disk_status_code,
diskUsage: parseFloat(dbRouter.disk_usage),
freeDisk: parseInt(dbRouter.free_disk),
@ -168,8 +193,10 @@ private async getRouterContainers(routerId: number): Promise<Container[]> {
studies
},
systemStatus: {
vpnStatus: dbRouter.vpn_status_code,
appStatus: dbRouter.app_status_code,
vpnStatus: vpnStatus,
appStatus: appStatus,
vmStatus: vmStatus,
routerStatus: routerStatus,
vms,
containers
}
@ -233,14 +260,17 @@ private async getRouterContainers(routerId: number): Promise<Container[]> {
async create(router: Partial<RouterData>): Promise<RouterData> {
const [result] = await pool.query<ResultSetHeader>(
`INSERT INTO routers (
router_id, facility, router_alias, last_seen,
vpn_status_code, disk_status_code, app_status_code,
router_id, facility, router_alias, facility_aet, openvpn_ip, router_vm_primary_ip,
last_seen, vpn_status_code, disk_status_code, app_status_code,
license_status, free_disk, total_disk, disk_usage
) VALUES (?, ?, ?, NOW(), ?, ?, ?, 'inactive', ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, ?, ?, 'inactive', ?, ?, ?)`,
[
router.routerId,
router.facility,
router.routerAlias,
router.facilityAET,
router.openvpnIp,
router.routerVmPrimaryIp,
router.systemStatus?.vpnStatus || 'unknown',
router.diskStatus || 'unknown',
router.systemStatus?.appStatus || 'unknown',
@ -259,6 +289,9 @@ private async getRouterContainers(routerId: number): Promise<Container[]> {
if (router.routerId) updates.router_id = router.routerId;
if (router.facility) updates.facility = router.facility;
if (router.routerAlias) updates.router_alias = router.routerAlias;
if (router.facilityAET) updates.facility_aet = router.facilityAET;
if (router.openvpnIp) updates.openvpn_ip = router.openvpnIp;
if (router.routerVmPrimaryIp) updates.router_vm_primary_ip = router.routerVmPrimaryIp;
if (router.diskStatus) updates.disk_status_code = router.diskStatus;
if (router.systemStatus?.vpnStatus) updates.vpn_status_code = router.systemStatus?.vpnStatus;

View File

@ -46,7 +46,7 @@ export class DicomStudyService {
}
}
// Commented, currently this field is inserted with active/idle
// Commented below, currently this field is inserted with active/idle
// Validate status code
//const isValidStatus = await this.isValidStatusCode(studyData.study_status_code);
//if (!isValidStatus) {

View File

@ -1,21 +1,26 @@
// src/types/index.ts
export interface RouterData {
id: number; // maps to backend 'id'
slNo: number; // maps to backend 'slNo'
routerId: string; // maps to backend 'router_id'
facility: string; // maps to backend 'facility'
routerAlias: string; // maps to backend 'router_alias'
lastSeen: string; // maps to backend 'last_seen'
diskStatus: string; // maps to backend 'disk_status_code'
diskUsage: number; // maps to backend 'disk_usage'
freeDisk: number; // maps to backend 'free_disk'
totalDisk: number; // maps to backend 'total_disk'
id: number; // maps to backend 'id'
slNo: number; // maps to backend 'slNo'
routerId: string; // maps to backend 'router_id'
facility: string; // maps to backend 'facility'
routerAlias: string; // maps to backend 'router_alias'
facilityAET: string; // maps to backend 'facility_aet'
openvpnIp: string; // maps to backend 'openvpn_ip'
routerVmPrimaryIp: string; // maps to backend 'router_vm_primary_ip'
lastSeen: string; // maps to backend 'last_seen'
diskStatus: string; // maps to backend 'disk_status_code'
diskUsage: number; // maps to backend 'disk_usage'
freeDisk: number; // maps to backend 'free_disk'
totalDisk: number; // maps to backend 'total_disk'
routerActivity: {
studies: Study[];
};
systemStatus: {
vpnStatus: string; // maps to backend 'vpn_status_code'
appStatus: string; // maps to backend 'app_status_code'
vpnStatus: string; // maps to backend 'vpn_status_code'
appStatus: string; // maps to backend 'app_status_code'
vmStatus: string; // router machine status
routerStatus: string; // overall operational status of the router
vms: VM[];
containers: Container[];
};