diff --git a/nebula/frontend/app.py b/nebula/frontend/app.py
index c9e372c04..4018dbed0 100755
--- a/nebula/frontend/app.py
+++ b/nebula/frontend/app.py
@@ -164,19 +164,37 @@ async def connect(self, websocket: WebSocket):
pass
def disconnect(self, websocket: WebSocket):
- self.active_connections.remove(websocket)
+ if websocket in self.active_connections:
+ self.active_connections.remove(websocket)
def add_message(self, message):
current_timestamp = datetime.datetime.fromtimestamp(time.time()).strftime("%Y-%m-%d %H:%M:%S")
self.historic_messages.update({current_timestamp: json.loads(message)})
async def send_personal_message(self, message: str, websocket: WebSocket):
- await websocket.send_text(message)
+ try:
+ await websocket.send_text(message)
+ except RuntimeError:
+ # Connection was closed, remove it from active connections
+ self.disconnect(websocket)
async def broadcast(self, message: str):
self.add_message(message)
+ disconnected_websockets = []
+
for connection in self.active_connections:
- await connection.send_text(message)
+ try:
+ await connection.send_text(message)
+ except RuntimeError:
+ # Mark connection for removal
+ disconnected_websockets.append(connection)
+ except Exception as e:
+ logging.error(f"Error broadcasting message: {e}")
+ disconnected_websockets.append(connection)
+
+ # Remove disconnected websockets
+ for websocket in disconnected_websockets:
+ self.disconnect(websocket)
def get_historic(self):
return self.historic_messages
@@ -195,12 +213,20 @@ async def websocket_endpoint(websocket: WebSocket, client_id: int):
"type": "control",
"message": f"Client #{client_id} says: {data}",
}
- await manager.broadcast(json.dumps(message))
- # await manager.send_personal_message(f"You wrote: {data}", websocket)
+ try:
+ await manager.broadcast(json.dumps(message))
+ except Exception as e:
+ logging.error(f"Error broadcasting message: {e}")
except WebSocketDisconnect:
manager.disconnect(websocket)
- message = {"type": "control", "message": f"Client #{client_id} left the chat"}
- await manager.broadcast(json.dumps(message))
+ try:
+ message = {"type": "control", "message": f"Client #{client_id} left the chat"}
+ await manager.broadcast(json.dumps(message))
+ except Exception as e:
+ logging.error(f"Error broadcasting disconnect message: {e}")
+ except Exception as e:
+ logging.error(f"WebSocket error: {e}")
+ manager.disconnect(websocket)
templates = Jinja2Templates(directory=settings.templates_dir)
@@ -917,7 +943,7 @@ async def nebula_monitor_log_error(scenario_name: str, id: str):
async def nebula_monitor_image(scenario_name: str):
topology_image = FileUtils.check_path(settings.config_dir, os.path.join(scenario_name, "topology.png"))
if os.path.exists(topology_image):
- return FileResponse(topology_image, media_type="image/png")
+ return FileResponse(topology_image, media_type="image/png", filename=f"{scenario_name}_topology.png")
else:
raise HTTPException(status_code=404, detail="Topology image not found")
diff --git a/nebula/frontend/static/css/dashboard.css b/nebula/frontend/static/css/dashboard.css
new file mode 100644
index 000000000..709874056
--- /dev/null
+++ b/nebula/frontend/static/css/dashboard.css
@@ -0,0 +1,262 @@
+@keyframes pulse {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.05); }
+ 100% { transform: scale(1); }
+}
+
+@keyframes float {
+ 0% { transform: translateY(0px); }
+ 50% { transform: translateY(-10px); }
+ 100% { transform: translateY(0px); }
+}
+
+.loading-pulse {
+ animation: pulse 2s infinite ease-in-out;
+}
+
+.loading-float {
+ animation: float 3s infinite ease-in-out;
+}
+
+.scenario-running-indicator {
+ position: fixed;
+ top: 6rem;
+ right: 2rem;
+ background: rgba(255, 193, 7, 0.1);
+ border: 2px solid #ffc107;
+ border-radius: 1rem;
+ padding: 1rem 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+ backdrop-filter: blur(8px);
+}
+
+.scenario-running-indicator .spinner {
+ width: 1.5rem;
+ height: 1.5rem;
+ border: 3px solid rgba(255, 193, 7, 0.3);
+ border-top-color: #ffc107;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.progress-bar {
+ width: var(--progress-width);
+}
+
+.bg-success-subtle {
+ background-color: rgba(25, 135, 84, 0.1);
+}
+
+.bg-warning-subtle {
+ background-color: rgba(255, 193, 7, 0.1);
+}
+
+.bg-danger-subtle {
+ background-color: rgba(220, 53, 69, 0.1);
+}
+
+.bg-primary-subtle {
+ background-color: rgba(13, 110, 253, 0.1);
+}
+
+/* Smooth transitions */
+.btn, .badge, .card {
+ transition: all 0.2s ease-in-out;
+}
+
+.btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+#table-scenarios .btn i {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
+/* Table styles */
+.table > :not(caption) > * > * {
+ padding: 1rem;
+}
+
+.table tbody tr {
+ transition: background-color 0.2s ease;
+}
+
+.table tbody tr:hover {
+ background-color: rgba(0, 0, 0, 0.02);
+}
+
+/* Card styles */
+.card {
+ border-radius: 0.5rem;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
+}
+
+.card-header {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+/* Progress bar */
+.progress {
+ height: 0.75rem;
+ border-radius: 1rem;
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.progress-bar {
+ border-radius: 1rem;
+ transition: width 0.6s ease;
+}
+
+/* Tooltips */
+[title] {
+ position: relative;
+}
+
+[title]:hover::after {
+ content: attr(title);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ white-space: nowrap;
+ z-index: 1000;
+ margin-bottom: 0.5rem;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+[title]:hover::after {
+ opacity: 1;
+ transform: translateX(-50%) translateY(-0.25rem);
+}
+
+/* Icon styles */
+.fa {
+ font-size: 1rem;
+}
+
+.fa-3x {
+ font-size: 3rem;
+}
+
+/* Button styles */
+.btn-sm {
+ padding: 0.4rem 0.6rem;
+}
+
+.btn-outline-primary:hover {
+ background-color: var(--bs-primary);
+ color: white;
+}
+
+/* Badge styles */
+.badge {
+ font-weight: 500;
+}
+
+/* Textarea styles */
+textarea.form-control {
+ border-radius: 0.375rem;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+textarea.form-control:focus {
+ border-color: var(--bs-primary);
+ box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
+}
+
+/* Empty state styles */
+.empty-state-container {
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.empty-state-container i {
+ display: inline-block;
+ margin-bottom: 1.5rem;
+ color: var(--bs-primary);
+ opacity: 0.9;
+}
+
+.empty-state-container h3 {
+ font-size: 1.75rem;
+ margin-bottom: 1rem;
+}
+
+.empty-state-container p {
+ font-size: 1.1rem;
+ line-height: 1.6;
+ color: #6c757d;
+}
+
+/* Button styles */
+.btn-lg {
+ padding: 0.75rem 1.5rem;
+ font-size: 1.1rem;
+}
+
+.btn-lg i {
+ font-size: 1.2rem;
+}
+
+/* Card hover effect */
+.card {
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1) !important;
+}
+
+/* Progress bar animation */
+.progress-bar {
+ position: relative;
+ overflow: hidden;
+}
+
+.progress-bar::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 255, 255, 0.2) 50%,
+ rgba(255, 255, 255, 0) 100%
+ );
+ animation: progress-shine 2s infinite;
+}
+
+@keyframes progress-shine {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
\ No newline at end of file
diff --git a/nebula/frontend/static/css/deployment.css b/nebula/frontend/static/css/deployment.css
new file mode 100644
index 000000000..6231bc48b
--- /dev/null
+++ b/nebula/frontend/static/css/deployment.css
@@ -0,0 +1,232 @@
+
+.btn {
+ outline: none !important;
+ text-decoration: none !important;
+}
+
+.row {
+ margin: 0;
+}
+
+.participant-item {
+ padding: 5px;
+}
+
+#form-configurations {
+ counter-reset: step;
+}
+
+.step-title {
+ font-size: 15px;
+ font-weight: bold;
+ margin-top: 5px;
+}
+
+.step-number::before {
+ content: counter(step);
+ counter-increment: step;
+ display: inline-block;
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ border-radius: 50%;
+ background-color: #333;
+ color: #fff;
+ text-align: center;
+ margin-right: 10px;
+ font-size: 15px;
+}
+
+/* Lock icons */
+
+.icon-container {
+ cursor: pointer;
+}
+
+input[type="checkbox"][id$="-lock"]:checked + .icon-container i::before {
+ content: "\f023";
+}
+
+input[type="checkbox"][id$="-lock"]:not(:checked) + .icon-container i::before {
+ content: "\f09c";
+}
+
+/* Tooltips */
+button[title] {
+ position: relative;
+}
+
+button[title]:hover::after {
+ content: attr(title);
+ position: absolute;
+ top: -35px;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 8px 12px;
+ border-radius: 6px;
+ font-size: 14px;
+ white-space: nowrap;
+ z-index: 1000;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease, top 0.3s ease;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+}
+
+button[title]:hover::after {
+ opacity: 1;
+ top: -50px;
+}
+
+#expert-container .step-number::before {
+ background-color: #3333338a;
+ color: #fff;
+}
+
+.participant-started {
+ background-color: rgb(255 165 0 / 51%) !important;
+ font-weight: bold !important;
+}
+
+.participant-not-started {
+ background-color: #cecece !important;
+ font-weight: bold !important;
+}
+
+.btn-info-participant {
+ background-color: #cecece !important;
+ font-weight: bold !important;
+}
+
+#info-container {
+ padding: 10px;
+ margin-top: 5px;
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
+}
+
+#legend-container {
+ border: 2px solid black;
+ background-color: #f2f2f2;
+ padding: 10px;
+ margin-top: 5px;
+ margin-left: 5px;
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
+}
+
+#legend-label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ font-weight: bold;
+ margin-bottom: 2px;
+}
+
+#legend {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ margin-right: 5px;
+}
+
+.legend-color {
+ display: block;
+ width: 10px;
+ height: 10px;
+ margin-right: 5px;
+}
+
+.legend-color.circle {
+ border-radius: 50%;
+ background-color: #d95f02;
+}
+
+.legend-color.triangle {
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-bottom: 10px solid #7570b3;
+ background-color: transparent;
+}
+
+.legend-color.square {
+ background-color: #1b9e77;
+}
+
+.legend-color.donut {
+ border: 3px solid #000000;
+ background-color: transparent;
+ border-radius: 50%;
+ box-sizing: border-box;
+}
+
+.legend-text {
+ font-size: 12px;
+ font-weight: bold;
+}
+
+.info-participants {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ font-weight: bold;
+ margin-top: 5px;
+}
+
+.popover {
+ max-width: 800px;
+}
+
+.overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.6);
+ /* Semi-transparent black */
+ display: none;
+ z-index: 1000;
+}
+
+#spinner {
+ display: none;
+ border: 7px solid #f3f3f3;
+ /* Light grey */
+ border-top: 7px solid #3498db;
+ /* Blue */
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ /* Spin animation with 1 second duration */
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 80px;
+ height: 80px;
+ z-index: 1000;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/nebula/frontend/static/css/particles.css b/nebula/frontend/static/css/particles.css
new file mode 100644
index 000000000..9e9959948
--- /dev/null
+++ b/nebula/frontend/static/css/particles.css
@@ -0,0 +1,18 @@
+canvas {
+ display: block;
+}
+
+#particles-js {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ background-color: #ffffff;
+ background-image: url("");
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-position: 50% 50%;
+ z-index: -1;
+ pointer-events: auto;
+}
\ No newline at end of file
diff --git a/nebula/frontend/static/css/style.css b/nebula/frontend/static/css/style.css
index a374ef03f..1a136ed96 100755
--- a/nebula/frontend/static/css/style.css
+++ b/nebula/frontend/static/css/style.css
@@ -4,7 +4,7 @@ html, body {
}
body {
- font-family: "Inter", sans-serif;
+ font-family: 'JetBrains Mono';
color: #444444;
display: flex;
flex-direction: column;
@@ -24,15 +24,6 @@ a:hover {
text-decoration: none;
}
-h1,
-h2,
-h3,
-h4,
-h5,
-h6 {
- font-family: "Inter", sans-serif;
-}
-
#preloader {
position: fixed;
top: 0;
@@ -379,7 +370,7 @@ h6 {
}
section {
- padding: 20px 0;
+ padding: 10px 0;
overflow: hidden;
}
@@ -389,13 +380,18 @@ section {
.alerts {
z-index: 100;
- position: fixed;
+ position: absolute;
max-width: 100% !important;
text-align: center;
}
.container {
- max-width: 90%;
+ max-width: 80%;
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
}
.colaboration {
@@ -423,10 +419,10 @@ section {
}
}
-.base .container {
+/*.base .container {
box-shadow: 0px 2px 15px rgba(0, 0, 0, 0.1);
padding-bottom: 15px;
-}
+} */
.base .content {
font-size: 15px;
@@ -491,7 +487,6 @@ section {
.about .count-box p {
padding: 5px 0 0 0;
margin: 0 0 0 60px;
- font-family: "Inter", sans-serif;
font-weight: 600;
font-size: 14px;
color: #2e4b5e;
@@ -503,7 +498,6 @@ section {
margin-top: 20px;
color: #2e4b5e;
font-size: 15px;
- font-family: "Inter", sans-serif;
transition: ease-in-out 0.3s;
}
@@ -575,16 +569,6 @@ div {
text-align: justify;
}
-h1,
-h2,
-h3,
-h4,
-h5,
-h6 {
- text-align: left;
- font-family: "Inter", sans-serif;
-}
-
hr.styled {
border: 0;
height: 1px;
@@ -597,7 +581,24 @@ hr.styled {
}
.table {
- display: block !important;
+ width: 100% !important;
+ margin-bottom: 1rem;
+ color: #212529;
+}
+
+.table td, .table th {
+ vertical-align: middle;
+ text-align: center;
+}
+
+.table .d-flex {
+ justify-content: center;
+}
+
+.table .badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
.table-centered td,
@@ -610,13 +611,15 @@ hr.styled {
display: inline-block;
}
-.table-responsive>.table>thead>tr>th,
-.table-responsive>.table>tbody>tr>th,
-.table-responsive>.table>tfoot>tr>th,
-.table-responsive>.table>thead>tr>td,
-.table-responsive>.table>tbody>tr>td,
-.table-responsive>.table>tfoot>tr>td {
- white-space: nowrap;
+.table-responsive {
+ width: 100%;
+ margin-bottom: 1rem;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.table-responsive>.table {
+ margin-bottom: 0;
}
@media screen and (max-width: 1530px) {
@@ -627,6 +630,12 @@ hr.styled {
-ms-overflow-style: -ms-autohiding-scrollbar;
-webkit-overflow-scrolling: touch;
}
+
+ .container {
+ max-width: 100%;
+ padding-right: 10px;
+ padding-left: 10px;
+ }
}
td {
@@ -645,12 +654,30 @@ td {
max-width: 500px !important;
}
+.btn-danger {
+ color: #fff;
+ background-color: var(--bs-danger-bg-subtle)!important;
+ border-color: var(--bs-danger-border-subtle)!important;
+}
+
+.btn-danger:hover {
+ color: #fff;
+ background-color: var(--bs-danger-border-subtle)!important;
+ border-color: var(--bs-danger-border-subtle)!important;
+}
+
.btn-cream {
color: #000000;
background-color: #FFEFCB;
border-color: #FFEFCB;
}
+.btn-cream:hover {
+ color: #000000;
+ background-color: #FFEFCB;
+ border-color: #FFEFCB;
+}
+
.btn-light {
color: #ffffff;
background-color: #4d517d;
@@ -675,6 +702,18 @@ td {
border-color: #1d2253d2;
}
+.btn-dark-outline {
+ color: #1d2253;
+ background-color: transparent;
+ border-color: #1d2253;
+}
+
+.btn-dark-outline:hover {
+ color: #ffffff;
+ background-color: #1d2253;
+ border-color: #1d2253;
+}
+
.form-group {
margin-bottom: 1rem !important;
vertical-align: baseline !important;
diff --git a/nebula/frontend/static/js/dashboard/config-manager.js b/nebula/frontend/static/js/dashboard/config-manager.js
new file mode 100644
index 000000000..9913162cc
--- /dev/null
+++ b/nebula/frontend/static/js/dashboard/config-manager.js
@@ -0,0 +1,39 @@
+// Configuration Manager Module
+const ConfigManager = {
+ init() {
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ document.querySelectorAll('[id^=config-btn]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.toggleConfigRow(button.dataset.scenarioName);
+ });
+ });
+ },
+
+ async toggleConfigRow(scenarioName) {
+ const configRow = document.getElementById(`config-row-${scenarioName}`);
+ const configTextElement = document.getElementById(`config-text-${scenarioName}`);
+
+ if (configRow.style.display === 'none') {
+ try {
+ const response = await fetch(`/platform/dashboard/${scenarioName}/config`);
+ const data = await response.json();
+
+ if (data.status === 'success') {
+ configTextElement.value = JSON.stringify(data.config, null, 2);
+ } else {
+ configTextElement.value = 'No configuration available.';
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('An error occurred while retrieving the configuration.');
+ return;
+ }
+ }
+ configRow.style.display = configRow.style.display === 'none' ? '' : 'none';
+ }
+};
+
+export default ConfigManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/dashboard/dashboard.js b/nebula/frontend/static/js/dashboard/dashboard.js
new file mode 100644
index 000000000..4c2bdcc74
--- /dev/null
+++ b/nebula/frontend/static/js/dashboard/dashboard.js
@@ -0,0 +1,31 @@
+import ScenarioActions from './scenario-actions.js';
+import NotesManager from './notes-manager.js';
+import ConfigManager from './config-manager.js';
+
+// Main Dashboard Module
+const Dashboard = {
+ init() {
+ this.initializeModules();
+ // Only show demo message if user is not logged in
+ if (typeof window.userLoggedIn === 'boolean' && !window.userLoggedIn) {
+ this.checkDemoMode();
+ }
+ },
+
+ initializeModules() {
+ ScenarioActions.init();
+ NotesManager.init();
+ ConfigManager.init();
+ },
+
+ checkDemoMode() {
+ showAlert('info', 'Some functionalities are disabled in the demo version. Please, log in to access all functionalities.');
+ }
+};
+
+// Initialize dashboard when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ Dashboard.init();
+});
+
+export default Dashboard;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/dashboard/notes-manager.js b/nebula/frontend/static/js/dashboard/notes-manager.js
new file mode 100644
index 000000000..8896f5be3
--- /dev/null
+++ b/nebula/frontend/static/js/dashboard/notes-manager.js
@@ -0,0 +1,75 @@
+// Notes Manager Module
+const NotesManager = {
+ init() {
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ document.querySelectorAll('[id^=note-btn]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.toggleNotesRow(button.dataset.scenarioName);
+ });
+ });
+
+ document.querySelectorAll('[id^=save-note]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.saveNotes(button.dataset.scenarioName);
+ });
+ });
+ },
+
+ async toggleNotesRow(scenarioName) {
+ const notesRow = document.getElementById(`notes-row-${scenarioName}`);
+ const notesTextElement = document.getElementById(`notes-text-${scenarioName}`);
+
+ if (notesRow.style.display === 'none') {
+ try {
+ const response = await fetch(`/platform/dashboard/${scenarioName}/notes`);
+ const data = await response.json();
+
+ if (data.status === 'success') {
+ notesTextElement.value = data.notes;
+ } else {
+ notesTextElement.value = '';
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('An error occurred while retrieving the notes.');
+ return;
+ }
+ }
+
+ notesRow.style.display = notesRow.style.display === 'none' ? '' : 'none';
+ },
+
+ async saveNotes(scenarioName) {
+ const notesText = document.getElementById(`notes-text-${scenarioName}`).value;
+
+ try {
+ const response = await fetch(`/platform/dashboard/${scenarioName}/save_note`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ notes: notesText }),
+ });
+
+ const data = await response.json();
+
+ if (data.status === 'success') {
+ showAlert('success', 'Notes saved successfully');
+ } else {
+ if (data.code === 401) {
+ showAlert('info', 'Some functionalities are disabled in the demo version. Please, log in to access all functionalities.');
+ } else {
+ showAlert('error', 'Failed to save notes');
+ }
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ showAlert('error', 'Failed to save notes');
+ }
+ }
+};
+
+export default NotesManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/dashboard/scenario-actions.js b/nebula/frontend/static/js/dashboard/scenario-actions.js
new file mode 100644
index 000000000..d5cd915de
--- /dev/null
+++ b/nebula/frontend/static/js/dashboard/scenario-actions.js
@@ -0,0 +1,81 @@
+// Scenario Actions Module
+const ScenarioActions = {
+ init() {
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ $(document).on('click', '#relaunch-btn', this.handleRelaunch.bind(this));
+ $(document).on('click', '#remove-btn', this.handleRemove.bind(this));
+ },
+
+ handleRelaunch(event) {
+ const scenarioName = $(event.currentTarget).data('scenario-name');
+ const scenarioTitle = $(event.currentTarget).data('scenario-title');
+
+ $('#confirm-modal').modal('show');
+ $('#confirm-modal .modal-title').text('Relaunch scenario');
+ $('#confirm-modal #confirm-modal-body').html(`Are you sure you want to relaunch the scenario ${scenarioTitle}?`);
+
+ $('#confirm-modal #yes-button').off('click').on('click', () => {
+ this.executeRelaunch(scenarioName);
+ });
+ },
+
+ handleRemove(event) {
+ const scenarioName = $(event.currentTarget).data('scenario-name');
+
+ $('#confirm-modal').modal('show');
+ $('#confirm-modal .modal-title').text('Remove scenario');
+ $('#confirm-modal #confirm-modal-body').html(
+ `Are you sure you want to remove the scenario ${scenarioName}?
` +
+ `
Warning: you will remove the scenario from the database
`
+ );
+
+ $('#confirm-modal #yes-button').off('click').on('click', () => {
+ this.executeRemove(scenarioName);
+ });
+ },
+
+ async executeRelaunch(scenarioName) {
+ try {
+ const response = await fetch(`/platform/dashboard/${scenarioName}/relaunch`, {
+ method: 'GET'
+ });
+
+ if (response.redirected) {
+ window.location.href = response.url;
+ } else {
+ $('#confirm-modal').modal('hide');
+ $('#confirm-modal').on('hidden.bs.modal', () => {
+ $('#info-modal-body').html('You are not allowed to relaunch a scenario with demo role.');
+ $('#info-modal').modal('show');
+ });
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ },
+
+ async executeRemove(scenarioName) {
+ try {
+ const response = await fetch(`/platform/dashboard/${scenarioName}/remove`, {
+ method: 'GET'
+ });
+
+ if (response.redirected) {
+ window.location.href = response.url;
+ } else {
+ $('#confirm-modal').modal('hide');
+ $('#confirm-modal').on('hidden.bs.modal', () => {
+ $('#info-modal-body').html('You are not allowed to remove a scenario with demo role.');
+ $('#info-modal').modal('show');
+ });
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ }
+};
+
+export default ScenarioActions;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/attack.js b/nebula/frontend/static/js/deployment/attack.js
new file mode 100644
index 000000000..4bc208eb4
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/attack.js
@@ -0,0 +1,226 @@
+// Attack Configuration Module
+const AttackManager = (function() {
+ const ATTACK_TYPES = {
+ NO_ATTACK: 'No Attack',
+ LABEL_FLIPPING: 'Label Flipping',
+ SAMPLE_POISONING: 'Sample Poisoning',
+ MODEL_POISONING: 'Model Poisoning',
+ GLL_NEURON_INVERSION: 'GLL Neuron Inversion',
+ SWAPPING_WEIGHTS: 'Swapping Weights',
+ DELAYER: 'Delayer',
+ FLOODING: 'Flooding'
+ };
+
+ function updateAttackUI(attackType) {
+ const elements = {
+ poisonedNode: {title: document.getElementById("poisoned-node-title"), container: document.getElementById("poisoned-node-percent-container")},
+ poisonedSample: {title: document.getElementById("poisoned-sample-title"), container: document.getElementById("poisoned-sample-percent-container")},
+ poisonedNoise: {title: document.getElementById("poisoned-noise-title"), container: document.getElementById("poisoned-noise-percent-container")},
+ noiseType: {title: document.getElementById("noise-type-title"), container: document.getElementById("noise-type-container")},
+ targeted: {title: document.getElementById("targeted-title"), container: document.getElementById("targeted-container")},
+ targetLabel: {title: document.getElementById("target_label-title"), container: document.getElementById("target_label-container")},
+ targetChangedLabel: {title: document.getElementById("target_changed_label-title"), container: document.getElementById("target_changed_label-container")},
+ layerIdx: {title: document.getElementById("layer_idx-title"), container: document.getElementById("layer_idx-container")},
+ delay: {title: document.getElementById("delay-title"), container: document.getElementById("delay-container")},
+ startAttack: {title: document.getElementById("start-attack-title"), container: document.getElementById("start-attack-container")},
+ stopAttack: {title: document.getElementById("stop-attack-title"), container: document.getElementById("stop-attack-container")},
+ attackInterval: {title: document.getElementById("attack-interval-title"), container: document.getElementById("attack-interval-container")},
+ targetPercentage: {title: document.getElementById("target-percentage-title"), container: document.getElementById("target-percentage-container")},
+ selectionInterval: {title: document.getElementById("selection-interval-title"), container: document.getElementById("selection-interval-container")},
+ floodingFactor: {title: document.getElementById("flooding-factor-title"), container: document.getElementById("flooding-factor-container")}
+ };
+
+ // Hide all elements first
+ Object.values(elements).forEach(element => {
+ element.title.style.display = "none";
+ element.container.style.display = "none";
+ });
+
+ // Show relevant elements based on attack type
+ switch(attackType) {
+ case ATTACK_TYPES.NO_ATTACK:
+ break;
+
+ case ATTACK_TYPES.LABEL_FLIPPING:
+ showElements(elements, ['poisonedNode', 'poisonedSample', 'targeted', 'startAttack', 'stopAttack', 'attackInterval']);
+ if(document.getElementById("targeted").checked) {
+ showElements(elements, ['targetLabel', 'targetChangedLabel']);
+ }
+ break;
+
+ case ATTACK_TYPES.SAMPLE_POISONING:
+ showElements(elements, ['poisonedNode', 'poisonedSample', 'poisonedNoise', 'noiseType', 'targeted', 'startAttack', 'stopAttack', 'attackInterval']);
+ break;
+
+ case ATTACK_TYPES.MODEL_POISONING:
+ showElements(elements, ['poisonedNode', 'poisonedNoise', 'noiseType', 'startAttack', 'stopAttack', 'attackInterval']);
+ break;
+
+ case ATTACK_TYPES.GLL_NEURON_INVERSION:
+ showElements(elements, ['poisonedNode', 'startAttack', 'stopAttack', 'attackInterval']);
+ break;
+
+ case ATTACK_TYPES.SWAPPING_WEIGHTS:
+ showElements(elements, ['poisonedNode', 'layerIdx', 'startAttack', 'stopAttack', 'attackInterval']);
+ break;
+
+ case ATTACK_TYPES.DELAYER:
+ showElements(elements, ['poisonedNode', 'delay', 'startAttack', 'stopAttack', 'attackInterval', 'targetPercentage', 'selectionInterval']);
+ break;
+
+ case ATTACK_TYPES.FLOODING:
+ showElements(elements, ['poisonedNode', 'startAttack', 'stopAttack', 'attackInterval', 'targetPercentage', 'selectionInterval', 'floodingFactor']);
+ break;
+ }
+ }
+
+ function showElements(elements, elementKeys) {
+ elementKeys.forEach(key => {
+ elements[key].title.style.display = "block";
+ elements[key].container.style.display = "block";
+ });
+ }
+
+ function initializeEventListeners() {
+ document.getElementById("poisoning-attack-select").addEventListener("change", function() {
+ updateAttackUI(this.value);
+ });
+
+ document.getElementById("targeted").addEventListener("change", function() {
+ const attackType = document.getElementById("poisoning-attack-select").value;
+ updateAttackUI(attackType);
+ });
+
+ document.getElementById("malicious-nodes-select").addEventListener("change", function() {
+ const poisonedNodePercent = document.getElementById("poisoned-node-percent");
+ if(this.value === "Manual") {
+ poisonedNodePercent.value = 0;
+ poisonedNodePercent.disabled = true;
+ } else {
+ poisonedNodePercent.disabled = false;
+ }
+ });
+ }
+
+ function getAttackConfig() {
+ const attackType = document.getElementById("poisoning-attack-select").value;
+ const config = {
+ type: attackType,
+ poisonedNodePercent: parseFloat(document.getElementById("poisoned-node-percent").value),
+ startRound: parseInt(document.getElementById("start-attack").value),
+ stopRound: parseInt(document.getElementById("stop-attack").value),
+ attackInterval: parseInt(document.getElementById("attack-interval").value)
+ };
+
+ switch(attackType) {
+ case ATTACK_TYPES.LABEL_FLIPPING:
+ config.poisonedSamplePercent = parseFloat(document.getElementById("poisoned-sample-percent").value);
+ config.targeted = document.getElementById("targeted").checked;
+ if(config.targeted) {
+ config.targetLabel = parseInt(document.getElementById("target_label").value);
+ config.targetChangedLabel = parseInt(document.getElementById("target_changed_label").value);
+ }
+ break;
+
+ case ATTACK_TYPES.SAMPLE_POISONING:
+ config.poisonedSamplePercent = parseFloat(document.getElementById("poisoned-sample-percent").value);
+ config.poisonedNoisePercent = parseFloat(document.getElementById("poisoned-noise-percent").value);
+ config.noiseType = document.getElementById("noise_type").value;
+ config.targeted = document.getElementById("targeted").checked;
+ break;
+
+ case ATTACK_TYPES.MODEL_POISONING:
+ config.poisonedNoisePercent = parseFloat(document.getElementById("poisoned-noise-percent").value);
+ config.noiseType = document.getElementById("noise_type").value;
+ break;
+
+ case ATTACK_TYPES.SWAPPING_WEIGHTS:
+ config.layerIdx = parseInt(document.getElementById("layer_idx").value);
+ break;
+
+ case ATTACK_TYPES.DELAYER:
+ config.delay = parseInt(document.getElementById("delay").value);
+ config.targetPercentage = parseFloat(document.getElementById("target-percentage").value);
+ config.selectionInterval = parseInt(document.getElementById("selection-interval").value);
+ break;
+
+ case ATTACK_TYPES.FLOODING:
+ config.floodingFactor = parseInt(document.getElementById("flooding-factor").value);
+ config.targetPercentage = parseFloat(document.getElementById("target-percentage").value);
+ config.selectionInterval = parseInt(document.getElementById("selection-interval").value);
+ break;
+ }
+
+ return config;
+ }
+
+ function setAttackConfig(config) {
+ if (!config) return;
+
+ // Set attack type and update UI
+ document.getElementById("poisoning-attack-select").value = config.type;
+ updateAttackUI(config.type);
+
+ // Set common fields
+ document.getElementById("poisoned-node-percent").value = config.poisonedNodePercent || 0;
+ document.getElementById("start-attack").value = config.startRound || 1;
+ document.getElementById("stop-attack").value = config.stopRound || 10;
+ document.getElementById("attack-interval").value = config.attackInterval || 1;
+
+ // Set attack-specific fields
+ switch(config.type) {
+ case ATTACK_TYPES.LABEL_FLIPPING:
+ document.getElementById("poisoned-sample-percent").value = config.poisonedSamplePercent || 0;
+ document.getElementById("targeted").checked = config.targeted || false;
+ if(config.targeted) {
+ document.getElementById("target_label").value = config.targetLabel || 4;
+ document.getElementById("target_changed_label").value = config.targetChangedLabel || 7;
+ }
+ break;
+
+ case ATTACK_TYPES.SAMPLE_POISONING:
+ document.getElementById("poisoned-sample-percent").value = config.poisonedSamplePercent || 0;
+ document.getElementById("poisoned-noise-percent").value = config.poisonedNoisePercent || 0;
+ document.getElementById("noise_type").value = config.noiseType || "Salt";
+ document.getElementById("targeted").checked = config.targeted || false;
+ break;
+
+ case ATTACK_TYPES.MODEL_POISONING:
+ document.getElementById("poisoned-noise-percent").value = config.poisonedNoisePercent || 0;
+ document.getElementById("noise_type").value = config.noiseType || "Salt";
+ break;
+
+ case ATTACK_TYPES.SWAPPING_WEIGHTS:
+ document.getElementById("layer_idx").value = config.layerIdx || 0;
+ break;
+
+ case ATTACK_TYPES.DELAYER:
+ document.getElementById("delay").value = config.delay || 10;
+ document.getElementById("target-percentage").value = config.targetPercentage || 100;
+ document.getElementById("selection-interval").value = config.selectionInterval || 1;
+ break;
+
+ case ATTACK_TYPES.FLOODING:
+ document.getElementById("flooding-factor").value = config.floodingFactor || 100;
+ document.getElementById("target-percentage").value = config.targetPercentage || 100;
+ document.getElementById("selection-interval").value = config.selectionInterval || 1;
+ break;
+ }
+ }
+
+ function resetAttackConfig() {
+ document.getElementById("poisoning-attack-select").value = ATTACK_TYPES.NO_ATTACK;
+ updateAttackUI(ATTACK_TYPES.NO_ATTACK);
+ }
+
+ return {
+ ATTACK_TYPES,
+ initializeEventListeners,
+ updateAttackUI,
+ getAttackConfig,
+ setAttackConfig,
+ resetAttackConfig
+ };
+})();
+
+export default AttackManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/graph-settings.js b/nebula/frontend/static/js/deployment/graph-settings.js
new file mode 100644
index 000000000..0c3d93be6
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/graph-settings.js
@@ -0,0 +1,41 @@
+// Graph Settings Module
+const GraphSettings = (function() {
+ const Settings = {
+ solidDistance: 50,
+ Distance: 50
+ };
+
+ function initializeDistanceControls() {
+ const distanceInput = document.getElementById('distanceInput');
+ const distanceValue = document.getElementById('distanceValue');
+
+ distanceInput.addEventListener('input', function() {
+ distanceValue.value = distanceInput.value;
+ Settings.Distance = distanceInput.value;
+ updateLinkDistance();
+ });
+
+ distanceValue.addEventListener('input', function() {
+ distanceInput.value = distanceValue.value;
+ Settings.Distance = distanceValue.value;
+ updateLinkDistance();
+ });
+ }
+
+ function updateLinkDistance() {
+ const Graph = window.TopologyManager.getGraph();
+ if (Graph) {
+ Graph.d3Force('link')
+ .distance(link => link.color ? Settings.solidDistance : Settings.Distance);
+ Graph.numDimensions(3); // Re-heat simulation
+ }
+ }
+
+ return {
+ initializeDistanceControls,
+ updateLinkDistance,
+ getSettings: () => Settings
+ };
+})();
+
+export default GraphSettings;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/help-content.js b/nebula/frontend/static/js/deployment/help-content.js
new file mode 100644
index 000000000..afd53c50e
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/help-content.js
@@ -0,0 +1,177 @@
+// Help Content Module
+const HelpContent = (function() {
+ function initializePopovers() {
+ const tooltipElements = {
+ 'processHelpIcon': 'Process deployment allows you to deploy participants in the same machine using different processes.',
+ 'dockerHelpIcon': 'Docker deployment allows you to deploy participants in different containers.',
+ 'architectureHelpIcon': architecture,
+ 'topologyCustomIcon': topology.custom,
+ 'topologyPredefinedIcon': topology.predefined,
+ 'datasetHelpIcon': dataset,
+ 'iidHelpIcon': iid,
+ 'partitionMethodsHelpIcon': partitionMethods,
+ 'parameterSettingHelpIcon': parameterSetting,
+ 'modelHelpIcon': model,
+ 'maliciousHelpIcon': malicious
+ };
+
+ Object.entries(tooltipElements).forEach(([id, content]) => {
+ const element = document.getElementById(id);
+ if (element) {
+ new bootstrap.Tooltip(element, {
+ title: content,
+ html: true,
+ placement: 'right'
+ });
+ }
+ });
+ }
+
+ const topology = {
+ custom: `
+
Custom Topology
+
+ - Custom: Custom topology with the nodes
+
+
`,
+ predefined: `
+
Predefined Topologies
+
+ - Fully: All nodes are connected to all other nodes
+ - Ring: All nodes are connected to two other nodes
+ - Star: A central node is connected to all other nodes
+ - Random: Nodes are connected to random nodes
+
+
`
+ };
+
+ const architecture = `
+
Federation Architectures
+
+ - CFL: All nodes are connected to a central node
+ - DFL: Nodes are connected to each other
+ - SDFL: Nodes are connected to each other and the aggregator rotates
+
+
`;
+
+ const dataset = `
+
Available Datasets
+
+ - MNIST: The MNIST dataset
+ - FashionMNIST: The FashionMNIST dataset
+ - CIFAR10: The CIFAR10 dataset
+
+
`;
+
+ const iid = `
+
Data Distribution Types
+
+ - IID (Independent and Identically Distributed):
+
+ - Each participant has a complete set of categories
+ - Equal number of samples per category within each participant
+
+
+ - Non-IID (Non-independent and Identically Distributed):
+
+ - Data distribution does not meet IID conditions
+ - May have missing categories or uneven sample distribution
+
+
+
+
`;
+
+ const partitionMethods = `
+
Partition Methods
+
+ - Dirichlet: Partition using a Dirichlet distribution
+ - Percentage: Partition with specified non-IID level
+ - BalancedIID: Equal-sized IID partitions
+ - UnbalancedIID: Varying-sized IID partitions
+
+
`;
+
+ const parameterSetting = `
+
Parameter Settings
+
+ - Dirichlet:
+
+ - Parameter: alpha (float)
+ - Lower value = greater imbalance
+
+
+ - Percentage:
+
+ - Parameter: percentage (10-100)
+ - Controls non-IID level
+ - Lower value = greater imbalance
+
+
+ - UnbalancedIID:
+
+ - Parameter: imbalance_factor (>1)
+ - Controls dataset size imbalance
+
+
+
+
`;
+
+ const model = `
+
Available Models
+
+ - MLP: Multi-layer perceptron
+ - CNN: Convolutional neural network
+ - RNN: Recurrent neural network
+
+
`;
+
+ const malicious = `
+
Malicious Node Selection
+
+ - Percentage: Set percentage of malicious nodes
+ - Manual: Select malicious nodes in the graph
+
+
`;
+
+ const deployment = {
+ process: `
+
Process Deployment
+
+ - Deploy federation nodes using processes
+
+
`,
+ docker: `
+
Docker Deployment
+
+ - Deploy federation nodes using docker containers
+
+
`
+ };
+
+ const reputation = {
+ initialization: `
+
Reputation Initialization
+
Initial reputation value for all participants
+
`,
+ weighting: `
+
Weighting Factor
+
Use dynamic or static weighting factor for reputation
+
`
+ };
+
+ return {
+ initializePopovers,
+ topology,
+ architecture,
+ dataset,
+ iid,
+ partitionMethods,
+ parameterSetting,
+ model,
+ malicious,
+ deployment,
+ reputation
+ };
+})();
+
+export default HelpContent;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/main.js b/nebula/frontend/static/js/deployment/main.js
new file mode 100644
index 000000000..37607713a
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/main.js
@@ -0,0 +1,257 @@
+// Main Deployment Module
+import ScenarioManager from './scenario.js';
+import TopologyManager from './topology.js';
+import AttackManager from './attack.js';
+import MobilityManager from './mobility.js';
+import ReputationManager from './reputation.js';
+import GraphSettings from './graph-settings.js';
+import Utils from './utils.js';
+
+const DeploymentManager = (function() {
+ function initialize() {
+ // First initialize all modules
+ initializeModules();
+
+ // Then initialize event listeners and UI controls
+ initializeEventListeners();
+ setupDeploymentButtons();
+ initializeSelectElements();
+
+ // Finally initialize scenarios after all modules are ready
+ ScenarioManager.initializeScenarios();
+ }
+
+ function initializeModules() {
+ // Initialize all sub-modules
+ TopologyManager.initializeGraph('3d-graph', getGraphWidth(), getGraphHeight());
+ AttackManager.initializeEventListeners();
+ MobilityManager.initializeMobility();
+ ReputationManager.initializeReputationSystem();
+ GraphSettings.initializeDistanceControls();
+
+ // Make modules globally available
+ window.ScenarioManager = ScenarioManager;
+ window.TopologyManager = TopologyManager;
+ window.AttackManager = AttackManager;
+ window.MobilityManager = MobilityManager;
+ window.ReputationManager = ReputationManager;
+ window.GraphSettings = GraphSettings;
+ }
+
+ function getGraphWidth() {
+ return document.getElementById('3d-graph').offsetWidth;
+ }
+
+ function getGraphHeight() {
+ return document.getElementById('3d-graph').offsetHeight;
+ }
+
+ function initializeEventListeners() {
+ window.addEventListener("resize", handleResize);
+ window.addEventListener("click", handleOutsideClick);
+ setupDatasetListeners();
+ setupInputValidation();
+ }
+
+ function handleResize() {
+ TopologyManager.updateGraph();
+ }
+
+ function handleOutsideClick(event) {
+ if (!event.target.matches('.dropdown-item')) {
+ const dropdowns = document.getElementsByClassName("dropdown-menu");
+ Array.from(dropdowns).forEach(dropdown => {
+ if (dropdown.style.display === "block") {
+ dropdown.style.display = "none";
+ }
+ });
+ }
+ }
+
+ function setupDeploymentButtons() {
+ const runBtn = document.getElementById("run-btn");
+ if (runBtn) {
+ runBtn.addEventListener("click", () => {
+ if (!validateScenario()) {
+ return;
+ }
+ window.UIControls.handleDeployment();
+ });
+ }
+ }
+
+ function validateScenario() {
+ // Validate topology
+ if (!TopologyManager.getData().nodes.length) {
+ Utils.showAlert('error', 'Please create a topology with at least one node');
+ return false;
+ }
+
+ // Validate start node
+ if (!document.querySelector(".participant-started")) {
+ Utils.showAlert('error', 'Please select one "start" participant for the scenario');
+ return false;
+ }
+
+ return true;
+ }
+
+ function setupDatasetListeners() {
+ const datasetSelect = document.getElementById("datasetSelect");
+ if (datasetSelect) {
+ datasetSelect.addEventListener("change", () => {
+ // Update model options based on selected dataset
+ updateModelOptions(datasetSelect.value);
+ });
+ }
+ }
+
+ function initializeSelectElements() {
+ // Initialize dataset options
+ populateDatasetOptions();
+
+ // Initialize topology options
+ const topologySelect = document.getElementById("predefined-topology-select");
+ if (topologySelect) {
+ const topologies = ['Fully', 'Ring', 'Star', 'Random'];
+ topologies.forEach(topology => {
+ if (!topologySelect.querySelector(`option[value="${topology}"]`)) {
+ const option = document.createElement("option");
+ option.value = topology;
+ option.textContent = topology;
+ topologySelect.appendChild(option);
+ }
+ });
+ topologySelect.value = 'Fully';
+ }
+
+ // Initialize IID options
+ const iidSelect = document.getElementById("iidSelect");
+ if (iidSelect) {
+ const iidOptions = [
+ { value: 'true', text: 'IID' },
+ { value: 'false', text: 'Non-IID' }
+ ];
+ iidOptions.forEach(opt => {
+ if (!iidSelect.querySelector(`option[value="${opt.value}"]`)) {
+ const option = document.createElement("option");
+ option.value = opt.value;
+ option.textContent = opt.text;
+ iidSelect.appendChild(option);
+ }
+ });
+ iidSelect.value = 'false';
+ }
+
+ // Initialize partition options
+ const partitionSelect = document.getElementById("partitionSelect");
+ if (partitionSelect) {
+ const partitionOptions = [
+ { value: 'dirichlet', text: 'Dirichlet' },
+ { value: 'percent', text: 'Percentage' },
+ { value: 'balancediid', text: 'Balanced IID' },
+ { value: 'unbalancediid', text: 'Unbalanced IID' }
+ ];
+ partitionOptions.forEach(opt => {
+ if (!partitionSelect.querySelector(`option[value="${opt.value}"]`)) {
+ const option = document.createElement("option");
+ option.value = opt.value;
+ option.textContent = opt.text;
+ option.disabled = opt.value === 'balancediid' || opt.value === 'unbalancediid';
+ option.style.display = opt.value === 'balancediid' || opt.value === 'unbalancediid' ? 'none' : 'block';
+ partitionSelect.appendChild(option);
+ }
+ });
+ partitionSelect.value = 'dirichlet';
+ }
+
+ // Initialize logging level options
+ const loggingLevel = document.getElementById("loggingLevel");
+ if (loggingLevel) {
+ const loggingOptions = [
+ { value: 'false', text: 'Only alerts' },
+ { value: 'true', text: 'Alerts and logs' }
+ ];
+ loggingOptions.forEach(opt => {
+ if (!loggingLevel.querySelector(`option[value="${opt.value}"]`)) {
+ const option = document.createElement("option");
+ option.value = opt.value;
+ option.textContent = opt.text;
+ loggingLevel.appendChild(option);
+ }
+ });
+ loggingLevel.value = 'true';
+ }
+ }
+
+ function populateDatasetOptions() {
+ const datasetSelect = document.getElementById("datasetSelect");
+ if (!datasetSelect) return;
+
+ // Clear existing options
+ datasetSelect.innerHTML = "";
+
+ // Add dataset options
+ const datasets = ['MNIST', 'FashionMNIST', 'CIFAR10'];
+ datasets.forEach(dataset => {
+ const option = document.createElement("option");
+ option.value = dataset;
+ option.textContent = dataset;
+ datasetSelect.appendChild(option);
+ });
+
+ // Set default value and trigger change event
+ datasetSelect.value = 'MNIST';
+ datasetSelect.dispatchEvent(new Event('change'));
+ }
+
+ function updateModelOptions(dataset) {
+ const modelSelect = document.getElementById("modelSelect");
+ if (!modelSelect) return;
+
+ // Clear existing options
+ modelSelect.innerHTML = "";
+
+ // Add appropriate models based on dataset
+ const models = getModelsForDataset(dataset);
+ models.forEach(model => {
+ const option = document.createElement("option");
+ option.value = model;
+ option.textContent = model;
+ modelSelect.appendChild(option);
+ });
+ }
+
+ function getModelsForDataset(dataset) {
+ // Return appropriate models based on dataset
+ switch(dataset.toLowerCase()) {
+ case 'mnist':
+ case 'fashionmnist':
+ return ['MLP', 'CNN'];
+ case 'cifar10':
+ return ['CNN', 'ResNet18'];
+ default:
+ return ['MLP', 'CNN'];
+ }
+ }
+
+ function setupInputValidation() {
+ const numericInputs = document.querySelectorAll('input[type="number"]');
+ numericInputs.forEach(input => {
+ if(input.hasAttribute('min')) {
+ input.addEventListener('input', () => Utils.greaterThan0(input));
+ }
+ if(input.hasAttribute('min') && input.hasAttribute('max')) {
+ input.addEventListener('input', () => Utils.isInRange(input,
+ parseInt(input.getAttribute('min')),
+ parseInt(input.getAttribute('max'))));
+ }
+ });
+ }
+
+ return {
+ initialize
+ };
+})();
+
+export default DeploymentManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/mobility.js b/nebula/frontend/static/js/deployment/mobility.js
new file mode 100644
index 000000000..4785a2ec5
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/mobility.js
@@ -0,0 +1,288 @@
+// Mobility Configuration Module
+const MobilityManager = {
+ map: null,
+
+ initializeMobility() {
+ this.setupLocationControls();
+ this.setupMobilityControls();
+ this.setupAdditionalParticipants();
+ },
+
+ setupLocationControls() {
+ const customLocationDiv = document.getElementById("mobility-custom-location");
+
+ document.getElementById("random-geo-btn").addEventListener("click", () => {
+ customLocationDiv.style.display = "none";
+ });
+
+ document.getElementById("custom-location-btn").addEventListener("click", () => {
+ customLocationDiv.style.display = "block";
+ });
+
+ document.getElementById("current-location-btn").addEventListener("click", () => {
+ navigator.geolocation.getCurrentPosition(position => {
+ document.getElementById("latitude").value = position.coords.latitude;
+ document.getElementById("longitude").value = position.coords.longitude;
+ if (this.map) {
+ this.updateMapMarker(position.coords.latitude, position.coords.longitude);
+ }
+ });
+ });
+
+ document.getElementById("open-map-btn").addEventListener("click", () => {
+ const mapContainer = document.getElementById("map-container");
+ if (mapContainer.style.display === "none") {
+ mapContainer.style.display = "block";
+ this.initializeMap();
+ } else {
+ mapContainer.style.display = "none";
+ }
+ });
+ },
+
+ setupMobilityControls() {
+ const mobilityOptionsDiv = document.getElementById("mobility-options");
+
+ document.getElementById("without-mobility-btn").addEventListener("click", () => {
+ mobilityOptionsDiv.style.display = "none";
+ if (this.map) {
+ this.removeMapCircle();
+ }
+ });
+
+ document.getElementById("mobility-btn").addEventListener("click", () => {
+ mobilityOptionsDiv.style.display = "block";
+ if (this.map) {
+ this.addMapCircle();
+ }
+ });
+
+ document.getElementById("radiusFederation").addEventListener("change", () => {
+ if (this.map && document.getElementById("mobility-btn").checked) {
+ this.updateMapCircle();
+ }
+ });
+ },
+
+ initializeMap() {
+ if (!this.map) {
+ this.map = L.map('map').setView([38.023522, -1.174389], 17);
+
+ L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
+ attribution: '© enriquetomasmb.com',
+ maxZoom: 18,
+ }).addTo(this.map);
+
+ this.addInitialMarker();
+ if (document.getElementById("mobility-btn").checked) {
+ this.addMapCircle();
+ }
+
+ this.map.on('click', this.handleMapClick.bind(this));
+ }
+ },
+
+ addInitialMarker() {
+ const lat = parseFloat(document.getElementById("latitude").value);
+ const lng = parseFloat(document.getElementById("longitude").value);
+ this.updateMapMarker(lat, lng);
+ },
+
+ handleMapClick(e) {
+ this.updateMapMarker(e.latlng.lat, e.latlng.lng);
+ document.getElementById("latitude").value = e.latlng.lat;
+ document.getElementById("longitude").value = e.latlng.lng;
+ },
+
+ updateMapMarker(lat, lng) {
+ this.map.eachLayer(layer => {
+ if (layer instanceof L.Marker) {
+ this.map.removeLayer(layer);
+ }
+ });
+ L.marker([lat, lng]).addTo(this.map);
+ this.updateMapCircle();
+ },
+
+ addMapCircle() {
+ const lat = parseFloat(document.getElementById("latitude").value);
+ const lng = parseFloat(document.getElementById("longitude").value);
+ const radius = parseInt(document.getElementById("radiusFederation").value);
+
+ L.circle([lat, lng], {
+ color: 'red',
+ fillColor: '#f03',
+ fillOpacity: 0.4,
+ radius: radius
+ }).addTo(this.map);
+ },
+
+ updateMapCircle() {
+ this.removeMapCircle();
+ if (document.getElementById("mobility-btn").checked) {
+ this.addMapCircle();
+ }
+ },
+
+ removeMapCircle() {
+ this.map.eachLayer(layer => {
+ if (layer instanceof L.Circle) {
+ this.map.removeLayer(layer);
+ }
+ });
+ },
+
+ setupAdditionalParticipants() {
+ document.getElementById("additionalParticipants").addEventListener("change", function() {
+ const container = document.getElementById("additional-participants-items");
+ container.innerHTML = "";
+
+ for (let i = 0; i < this.value; i++) {
+ const participantItem = this.createParticipantItem(i);
+ container.appendChild(participantItem);
+ }
+ }.bind(this));
+ },
+
+ createParticipantItem(index) {
+ const participantItem = document.createElement("div");
+ participantItem.style.marginLeft = "20px";
+ participantItem.classList.add("additional-participant-item");
+
+ const heading = document.createElement("h5");
+ heading.textContent = `Round of deployment (participant ${index + 1})`;
+
+ const input = document.createElement("input");
+ input.type = "number";
+ input.classList.add("form-control");
+ input.id = `roundsAdditionalParticipant${index}`;
+ input.placeholder = "round";
+ input.min = "1";
+ input.value = "1";
+ input.style.display = "inline";
+ input.style.width = "20%";
+
+ participantItem.appendChild(heading);
+ participantItem.appendChild(input);
+
+ return participantItem;
+ },
+
+ getMobilityConfig() {
+ const config = {
+ enabled: document.getElementById("mobility-btn").checked,
+ randomGeo: document.getElementById("random-geo-btn").checked,
+ location: {
+ latitude: parseFloat(document.getElementById("latitude").value),
+ longitude: parseFloat(document.getElementById("longitude").value)
+ },
+ mobilityType: document.getElementById("mobilitySelect").value,
+ radiusFederation: parseInt(document.getElementById("radiusFederation").value),
+ schemeMobility: document.getElementById("schemeMobilitySelect").value,
+ roundFrequency: parseInt(document.getElementById("roundFrequency").value),
+ mobileParticipantsPercent: parseInt(document.getElementById("mobileParticipantsPercent").value),
+ additionalParticipants: []
+ };
+
+ const additionalParticipantsCount = parseInt(document.getElementById("additionalParticipants").value);
+ for (let i = 0; i < additionalParticipantsCount; i++) {
+ config.additionalParticipants.push({
+ round: parseInt(document.getElementById(`roundsAdditionalParticipant${i}`).value)
+ });
+ }
+
+ return config;
+ },
+
+ setMobilityConfig(config) {
+ if (!config) return;
+
+ // Validate required properties
+ if (typeof config.enabled !== 'boolean') {
+ console.warn('Invalid mobility config: enabled must be a boolean');
+ return;
+ }
+
+ if (typeof config.randomGeo !== 'boolean') {
+ console.warn('Invalid mobility config: randomGeo must be a boolean');
+ return;
+ }
+
+ if (config.location && (typeof config.location.latitude !== 'number' || typeof config.location.longitude !== 'number')) {
+ console.warn('Invalid mobility config: location must have numeric latitude and longitude');
+ return;
+ }
+
+ // Set mobility enabled/disabled
+ document.getElementById("mobility-btn").checked = config.enabled;
+ document.getElementById("without-mobility-btn").checked = !config.enabled;
+ document.getElementById("mobility-options").style.display = config.enabled ? "block" : "none";
+
+ // Set location type and coordinates
+ document.getElementById("random-geo-btn").checked = config.randomGeo;
+ document.getElementById("custom-location-btn").checked = !config.randomGeo;
+ document.getElementById("mobility-custom-location").style.display = config.randomGeo ? "none" : "block";
+
+ if (config.location) {
+ document.getElementById("latitude").value = config.location.latitude;
+ document.getElementById("longitude").value = config.location.longitude;
+ if (this.map) {
+ this.updateMapMarker(config.location.latitude, config.location.longitude);
+ }
+ }
+
+ // Set mobility settings
+ document.getElementById("mobilitySelect").value = config.mobilityType || "both";
+ document.getElementById("radiusFederation").value = config.radiusFederation || 100;
+ document.getElementById("schemeMobilitySelect").value = config.schemeMobility || "random";
+ document.getElementById("roundFrequency").value = config.roundFrequency || 1;
+ document.getElementById("mobileParticipantsPercent").value = config.mobileParticipantsPercent || 100;
+
+ // Set additional participants
+ if (config.additionalParticipants) {
+ if (!Array.isArray(config.additionalParticipants)) {
+ console.warn('Invalid mobility config: additionalParticipants must be an array');
+ return;
+ }
+
+ document.getElementById("additionalParticipants").value = config.additionalParticipants.length;
+ const container = document.getElementById("additional-participants-items");
+ container.innerHTML = "";
+
+ config.additionalParticipants.forEach((participant, index) => {
+ if (typeof participant.round !== 'number') {
+ console.warn(`Invalid mobility config: participant ${index} round must be a number`);
+ return;
+ }
+ const participantItem = this.createParticipantItem(index);
+ document.getElementById(`roundsAdditionalParticipant${index}`).value = participant.round;
+ container.appendChild(participantItem);
+ });
+ }
+ },
+
+ resetMobilityConfig() {
+ // Reset to default values
+ document.getElementById("without-mobility-btn").checked = true;
+ document.getElementById("mobility-options").style.display = "none";
+ document.getElementById("random-geo-btn").checked = false;
+ document.getElementById("custom-location-btn").checked = true;
+ document.getElementById("mobility-custom-location").style.display = "block";
+ document.getElementById("latitude").value = "38.023522";
+ document.getElementById("longitude").value = "-1.174389";
+ document.getElementById("mobilitySelect").value = "both";
+ document.getElementById("radiusFederation").value = "100";
+ document.getElementById("schemeMobilitySelect").value = "random";
+ document.getElementById("roundFrequency").value = "1";
+ document.getElementById("mobileParticipantsPercent").value = "100";
+ document.getElementById("additionalParticipants").value = "0";
+ document.getElementById("additional-participants-items").innerHTML = "";
+
+ if (this.map) {
+ this.updateMapMarker(38.023522, -1.174389);
+ this.removeMapCircle();
+ }
+ }
+};
+
+export default MobilityManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/reputation.js b/nebula/frontend/static/js/deployment/reputation.js
new file mode 100644
index 000000000..f65e1595f
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/reputation.js
@@ -0,0 +1,164 @@
+// Reputation System Module
+const ReputationManager = (function() {
+ function initializeReputationSystem() {
+ setupReputationSwitch();
+ setupWeightingFactor();
+ setupWeightValidation();
+ setupInitialReputation();
+ }
+
+ function setupReputationSwitch() {
+ document.getElementById("reputationSwitch").addEventListener("change", function() {
+ const reputationMetrics = document.getElementById("reputation-metrics");
+ const reputationSettings = document.getElementById("reputation-settings");
+ const weightingSettings = document.getElementById("weighting-settings");
+
+ reputationMetrics.style.display = this.checked ? "block" : "none";
+ reputationSettings.style.display = this.checked ? "block" : "none";
+ weightingSettings.style.display = this.checked ? "block" : "none";
+ });
+ }
+
+ function setupWeightingFactor() {
+ document.getElementById("weighting-factor").addEventListener("change", function() {
+ const showWeights = this.value === "static";
+ document.querySelectorAll(".weight-input").forEach(input => {
+ input.style.display = showWeights ? "inline-block" : "none";
+ });
+ });
+ }
+
+ function setupWeightValidation() {
+ document.querySelectorAll(".weight-input").forEach(input => {
+ input.addEventListener("input", validateWeights);
+ });
+ }
+
+ function validateWeights() {
+ let totalWeight = 0;
+ document.querySelectorAll(".weight-input").forEach(input => {
+ const checkbox = input.previousElementSibling.previousElementSibling;
+ if (checkbox.checked && input.style.display !== "none" && input.value) {
+ totalWeight += parseFloat(input.value);
+ }
+ });
+ document.getElementById("weight-warning").style.display = totalWeight > 1 ? "block" : "none";
+ }
+
+ function setupInitialReputation() {
+ document.getElementById("initial-reputation").addEventListener("blur", function() {
+ const min = parseFloat(this.min);
+ const max = parseFloat(this.max);
+ const value = parseFloat(this.value);
+
+ if (value < min) {
+ this.value = min;
+ } else if (value > max) {
+ this.value = max;
+ }
+ });
+ }
+
+ function getReputationConfig() {
+ return {
+ enabled: document.getElementById("reputationSwitch").checked,
+ initialReputation: parseFloat(document.getElementById("initial-reputation").value),
+ weightingFactor: document.getElementById("weighting-factor").value,
+ metrics: {
+ modelSimilarity: {
+ enabled: document.getElementById("model-similarity").checked,
+ weight: parseFloat(document.getElementById("weight-model-similarity").value)
+ },
+ numMessages: {
+ enabled: document.getElementById("num-messages").checked,
+ weight: parseFloat(document.getElementById("weight-num-messages").value)
+ },
+ modelArrivalLatency: {
+ enabled: document.getElementById("model-arrival-latency").checked,
+ weight: parseFloat(document.getElementById("weight-model-arrival-latency").value)
+ },
+ fractionParametersChanged: {
+ enabled: document.getElementById("fraction-parameters-changed").checked,
+ weight: parseFloat(document.getElementById("weight-fraction-parameters-changed").value)
+ }
+ }
+ };
+ }
+
+ function setReputationConfig(config) {
+ if (!config) return;
+
+ // Set reputation enabled/disabled
+ document.getElementById("reputationSwitch").checked = config.enabled;
+ document.getElementById("reputation-metrics").style.display = config.enabled ? "block" : "none";
+ document.getElementById("reputation-settings").style.display = config.enabled ? "block" : "none";
+ document.getElementById("weighting-settings").style.display = config.enabled ? "block" : "none";
+
+ // Set initial reputation
+ document.getElementById("initial-reputation").value = config.initialReputation || 0.6;
+
+ // Set weighting factor
+ document.getElementById("weighting-factor").value = config.weightingFactor || "dynamic";
+ const showWeights = config.weightingFactor === "static";
+ document.querySelectorAll(".weight-input").forEach(input => {
+ input.style.display = showWeights ? "inline-block" : "none";
+ });
+
+ // Set metrics
+ if (config.metrics) {
+ // Model Similarity
+ document.getElementById("model-similarity").checked = config.metrics.modelSimilarity?.enabled || false;
+ document.getElementById("weight-model-similarity").value = config.metrics.modelSimilarity?.weight || 0;
+
+ // Number of Messages
+ document.getElementById("num-messages").checked = config.metrics.numMessages?.enabled || false;
+ document.getElementById("weight-num-messages").value = config.metrics.numMessages?.weight || 0;
+
+ // Model Arrival Latency
+ document.getElementById("model-arrival-latency").checked = config.metrics.modelArrivalLatency?.enabled || false;
+ document.getElementById("weight-model-arrival-latency").value = config.metrics.modelArrivalLatency?.weight || 0;
+
+ // Fraction Parameters Changed
+ document.getElementById("fraction-parameters-changed").checked = config.metrics.fractionParametersChanged?.enabled || false;
+ document.getElementById("weight-fraction-parameters-changed").value = config.metrics.fractionParametersChanged?.weight || 0;
+ }
+
+ // Validate weights
+ validateWeights();
+ }
+
+ function resetReputationConfig() {
+ // Reset to default values
+ document.getElementById("reputationSwitch").checked = false;
+ document.getElementById("reputation-metrics").style.display = "none";
+ document.getElementById("reputation-settings").style.display = "none";
+ document.getElementById("weighting-settings").style.display = "none";
+ document.getElementById("initial-reputation").value = "0.6";
+ document.getElementById("weighting-factor").value = "dynamic";
+ document.getElementById("weight-warning").style.display = "none";
+
+ // Reset metrics
+ document.getElementById("model-similarity").checked = false;
+ document.getElementById("weight-model-similarity").value = "0";
+ document.getElementById("num-messages").checked = false;
+ document.getElementById("weight-num-messages").value = "0";
+ document.getElementById("model-arrival-latency").checked = false;
+ document.getElementById("weight-model-arrival-latency").value = "0";
+ document.getElementById("fraction-parameters-changed").checked = false;
+ document.getElementById("weight-fraction-parameters-changed").value = "0";
+
+ // Hide weight inputs
+ document.querySelectorAll(".weight-input").forEach(input => {
+ input.style.display = "none";
+ });
+ }
+
+ return {
+ initializeReputationSystem,
+ getReputationConfig,
+ setReputationConfig,
+ resetReputationConfig
+ };
+})();
+
+export default ReputationManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/scenario.js b/nebula/frontend/static/js/deployment/scenario.js
new file mode 100644
index 000000000..1c071a0a8
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/scenario.js
@@ -0,0 +1,335 @@
+// Scenario Management Module
+const ScenarioManager = (function() {
+ let scenariosList = [];
+ let actual_scenario = 0;
+
+ // Initialize scenarios from session storage
+ function initializeScenarios() {
+ // Clear session storage
+ sessionStorage.removeItem("ScenarioList");
+
+ // Reset the scenarios list
+ scenariosList = [];
+ actual_scenario = 0;
+
+ // Clear all fields and reset modules
+ clearFields();
+
+ // Update UI
+ updateScenariosPosition(true);
+ }
+
+ // Don't call initializeScenarios immediately
+ // It will be called after all modules are initialized
+
+ function collectScenarioData() {
+ const topologyData = window.TopologyManager.getData();
+ const nodes = {};
+ const nodes_graph = {};
+
+ // Convert nodes array to objects with string IDs
+ topologyData.nodes.forEach(node => {
+ const nodeId = node.id.toString();
+ nodes[nodeId] = {
+ id: nodeId,
+ ip: node.ip,
+ port: node.port,
+ role: node.role,
+ malicious: node.malicious,
+ proxy: node.proxy,
+ start: node.start,
+ neighbors: node.neighbors.map(n => n.toString()),
+ links: node.links,
+ attacks: [],
+ attack_params: {},
+ with_reputation: false,
+ mobility: false
+ };
+ nodes_graph[nodeId] = {
+ id: nodeId,
+ role: node.role,
+ malicious: node.malicious,
+ proxy: node.proxy,
+ start: node.start
+ };
+ });
+
+ // Get topology type from select element
+ const topologyType = document.getElementById('predefined-topology-select').value;
+
+ return {
+ scenario_title: document.getElementById("scenario-title").value,
+ scenario_description: document.getElementById("scenario-description").value,
+ deployment: document.querySelector('input[name="deploymentRadioOptions"]:checked').value,
+ federation: document.getElementById("federationArchitecture").value,
+ rounds: parseInt(document.getElementById("rounds").value),
+ topology: topologyType,
+ nodes: nodes,
+ nodes_graph: nodes_graph,
+ n_nodes: topologyData.nodes.length,
+ matrix: window.TopologyManager.getMatrix(),
+ dataset: document.getElementById("datasetSelect").value,
+ iid: document.getElementById("iidSelect").value === "true",
+ partition_selection: document.getElementById("partitionSelect").value,
+ partition_parameter: parseFloat(document.getElementById("partitionParameter").value),
+ model: document.getElementById("modelSelect").value,
+ agg_algorithm: document.getElementById("aggregationSelect").value,
+ logginglevel: document.getElementById("loggingLevel").value === "true",
+ report_status_data_queue: document.getElementById("reportingSwitch").checked,
+ epochs: parseInt(document.getElementById("epochs").value),
+ attacks: window.AttackManager.getAttackConfig().attacks || [],
+ poisoned_node_percent: window.AttackManager.getAttackConfig().poisoned_node_percent || 0,
+ poisoned_sample_percent: window.AttackManager.getAttackConfig().poisoned_sample_percent || 0,
+ poisoned_noise_percent: window.AttackManager.getAttackConfig().poisoned_noise_percent || 0,
+ attack_params: window.AttackManager.getAttackConfig().attack_params || {},
+ with_reputation: window.ReputationManager.getReputationConfig().with_reputation || false,
+ reputation_metrics: window.ReputationManager.getReputationConfig().reputation_metrics || [],
+ initial_reputation: window.ReputationManager.getReputationConfig().initial_reputation || 1.0,
+ weighting_factor: window.ReputationManager.getReputationConfig().weighting_factor || "static",
+ weight_model_arrival_latency: window.ReputationManager.getReputationConfig().weight_model_arrival_latency || 0.25,
+ weight_model_similarity: window.ReputationManager.getReputationConfig().weight_model_similarity || 0.25,
+ weight_num_messages: window.ReputationManager.getReputationConfig().weight_num_messages || 0.25,
+ weight_fraction_params_changed: window.ReputationManager.getReputationConfig().weight_fraction_params_changed || 0.25,
+ mobility: window.MobilityManager.getMobilityConfig().enabled || false,
+ mobility_type: window.MobilityManager.getMobilityConfig().mobilityType || "random",
+ radius_federation: window.MobilityManager.getMobilityConfig().radiusFederation || 1000,
+ scheme_mobility: window.MobilityManager.getMobilityConfig().schemeMobility || "random",
+ round_frequency: window.MobilityManager.getMobilityConfig().roundFrequency || 1,
+ mobile_participants_percent: window.MobilityManager.getMobilityConfig().mobileParticipantsPercent || 0.5,
+ random_geo: window.MobilityManager.getMobilityConfig().randomGeo || false,
+ latitude: window.MobilityManager.getMobilityConfig().location.latitude || 0,
+ longitude: window.MobilityManager.getMobilityConfig().location.longitude || 0,
+ random_topology_probability: document.getElementById("random-probability").value || 0.5,
+ network_subnet: "172.20.0.0/16",
+ network_gateway: "172.20.0.1",
+ additional_participants: window.MobilityManager.getMobilityConfig().additionalParticipants || [],
+ schema_additional_participants: document.getElementById("schemaAdditionalParticipantsSelect").value || "random",
+ accelerator: "cpu",
+ gpu_id: []
+ };
+ }
+
+ function loadScenarioData(scenario) {
+ if (!scenario) return;
+
+ // Load basic fields
+ document.getElementById("scenario-title").value = scenario.scenario_title || "";
+ document.getElementById("scenario-description").value = scenario.scenario_description || "";
+
+ // Load deployment
+ const deploymentRadio = document.querySelector(`input[name="deploymentRadioOptions"][value="${scenario.deployment}"]`);
+ if (deploymentRadio) deploymentRadio.checked = true;
+
+ // Load architecture and rounds
+ document.getElementById("federationArchitecture").value = scenario.federation;
+ document.getElementById("rounds").value = scenario.rounds;
+
+ // Load topology
+ if (scenario.nodes && scenario.nodes_graph) {
+ const topologyData = {
+ nodes: Object.values(scenario.nodes),
+ links: []
+ };
+
+ // Reconstruct links from the nodes' neighbors
+ topologyData.nodes.forEach(node => {
+ if (node.neighbors) {
+ node.neighbors.forEach(neighborId => {
+ topologyData.links.push({
+ source: node.id,
+ target: neighborId
+ });
+ });
+ }
+ });
+
+ window.TopologyManager.setData(topologyData);
+ } else {
+ window.TopologyManager.generatePredefinedTopology();
+ }
+
+ // Load dataset settings
+ document.getElementById("datasetSelect").value = scenario.dataset;
+ document.getElementById("iidSelect").value = scenario.iid ? "true" : "false";
+ document.getElementById("partitionSelect").value = scenario.partition_selection;
+ document.getElementById("partitionParameter").value = scenario.partition_parameter;
+
+ // Load model and aggregation
+ document.getElementById("modelSelect").value = scenario.model;
+ document.getElementById("aggregationSelect").value = scenario.agg_algorithm;
+
+ // Load advanced settings
+ document.getElementById("loggingLevel").value = scenario.logginglevel ? "true" : "false";
+ document.getElementById("reportingSwitch").checked = scenario.report_status_data_queue;
+ document.getElementById("epochs").value = scenario.epochs;
+
+ // Load module configurations
+ if (scenario.attacks && scenario.attacks.length > 0) {
+ window.AttackManager.setAttackConfig({
+ attacks: scenario.attacks,
+ poisoned_node_percent: scenario.poisoned_node_percent,
+ poisoned_sample_percent: scenario.poisoned_sample_percent,
+ poisoned_noise_percent: scenario.poisoned_noise_percent,
+ attack_params: scenario.attack_params
+ });
+ }
+ if (scenario.mobility) {
+ window.MobilityManager.setMobilityConfig({
+ enabled: scenario.mobility,
+ mobilityType: scenario.mobility_type,
+ radiusFederation: scenario.radius_federation,
+ schemeMobility: scenario.scheme_mobility,
+ roundFrequency: scenario.round_frequency,
+ mobileParticipantsPercent: scenario.mobile_participants_percent,
+ randomGeo: scenario.random_geo,
+ location: {
+ latitude: scenario.latitude,
+ longitude: scenario.longitude
+ },
+ additionalParticipants: scenario.additional_participants
+ });
+ }
+ if (scenario.with_reputation) {
+ window.ReputationManager.setReputationConfig({
+ with_reputation: scenario.with_reputation,
+ reputation_metrics: scenario.reputation_metrics,
+ initial_reputation: scenario.initial_reputation,
+ weighting_factor: scenario.weighting_factor,
+ weight_model_arrival_latency: scenario.weight_model_arrival_latency,
+ weight_model_similarity: scenario.weight_model_similarity,
+ weight_num_messages: scenario.weight_num_messages,
+ weight_fraction_params_changed: scenario.weight_fraction_params_changed
+ });
+ }
+
+ // Trigger necessary events
+ document.getElementById("federationArchitecture").dispatchEvent(new Event('change'));
+ document.getElementById("datasetSelect").dispatchEvent(new Event('change'));
+ document.getElementById("iidSelect").dispatchEvent(new Event('change'));
+ }
+
+ function saveScenario() {
+ const scenarioData = collectScenarioData();
+ scenariosList.push(scenarioData);
+ actual_scenario = scenariosList.length - 1;
+ sessionStorage.setItem("ScenarioList", JSON.stringify(scenariosList));
+ updateScenariosPosition();
+ }
+
+ function deleteScenario() {
+ if (scenariosList.length === 0) return;
+
+ scenariosList.splice(actual_scenario, 1);
+ if (actual_scenario >= scenariosList.length) {
+ actual_scenario = Math.max(0, scenariosList.length - 1);
+ }
+
+ if (scenariosList.length > 0) {
+ loadScenarioData(scenariosList[actual_scenario]);
+ } else {
+ clearFields();
+ }
+
+ sessionStorage.setItem("ScenarioList", JSON.stringify(scenariosList));
+ updateScenariosPosition(scenariosList.length === 0);
+ }
+
+ function replaceScenario() {
+ if (actual_scenario < 0 || actual_scenario >= scenariosList.length) return;
+
+ const scenarioData = collectScenarioData();
+ scenariosList[actual_scenario] = scenarioData;
+ sessionStorage.setItem("ScenarioList", JSON.stringify(scenariosList));
+ }
+
+ function updateScenariosPosition(isEmptyScenario = false) {
+ const container = document.getElementById("scenarios-position");
+ if (!container) return;
+
+ // Clear existing content
+ container.innerHTML = '';
+
+ if (isEmptyScenario) {
+ container.innerHTML = 'No scenarios';
+ return;
+ }
+
+ // Create a single span for all scenarios
+ const span = document.createElement("span");
+ span.style.margin = "0 10px";
+
+ // Create the scenario indicators
+ const indicators = scenariosList.map((_, index) =>
+ index === actual_scenario ? `●` : `○`
+ ).join(' ');
+
+ span.textContent = indicators;
+ container.appendChild(span);
+ }
+
+ function clearFields() {
+ // Reset form fields to default values
+ document.getElementById("scenario-title").value = "";
+ document.getElementById("scenario-description").value = "";
+ document.getElementById("docker-radio").checked = true;
+ document.getElementById("federationArchitecture").value = "DFL";
+ document.getElementById("rounds").value = "10";
+ document.getElementById("custom-topology-btn").checked = true;
+ document.getElementById("predefined-topology").style.display = "none";
+ document.getElementById("datasetSelect").value = "MNIST";
+ document.getElementById("iidSelect").value = "false";
+ document.getElementById("partitionSelect").value = "dirichlet";
+ document.getElementById("partitionParameter").value = "0.5";
+ document.getElementById("modelSelect").value = "MLP";
+ document.getElementById("aggregationSelect").value = "FedAvg";
+ document.getElementById("loggingLevel").value = "false";
+ document.getElementById("reportingSwitch").checked = true;
+ document.getElementById("epochs").value = "1";
+
+ // Reset modules
+ if (window.TopologyManager) {
+ window.TopologyManager.generatePredefinedTopology();
+ }
+ if (window.AttackManager) {
+ window.AttackManager.resetAttackConfig();
+ }
+ if (window.MobilityManager) {
+ window.MobilityManager.resetMobilityConfig();
+ }
+ if (window.ReputationManager) {
+ window.ReputationManager.resetReputationConfig();
+ }
+
+ // Trigger necessary events
+ document.getElementById("federationArchitecture").dispatchEvent(new Event('change'));
+ document.getElementById("datasetSelect").dispatchEvent(new Event('change'));
+ document.getElementById("iidSelect").dispatchEvent(new Event('change'));
+ }
+
+ return {
+ saveScenario,
+ deleteScenario,
+ replaceScenario,
+ loadScenarioData,
+ clearFields,
+ updateScenariosPosition,
+ initializeScenarios,
+ getScenariosList: () => scenariosList,
+ getActualScenario: () => actual_scenario,
+ setActualScenario: (index) => {
+ actual_scenario = index;
+ if (scenariosList[index]) {
+ loadScenarioData(scenariosList[index]);
+ }
+ },
+ setScenariosList: (list) => {
+ scenariosList = list;
+ if (list.length > 0) {
+ actual_scenario = 0;
+ loadScenarioData(list[0]);
+ }
+ }
+ };
+})();
+
+export default ScenarioManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/topology.js b/nebula/frontend/static/js/deployment/topology.js
new file mode 100644
index 000000000..0f0ac9add
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/topology.js
@@ -0,0 +1,637 @@
+// Topology Management Module
+
+const TopologyManager = (function() {
+ let gData = {
+ nodes: [],
+ links: []
+ };
+ let Graph = null;
+ let selectedNodes = new Set();
+
+ function initializeGraph(containerId, width, height) {
+ setupTopologyListeners();
+ generatePredefinedTopology();
+ }
+
+ function setupTopologyListeners() {
+ const topologySelect = document.getElementById('predefined-topology-select');
+ const nodesInput = document.getElementById('predefined-topology-nodes');
+ const probabilitySelect = document.getElementById('random-probability');
+ const randomOptions = document.getElementById('random-topology-options');
+ const predefinedTopology = document.getElementById('predefined-topology');
+ const customTopologyBtn = document.getElementById('custom-topology-btn');
+ const predefinedTopologyBtn = document.getElementById('predefined-topology-btn');
+
+ // Set default topology
+ topologySelect.value = 'Fully';
+ nodesInput.value = '3';
+ predefinedTopologyBtn.checked = true;
+ predefinedTopology.style.display = 'block';
+
+ // Add radio button listeners
+ customTopologyBtn.addEventListener('change', () => {
+ predefinedTopology.style.display = 'none';
+ });
+
+ predefinedTopologyBtn.addEventListener('change', () => {
+ predefinedTopology.style.display = 'block';
+ generatePredefinedTopology();
+ });
+
+ // Add federation architecture change listener
+ document.getElementById('federationArchitecture').addEventListener('change', function() {
+ const federationType = this.value;
+ const topologySelect = document.getElementById('predefined-topology-select');
+
+ if (federationType === 'CFL') {
+ // For CFL, only allow Star topology
+ topologySelect.value = 'Star';
+ topologySelect.disabled = true;
+ predefinedTopologyBtn.checked = true;
+ predefinedTopology.style.display = 'block';
+ customTopologyBtn.disabled = true;
+ } else {
+ // For DFL and SDFL, allow all topologies
+ topologySelect.disabled = false;
+ customTopologyBtn.disabled = false;
+ }
+
+ generatePredefinedTopology();
+ });
+
+ topologySelect.addEventListener('change', () => {
+ if (topologySelect.value === 'Random') {
+ randomOptions.style.display = 'block';
+ } else {
+ randomOptions.style.display = 'none';
+ }
+ generatePredefinedTopology();
+ });
+
+ nodesInput.addEventListener('input', () => {
+ generatePredefinedTopology();
+ });
+
+ probabilitySelect.addEventListener('change', () => {
+ if (topologySelect.value === 'Random') {
+ generatePredefinedTopology();
+ }
+ });
+ }
+
+ function generatePredefinedTopology() {
+ const topologyType = document.getElementById('predefined-topology-select').value;
+ const N = parseInt(document.getElementById('predefined-topology-nodes').value) || 3;
+ let probability = 0.5; // default value
+
+ if (topologyType === 'Random') {
+ const probSelect = document.getElementById('random-probability');
+ probability = parseFloat(probSelect.value);
+ }
+
+ // Create nodes with roles based on topology type
+ gData.nodes = [...Array(N).keys()].map(i => {
+ let role;
+ switch(topologyType) {
+ case 'Fully':
+ role = "aggregator";
+ break;
+ case 'Star':
+ role = i === 0 ? "server" : "trainer";
+ break;
+ case 'Ring':
+ role = "aggregator";
+ break;
+ case 'Random':
+ role = "aggregator";
+ break;
+ default:
+ role = i === 0 ? "aggregator" : "trainer";
+ }
+
+ return {
+ id: i,
+ ip: "127.0.0.1",
+ port: (45000 + i).toString(),
+ role: role,
+ malicious: false,
+ proxy: false,
+ start: (i === 0),
+ neighbors: [],
+ links: [],
+ };
+ });
+
+ // Create links based on topology type
+ gData.links = [];
+ switch(topologyType) {
+ case 'Fully':
+ for (let i = 0; i < N; i++) {
+ for (let j = i + 1; j < N; j++) {
+ gData.links.push({ source: i, target: j });
+ }
+ }
+ break;
+ case 'Ring':
+ for (let i = 0; i < N; i++) {
+ gData.links.push({ source: i, target: (i + 1) % N });
+ }
+ break;
+ case 'Star':
+ for (let i = 1; i < N; i++) {
+ gData.links.push({ source: 0, target: i });
+ }
+ break;
+ case 'Random':
+ for (let i = 0; i < N; i++) {
+ for (let j = i + 1; j < N; j++) {
+ if (Math.random() < probability) {
+ gData.links.push({ source: i, target: j });
+ }
+ }
+ }
+ break;
+ }
+
+ // After generating topology, assign roles based on federation architecture
+ assignRolesByFederationArchitecture();
+
+ // Update graph visualization
+ if (Graph) {
+ updateGraph();
+ } else {
+ const containerId = '3d-graph';
+ Graph = ForceGraph3D()(document.getElementById(containerId))
+ .graphData(gData)
+ .nodeThreeObject(createNodeObject)
+ .showNavInfo(false)
+ .width(document.getElementById(containerId).offsetWidth)
+ .height(document.getElementById(containerId).offsetHeight)
+ .backgroundColor('#ffffff')
+ .nodeLabel(node => `ID: ${node.id} | Role: ${node.role} | ${node.malicious ? 'Malicious' : 'Honest'}
`)
+ .onNodeRightClick(handleNodeRightClick)
+ .onNodeClick(handleNodeClick)
+ .onBackgroundClick(handleBackgroundClick)
+ .onLinkClick(handleLinkClick)
+ .linkColor(link => link.color || '#999')
+ .linkWidth(2)
+ .linkOpacity(0.6)
+ .linkDirectionalParticles(2)
+ .linkDirectionalParticleWidth(2)
+ .linkDirectionalParticleSpeed(0.005)
+ .linkCurvature(0.25)
+ .linkDirectionalParticleResolution(2)
+ .linkDirectionalParticleColor(() => '#ff0000');
+ }
+
+ // Update neighbors
+ updateNeighbors();
+ // Emit event when graph data changes
+ emitGraphDataUpdated();
+ }
+
+ function updateGraphData(newData) {
+ gData = newData;
+ Graph.graphData(gData);
+ emitGraphDataUpdated();
+ }
+
+ function emitGraphDataUpdated() {
+ document.dispatchEvent(new CustomEvent('graphDataUpdated'));
+ }
+
+ function handleNodeRightClick(node, event) {
+ const dropdown = document.getElementById("node-dropdown");
+ dropdown.innerHTML = "";
+ dropdown.style.display = "block";
+ dropdown.style.left = event.clientX + "px";
+ dropdown.style.top = event.clientY + "px";
+ dropdown.style.position = "absolute";
+ dropdown.style.zIndex = "1000";
+ dropdown.style.backgroundColor = "white";
+ dropdown.style.border = "1px solid black";
+ dropdown.style.padding = "10px";
+ dropdown.style.borderRadius = "5px";
+ dropdown.style.boxShadow = "0 0 10px rgba(0,0,0,0.5)";
+ dropdown.setAttribute("data-id", node.id);
+
+ const title = document.createElement("h5");
+ title.innerHTML = "Node " + node.id;
+ dropdown.appendChild(title);
+
+ createDropdownOptions(dropdown, node);
+ }
+
+ function createDropdownOptions(dropdown, node) {
+ const options = [
+ {
+ label: "Add node",
+ icon: "fa-plus",
+ handler: () => addNode(node),
+ condition: () => document.getElementById("federationArchitecture").value !== "CFL"
+ },
+ {
+ label: "Remove node",
+ icon: "fa-minus",
+ handler: () => removeNode(node),
+ condition: () => document.getElementById("federationArchitecture").value !== "CFL"
+ },
+ {
+ label: "Change role",
+ icon: "fa-refresh",
+ handler: () => changeRole(node),
+ condition: () => document.getElementById("federationArchitecture").value !== "CFL"
+ },
+ {
+ label: "Change malicious",
+ icon: "fa-exclamation-triangle",
+ handler: () => changeMalicious(node),
+ condition: () => true
+ },
+ {
+ label: "Change proxy",
+ icon: "fa-exchange",
+ handler: () => changeProxy(node),
+ condition: () => document.getElementById("federationArchitecture").value !== "CFL"
+ }
+ ];
+
+ options.forEach(option => {
+ if (option.condition()) {
+ const element = createDropdownElement(option.label, option.icon, option.handler);
+ dropdown.appendChild(element);
+ }
+ });
+ }
+
+ function createDropdownElement(label, icon, handler) {
+ const element = document.createElement("a");
+ element.innerHTML = ` ${label}`;
+ element.style.display = "block";
+ element.style.cursor = "pointer";
+ element.style.padding = "5px";
+ element.style.borderRadius = "5px";
+ element.style.marginBottom = "5px";
+ element.classList.add("dropdown-item");
+ element.addEventListener("click", handler);
+ return element;
+ }
+
+ function handleNodeClick(node) {
+ if (!selectedNodes.has(node)) {
+ selectedNodes.add(node);
+ } else {
+ selectedNodes.delete(node);
+ }
+
+ // Force graph update to show color change
+ Graph.nodeThreeObject(node => createNodeObject(node));
+
+ if (selectedNodes.size === 2) {
+ const [a, b] = selectedNodes;
+ if (document.getElementById("federationArchitecture").value !== "CFL") {
+ const link = { source: a, target: b };
+ a.neighbors.push(b.id);
+ b.neighbors.push(a.id);
+ gData.links.push(link);
+ }
+ selectedNodes.clear();
+ // Force another update after clearing selection
+ Graph.nodeThreeObject(node => createNodeObject(node));
+ }
+ updateGraph();
+ }
+
+ function handleLinkClick(link) {
+ if (document.getElementById("federationArchitecture").value === "CFL") {
+ return;
+ }
+
+ // Find and remove both directional links
+ const source = typeof link.source === 'object' ? link.source.id : link.source;
+ const target = typeof link.target === 'object' ? link.target.id : link.target;
+
+ gData.links = gData.links.filter(l => {
+ const lSource = typeof l.source === 'object' ? l.source.id : l.source;
+ const lTarget = typeof l.target === 'object' ? l.target.id : l.target;
+ return !((lSource === source && lTarget === target) || (lSource === target && lTarget === source));
+ });
+
+ // Remove from neighbors
+ gData.nodes[source].neighbors = gData.nodes[source].neighbors.filter(id => id !== target);
+ gData.nodes[target].neighbors = gData.nodes[target].neighbors.filter(id => id !== source);
+
+ updateGraph();
+ }
+
+ function addNode(sourceNode) {
+ document.getElementById("custom-topology-btn").checked = true;
+ document.getElementById("predefined-topology").style.display = "none";
+
+ const newNode = {
+ id: gData.nodes.length,
+ ip: "127.0.0.1",
+ port: "45000",
+ role: 'aggregator',
+ malicious: false,
+ proxy: false,
+ start: false,
+ neighbors: [sourceNode.id],
+ links: []
+ };
+
+ sourceNode.neighbors.push(newNode.id);
+ gData.nodes.push(newNode);
+ gData.links.push({ source: newNode.id, target: sourceNode.id });
+ updateGraph();
+ }
+
+ function removeNode(node) {
+ if (gData.nodes.length <= 1) return;
+
+ document.getElementById("custom-topology-btn").checked = true;
+ document.getElementById("predefined-topology").style.display = "none";
+
+ // Remove links connected to this node
+ gData.links = gData.links.filter(l =>
+ l.source.id !== node.id && l.target.id !== node.id
+ );
+
+ // Remove node and update IDs
+ gData.nodes = gData.nodes.filter(n => n.id !== node.id);
+ gData.nodes.forEach((n, idx) => {
+ n.id = idx;
+ n.neighbors = n.neighbors.filter(id => id !== node.id)
+ .map(id => id > node.id ? id - 1 : id);
+ });
+
+ updateGraph();
+ }
+
+ function changeRole(node) {
+ node.role = node.role === 'trainer' ? 'aggregator' : 'trainer';
+ updateGraph();
+ }
+
+ function changeMalicious(node) {
+ document.getElementById("malicious-nodes-select").value = "Manual";
+ document.getElementById("malicious-nodes-select").dispatchEvent(new Event('change'));
+ node.malicious = !node.malicious;
+ // Force complete graph update
+ if (Graph) {
+ Graph.nodeThreeObject(node => createNodeObject(node));
+ Graph.graphData(gData);
+ }
+ updateGraph();
+ }
+
+ function changeProxy(node) {
+ node.proxy = !node.proxy;
+ // Force complete graph update
+ if (Graph) {
+ Graph.nodeThreeObject(node => createNodeObject(node));
+ Graph.graphData(gData);
+ }
+ updateGraph();
+ }
+
+ function createNodeObject(node) {
+ let geometry;
+ let main_color;
+
+ if (node.malicious) {
+ geometry = new THREE.TorusGeometry(5, 2, 16, 100);
+ main_color = "#000000";
+ } else {
+ switch (node.role) {
+ case 'aggregator':
+ geometry = new THREE.SphereGeometry(5);
+ main_color = "#d95f02";
+ break;
+ case 'trainer':
+ geometry = new THREE.ConeGeometry(5, 12);
+ main_color = "#7570b3";
+ break;
+ case 'server':
+ geometry = new THREE.BoxGeometry(10, 10, 10);
+ main_color = "#1b9e77";
+ break;
+ default:
+ break;
+ }
+ }
+
+ const isSelected = selectedNodes.has(node);
+ const color = isSelected ? '#ff0000' : main_color;
+
+ const mesh = new THREE.Mesh(
+ geometry,
+ new THREE.MeshLambertMaterial({
+ color: color,
+ transparent: false,
+ opacity: 0.75
+ })
+ );
+
+ if (node.proxy) {
+ // Add proxy indicator
+ const sprite = createProxySprite();
+ mesh.add(sprite);
+ }
+
+ return mesh;
+ }
+
+ function createProxySprite() {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ context.font = '80px Arial';
+ context.fillText('PROXY', 0, 70);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ const spriteMaterial = new THREE.SpriteMaterial({ map: texture });
+ const sprite = new THREE.Sprite(spriteMaterial);
+ sprite.scale.set(10, 10 * 0.7, 5);
+ sprite.position.set(0, 5, 0);
+
+ return sprite;
+ }
+
+ function updateGraph() {
+ gDataUpdate();
+ if (Graph) {
+ Graph.graphData(gData);
+ // Update link visualization
+ Graph.linkColor(link => link.color || '#999')
+ .linkWidth(2)
+ .linkOpacity(0.6)
+ .linkDirectionalParticles(2)
+ .linkDirectionalParticleWidth(3)
+ .linkDirectionalParticleSpeed(0.01)
+ .linkCurvature(0.25)
+ .linkDirectionalParticleResolution(2)
+ .linkDirectionalParticleColor(() => '#ff0000');
+ }
+ }
+
+ function gDataUpdate() {
+ // Remove duplicated links
+ removeDuplicateLinks();
+ // Update neighbors
+ updateNeighbors();
+ // Update IPs and ports
+ updateIPsAndPorts();
+ }
+
+ function removeDuplicateLinks() {
+ for (let i = 0; i < gData.links.length; i++) {
+ for (let j = i + 1; j < gData.links.length; j++) {
+ if ((gData.links[i].source === gData.links[j].source && gData.links[i].target === gData.links[j].target) ||
+ (gData.links[i].source === gData.links[j].target && gData.links[i].target === gData.links[j].source)) {
+ gData.links.splice(j, 1);
+ }
+ }
+ }
+ }
+
+ function updateNeighbors() {
+ gData.links.forEach(link => {
+ for (let node of gData.nodes) {
+ if (node.id === link.source) {
+ node.neighbors.push(link.target);
+ }
+ if (node.id === link.target) {
+ node.neighbors.push(link.source);
+ }
+ }
+ });
+
+ // Clean up neighbors
+ for (let node of gData.nodes) {
+ node.neighbors = [...new Set(node.neighbors)].filter(id => id !== node.id);
+ }
+ }
+
+ function updateIPsAndPorts() {
+ const isProcess = document.getElementById("process-radio").checked;
+ const baseIP = "192.168.50";
+
+ gData.nodes.forEach((node, index) => {
+ node.ip = isProcess ? "127.0.0.1" : `${baseIP}.${index + 2}`;
+ node.port = (45001 + index).toString();
+ });
+ }
+
+ function getMatrix() {
+ const matrix = Array(gData.nodes.length).fill().map(() => Array(gData.nodes.length).fill(0));
+
+ gData.links.forEach(link => {
+ const source = typeof link.source === 'object' ? link.source.id : link.source;
+ const target = typeof link.target === 'object' ? link.target.id : link.target;
+ matrix[source][target] = 1;
+ matrix[target][source] = 1;
+ });
+
+ // Ensure diagonal is 0
+ for (let i = 0; i < matrix.length; i++) {
+ matrix[i][i] = 0;
+ }
+
+ return matrix;
+ }
+
+ function handleBackgroundClick() {
+ // Clear selected nodes
+ selectedNodes.clear();
+ // Hide node dropdown if visible
+ const dropdown = document.getElementById("node-dropdown");
+ if (dropdown) {
+ dropdown.style.display = "none";
+ }
+ // Update graph to reflect changes
+ updateGraph();
+ }
+
+ function assignRolesByFederationArchitecture() {
+ const federationType = document.getElementById("federationArchitecture").value;
+ const nodes = gData.nodes;
+
+ if (nodes.length === 0) return;
+
+ switch (federationType) {
+ case "CFL":
+ // First node as server, rest as trainers
+ nodes[0].role = "server";
+ for (let i = 1; i < nodes.length; i++) {
+ nodes[i].role = "trainer";
+ }
+ break;
+
+ case "SDFL":
+ // All as trainers except one random node as aggregator
+ const randomIndex = Math.floor(Math.random() * nodes.length);
+ for (let i = 0; i < nodes.length; i++) {
+ nodes[i].role = i === randomIndex ? "aggregator" : "trainer";
+ }
+ break;
+
+ case "DFL":
+ // All as aggregators
+ for (let i = 0; i < nodes.length; i++) {
+ nodes[i].role = "aggregator";
+ }
+ break;
+ }
+
+ // Force complete graph update
+ if (Graph) {
+ Graph.nodeThreeObject(node => createNodeObject(node));
+ Graph.graphData(gData);
+ }
+ updateGraph();
+ }
+
+ // Add event listener for federation architecture changes
+ document.getElementById("federationArchitecture").addEventListener("change", function() {
+ assignRolesByFederationArchitecture();
+ });
+
+ return {
+ initializeGraph,
+ updateGraphData,
+ getGraph: () => Graph,
+ getData: () => gData,
+ setData: (data) => {
+ // Ensure data has the required structure
+ if (!data || !data.nodes || !data.links) {
+ // If data is invalid, generate a new predefined topology
+ generatePredefinedTopology();
+ return;
+ }
+
+ // Ensure each node has the required properties
+ data.nodes = data.nodes.map(node => ({
+ id: node.id,
+ role: node.role || 'trainer',
+ malicious: node.malicious || false,
+ proxy: node.proxy || false,
+ neighbors: node.neighbors || [],
+ links: node.links || []
+ }));
+
+ // Ensure each link has the required properties
+ data.links = data.links.map(link => ({
+ source: link.source,
+ target: link.target
+ }));
+
+ gData = data;
+ updateGraph();
+ },
+ getMatrix,
+ generatePredefinedTopology,
+ updateGraph
+ };
+})();
+
+export default TopologyManager;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/ui-controls.js b/nebula/frontend/static/js/deployment/ui-controls.js
new file mode 100644
index 000000000..c9db4f2f4
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/ui-controls.js
@@ -0,0 +1,627 @@
+// UI Controls Module
+const UIControls = (function() {
+ function initializeUIControls() {
+ setupModeButton();
+ setupPartitionControls();
+ setupMethodTip();
+ setupReputationControls();
+ setupActionButtons();
+ setupDeploymentButtons();
+ setupParticipantDisplay();
+ setupParticipantModal();
+ setupConfigButtons();
+ // Initialize help icons
+ window.HelpContent.initializePopovers();
+ }
+
+ function setupModeButton() {
+ const modeBtn = document.getElementById('mode-btn');
+ const expertContainer = document.getElementById('expert-container');
+ const loggingLevel = document.getElementById('loggingLevel');
+
+ if (modeBtn) {
+ modeBtn.addEventListener('click', function() {
+ const isAdvancedMode = modeBtn.innerHTML.trim() === "Advanced mode";
+
+ if (isAdvancedMode) {
+ // Switch to advanced mode
+ modeBtn.innerHTML = "User mode";
+ modeBtn.classList.remove("btn-dark");
+ modeBtn.classList.add("btn-light");
+ expertContainer.style.display = "block";
+ expertContainer.style.visibility = "visible";
+ expertContainer.style.opacity = "1";
+ expertContainer.style.transition = "all 0.5s ease-in-out";
+ loggingLevel.value = "true";
+ } else {
+ // Switch back to user mode
+ resetModeBtn();
+ }
+ });
+ }
+ }
+
+ function resetModeBtn() {
+ const modeBtn = document.getElementById('mode-btn');
+ const expertContainer = document.getElementById('expert-container');
+ const loggingLevel = document.getElementById('loggingLevel');
+
+ modeBtn.innerHTML = "Advanced mode";
+ modeBtn.classList.remove("btn-light");
+ modeBtn.classList.add("btn-dark");
+ expertContainer.style.display = "none";
+ expertContainer.style.visibility = "hidden";
+ expertContainer.style.opacity = "0";
+ expertContainer.style.transition = "all 0.5s ease-in-out";
+ loggingLevel.value = "false";
+ }
+
+ function setupPartitionControls() {
+ const iidSelect = document.getElementById("iidSelect");
+ const partitionSelect = document.getElementById("partitionSelect");
+ const partitionBlock = document.getElementById("partitionBlock");
+ const partitionParameter = document.getElementById("partitionParameter");
+
+ iidSelect.addEventListener("change", function() {
+ if (iidSelect.value === "false") {
+ // Set up for non-IID
+ partitionSelect.options[0].selected = true;
+ partitionSelect.options[2].selected = false;
+ partitionSelect.options[0].disabled = false;
+ partitionSelect.options[1].disabled = false;
+ partitionSelect.options[2].disabled = true;
+ partitionSelect.options[3].disabled = true;
+
+ partitionSelect.options[0].style.display = "block";
+ partitionSelect.options[1].style.display = "block";
+ partitionSelect.options[2].style.display = "none";
+ partitionSelect.options[3].style.display = "none";
+ partitionBlock.style.display = "block";
+ } else {
+ // Set up for IID
+ partitionSelect.options[0].selected = false;
+ partitionSelect.options[2].selected = true;
+ partitionSelect.options[0].disabled = true;
+ partitionSelect.options[1].disabled = true;
+ partitionSelect.options[2].disabled = false;
+ partitionSelect.options[3].disabled = false;
+
+ partitionSelect.options[0].style.display = "none";
+ partitionSelect.options[1].style.display = "none";
+ partitionSelect.options[2].style.display = "block";
+ partitionSelect.options[3].style.display = "block";
+ partitionBlock.style.display = "none";
+ }
+ });
+
+ partitionSelect.addEventListener("change", function() {
+ switch(partitionSelect.value) {
+ case "balancediid":
+ partitionBlock.style.display = "none";
+ partitionParameter.value = "0.0";
+ break;
+ case "unbalancediid":
+ partitionBlock.style.display = "block";
+ partitionParameter.value = "2";
+ partitionParameter.step = "0.1";
+ partitionParameter.min = "1";
+ break;
+ case "dirichlet":
+ partitionBlock.style.display = "block";
+ partitionParameter.value = "0.5";
+ partitionParameter.step = "0.1";
+ partitionParameter.min = "0.1";
+ break;
+ case "percentage":
+ partitionBlock.style.display = "block";
+ partitionParameter.value = "50";
+ partitionParameter.step = "1";
+ partitionParameter.min = "10";
+ partitionParameter.max = "100";
+ break;
+ }
+ });
+ }
+
+ function setupMethodTip() {
+ const methodtip = document.getElementById('methodtip');
+ const methodtipImage = document.getElementById('methodtipImage');
+ const partitionSelect = document.getElementById('partitionSelect');
+
+ methodtip.addEventListener('mouseover', function() {
+ const imageMap = {
+ "dirichlet": "dirichlet_noniid.png",
+ "percent": "percentage.png",
+ "balancediid": "balancediid.png",
+ "unbalancediid": "unbalanceiid.png"
+ };
+
+ const imageName = imageMap[partitionSelect.value];
+ if (imageName) {
+ methodtipImage.src = `/static/images/${imageName}`;
+ methodtipImage.style.display = "block";
+ }
+ });
+
+ methodtip.addEventListener('mouseout', function() {
+ methodtipImage.style.display = "none";
+ });
+ }
+
+ function setupReputationControls() {
+ const reputationSwitch = document.getElementById("reputationSwitch");
+ const initialReputation = document.getElementById("initial-reputation");
+ const weightingFactor = document.getElementById("weighting-factor");
+ const weightInputs = document.querySelectorAll(".weight-input");
+
+ reputationSwitch.addEventListener("change", function() {
+ const elements = ["reputation-metrics", "reputation-settings", "weighting-settings"];
+ elements.forEach(id => {
+ document.getElementById(id).style.display = this.checked ? "block" : "none";
+ });
+ });
+
+ initialReputation.addEventListener("blur", function() {
+ const min = parseFloat(this.min);
+ const max = parseFloat(this.max);
+ const value = parseFloat(this.value);
+
+ if (value < min) this.value = min;
+ else if (value > max) this.value = max;
+ });
+
+ weightingFactor.addEventListener("change", function() {
+ const showWeights = this.value === "static";
+ weightInputs.forEach(input => {
+ input.style.display = showWeights ? "inline-block" : "none";
+ });
+ });
+
+ weightInputs.forEach(input => {
+ input.addEventListener("input", validateWeights);
+ });
+ }
+
+ function validateWeights() {
+ let totalWeight = 0;
+ document.querySelectorAll(".weight-input").forEach(input => {
+ const checkbox = input.previousElementSibling.previousElementSibling;
+ if (checkbox.checked && input.style.display !== "none" && input.value) {
+ totalWeight += parseFloat(input.value);
+ }
+ });
+ document.getElementById("weight-warning").style.display = totalWeight > 1 ? "block" : "none";
+ }
+
+ function setupActionButtons() {
+ // Add button
+ const addBtn = document.getElementById('add-btn');
+ if (addBtn) {
+ addBtn.addEventListener('click', function() {
+ window.ScenarioManager.saveScenario();
+ updateButtonVisibility();
+ });
+ }
+
+ // Delete button
+ const delBtn = document.getElementById('del-btn');
+ if (delBtn) {
+ delBtn.addEventListener('click', function() {
+ window.ScenarioManager.deleteScenario();
+ if (window.ScenarioManager.getScenariosList().length < 1) {
+ window.ScenarioManager.clearFields();
+ window.ScenarioManager.updateScenariosPosition(true);
+ updateButtonVisibility(true);
+ } else {
+ window.ScenarioManager.updateScenariosPosition();
+ updateButtonVisibility();
+ }
+ });
+ }
+
+ // Previous button
+ const prevBtn = document.getElementById('prev-btn');
+ if (prevBtn) {
+ prevBtn.addEventListener('click', function() {
+ window.ScenarioManager.replaceScenario();
+ window.ScenarioManager.setActualScenario(window.ScenarioManager.getActualScenario() - 1);
+ window.ScenarioManager.updateScenariosPosition();
+ updateButtonVisibility();
+ });
+ }
+
+ // Next button
+ const nextBtn = document.getElementById('next-btn');
+ if (nextBtn) {
+ nextBtn.addEventListener('click', function() {
+ window.ScenarioManager.replaceScenario();
+ window.ScenarioManager.setActualScenario(window.ScenarioManager.getActualScenario() + 1);
+ window.ScenarioManager.updateScenariosPosition();
+ updateButtonVisibility();
+ });
+ }
+ }
+
+ function updateButtonVisibility(isEmptyScenario = false) {
+ const prevBtn = document.getElementById("prev-btn");
+ const nextBtn = document.getElementById("next-btn");
+ const addBtn = document.getElementById("add-btn");
+ const delBtn = document.getElementById("del-btn");
+ const runBtn = document.getElementById("run-btn");
+
+ if (isEmptyScenario) {
+ prevBtn.style.display = "none";
+ nextBtn.style.display = "none";
+ delBtn.style.display = "none";
+ runBtn.disabled = true;
+ addBtn.style.display = "inline-block";
+ return;
+ }
+
+ const scenarioCount = window.ScenarioManager.getScenariosList().length;
+ const currentScenario = window.ScenarioManager.getActualScenario();
+
+ prevBtn.style.display = currentScenario > 0 ? "inline-block" : "none";
+ nextBtn.style.display = currentScenario < scenarioCount - 1 ? "inline-block" : "none";
+ addBtn.style.display = currentScenario === scenarioCount - 1 ? "inline-block" : "none";
+ delBtn.style.display = "inline-block";
+ runBtn.disabled = false;
+ }
+
+ function setupDeploymentButtons() {
+ const runBtn = document.getElementById('run-btn');
+ if (runBtn) {
+ runBtn.addEventListener('click', handleDeployment);
+ }
+ }
+
+ async function handleDeployment() {
+ const confirmModal = document.getElementById('confirm-modal');
+ const confirmModalBody = document.getElementById('confirm-modal-body');
+ const yesButton = document.getElementById("yes-button");
+
+ if (!document.querySelector(".participant-started")) {
+ confirmModalBody.innerHTML = 'Please select one "start" participant for the scenario';
+ yesButton.disabled = true;
+ const modal = new bootstrap.Modal(confirmModal);
+ modal.show();
+ return;
+ }
+
+ const deploymentOption = document.querySelector('input[name="deploymentRadioOptions"]:checked');
+ confirmModalBody.innerHTML = `Are you sure you want to run the scenario?
+
The scenario will be deployed using the selected deployment option: ${deploymentOption.value}
+
Warning: you will stop the running scenario and start a new one
`;
+ yesButton.disabled = false;
+
+ const modal = new bootstrap.Modal(confirmModal);
+ modal.show();
+
+ yesButton.onclick = async () => {
+ // If no scenarios exist, save the current one first
+ if (window.ScenarioManager.getScenariosList().length < 1) {
+ window.ScenarioManager.saveScenario();
+ } else {
+ window.ScenarioManager.replaceScenario();
+ }
+
+ // Ensure all scenarios have a title
+ window.ScenarioManager.getScenariosList().forEach((scenario, index) => {
+ if (!scenario.scenario_title) {
+ scenario.scenario_title = "empty";
+ }
+ if (!scenario.scenario_description) {
+ scenario.scenario_description = "empty";
+ }
+ });
+
+ modal.hide();
+ document.querySelector(".overlay").style.display = "block";
+ document.getElementById("spinner").style.display = "block";
+
+ try {
+ const response = await fetch("/platform/dashboard/deployment/run", {
+ method: "POST",
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(window.ScenarioManager.getScenariosList())
+ });
+
+ if (response.redirected) {
+ window.location.href = response.url;
+ } else if (!response.ok) {
+ handleDeploymentError(response.status);
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ hideLoadingIndicators();
+ handleDeploymentError(500, error);
+ } finally {
+ hideLoadingIndicators();
+ }
+ };
+ }
+
+ function handleDeploymentError(status, error = null) {
+ hideLoadingIndicators();
+ let errorMessage;
+
+ switch(status) {
+ case 401:
+ errorMessage = "You are not authorized to run a scenario. Please log in.";
+ break;
+ case 503:
+ errorMessage = "Not enough resources to run a scenario. Please try again later.";
+ break;
+ default:
+ errorMessage = "An unexpected error occurred. See console for more details.";
+ }
+ if (error) {
+ console.error('Error:', error);
+ }
+ showErrorModal(errorMessage);
+ }
+
+ function showErrorModal(message) {
+ const infoModal = document.getElementById('info-modal');
+ const infoModalBody = document.getElementById('info-modal-body');
+ infoModalBody.innerHTML = message;
+ const modal = new bootstrap.Modal(infoModal);
+
+ // Add event listener for when modal is hidden
+ infoModal.addEventListener('hidden.bs.modal', function () {
+ document.querySelector(".overlay").style.display = "none";
+ // Remove the modal backdrop
+ const backdrop = document.querySelector('.modal-backdrop');
+ if (backdrop) {
+ backdrop.remove();
+ }
+ // Remove the modal-open class from body
+ document.body.classList.remove('modal-open');
+ document.body.style.overflow = '';
+ document.body.style.paddingRight = '';
+ });
+
+ modal.show();
+ }
+
+ function hideLoadingIndicators() {
+ document.querySelector(".overlay").style.display = "none";
+ document.getElementById("spinner").style.display = "none";
+ }
+
+ function setupParticipantDisplay() {
+ // Initial update of participants
+ updateParticipantDisplay();
+
+ // Listen for graph data changes
+ document.addEventListener('graphDataUpdated', updateParticipantDisplay);
+ }
+
+ function updateParticipantDisplay() {
+ const participantItems = document.getElementById("participant-items");
+ if (!participantItems) return;
+
+ const graph = window.TopologyManager.getGraph();
+ if (!graph) return;
+
+ const nodes = graph.graphData().nodes;
+ const numberOfNodes = nodes.length;
+
+ // Update the info-participants number
+ const infoParticipantsNumber = document.getElementById("info-participants-number");
+ if (infoParticipantsNumber) {
+ infoParticipantsNumber.innerHTML = numberOfNodes;
+ }
+
+ // Clear existing participants
+ participantItems.innerHTML = "";
+
+ // Create participant items
+ nodes.forEach((node, i) => {
+ const participantItem = createParticipantItem(node, i);
+ participantItems.appendChild(participantItem);
+ });
+
+ // If there is no participant-started, add the class to the first participant
+ if (document.getElementsByClassName("participant-started").length === 0 && nodes.length > 0) {
+ const firstStartBtn = document.getElementById("participant-0-start-btn");
+ if (firstStartBtn) {
+ firstStartBtn.classList.add("participant-started");
+ firstStartBtn.title = `Participant 0 (start node)`;
+ nodes[0].start = true;
+ }
+ }
+ }
+
+ function createParticipantItem(node, index) {
+ const participantItem = document.createElement("div");
+ participantItem.classList.add("col-md-2");
+ participantItem.classList.add("participant-item");
+
+ // Create participant image
+ const participantImg = document.createElement("img");
+ participantImg.id = `participant-img-${index}`;
+ participantImg.setAttribute("data-id", index.toString());
+ participantImg.src = "/static/images/device.png";
+ participantImg.width = 50;
+ participantImg.height = 50;
+ participantImg.style.marginRight = "10px";
+
+ // Create label
+ const label = document.createElement("label");
+ label.setAttribute("for", `participant-${index}`);
+ label.setAttribute("data-id", index.toString());
+ label.innerHTML = `Participant ${index}`;
+ label.style.marginRight = "10px";
+
+ // Create info button
+ const infoBtn = createInfoButton(node, index);
+
+ // Create start button
+ const startBtn = createStartButton(node, index);
+
+ // Add all elements to participant item
+ participantItem.appendChild(participantImg);
+ participantItem.appendChild(label);
+ participantItem.appendChild(infoBtn);
+ participantItem.appendChild(startBtn);
+
+ return participantItem;
+ }
+
+ function createInfoButton(node, index) {
+ const infoBtn = document.createElement("button");
+ infoBtn.id = `participant-${index}-btn`;
+ infoBtn.setAttribute("data-id", index.toString());
+ infoBtn.type = "button";
+ infoBtn.classList.add("btn", "btn-info-participant");
+ infoBtn.innerHTML = "Details";
+ infoBtn.style.border = "none";
+ infoBtn.style.cursor = "pointer";
+
+ infoBtn.addEventListener("click", () => {
+ showParticipantDetails(node, index);
+ });
+
+ return infoBtn;
+ }
+
+ function createStartButton(node, index) {
+ const startBtn = document.createElement("button");
+ startBtn.id = `participant-${index}-start-btn`;
+ startBtn.setAttribute("data-id", index.toString());
+ startBtn.type = "button";
+ startBtn.classList.add("btn");
+ startBtn.innerHTML = "Start";
+ startBtn.style.marginLeft = "10px";
+ startBtn.style.border = "none";
+ startBtn.style.cursor = "pointer";
+
+ if (node.start) {
+ startBtn.classList.add("participant-started");
+ startBtn.title = `Participant ${index} (start node)`;
+ } else {
+ startBtn.classList.add("participant-not-started");
+ startBtn.title = `Participant ${index} (not start node)`;
+ }
+
+ startBtn.addEventListener("click", () => {
+ handleStartButtonClick(startBtn, node, index);
+ });
+
+ return startBtn;
+ }
+
+ function showParticipantDetails(node, index) {
+ const modalTitle = document.getElementById("participant-modal-title");
+ const modalContent = document.getElementById("participant-modal-content");
+
+ modalTitle.innerHTML = `Participant ${index}`;
+ modalContent.innerHTML = "";
+
+ // Add additional info
+ modalContent.innerHTML += `Neighbors: ${node.neighbors.length}
Role: ${node.role}
Start: ${node.start}`;
+
+ // Show modal
+ $('#participant-modal').modal('show');
+ }
+
+ function handleStartButtonClick(startBtn, node, index) {
+ const currentStarted = document.querySelector(".participant-started");
+ if (currentStarted) {
+ const graph = window.TopologyManager.getGraph();
+ if (!graph) return;
+
+ const nodes = graph.graphData().nodes;
+ const currentStartedNode = nodes[currentStarted.getAttribute("data-id")];
+ if (currentStartedNode) {
+ currentStartedNode.start = false;
+ currentStarted.classList.remove("participant-started");
+ currentStarted.classList.add("participant-not-started");
+ currentStarted.title = `Participant ${currentStarted.getAttribute("data-id")} (not start node)`;
+ }
+ }
+
+ startBtn.classList.remove("participant-not-started");
+ startBtn.classList.add("participant-started");
+ startBtn.title = `Participant ${index} (start node)`;
+ node.start = true;
+
+ // Update graph data
+ const graph = window.TopologyManager.getGraph();
+ if (graph) {
+ graph.graphData(graph.graphData());
+ }
+ }
+
+ function setupParticipantModal() {
+ const modal = document.getElementById('participant-modal');
+ const closeButton = modal.querySelector('.close');
+
+ closeButton.addEventListener('click', () => {
+ $(modal).modal('hide');
+ });
+ }
+
+ function setupConfigButtons() {
+ // Save configuration button
+ const saveConfigBtn = document.getElementById('save-config-btn');
+ if (saveConfigBtn) {
+ saveConfigBtn.addEventListener('click', function() {
+ const scenarioData = window.ScenarioManager.collectScenarioData();
+ const blob = new Blob([JSON.stringify(scenarioData, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${scenarioData.title || 'scenario'}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ });
+ }
+
+ // Load configuration button
+ const loadConfigBtn = document.getElementById('load-config-btn');
+ if (loadConfigBtn) {
+ loadConfigBtn.addEventListener('click', function() {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = function(e) {
+ const file = e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = function(e) {
+ try {
+ const scenarioData = JSON.parse(e.target.result);
+ window.ScenarioManager.loadScenarioData(scenarioData);
+ window.ScenarioManager.saveScenario();
+ window.ScenarioManager.updateScenariosPosition();
+ updateButtonVisibility();
+ } catch (error) {
+ console.error('Error loading configuration:', error);
+ alert('Error loading configuration file. Please make sure it is a valid JSON file.');
+ }
+ };
+ reader.readAsText(file);
+ }
+ };
+ input.click();
+ });
+ }
+ }
+
+ return {
+ initializeUIControls,
+ resetModeBtn,
+ updateButtonVisibility,
+ updateParticipantDisplay,
+ setupParticipantModal,
+ handleDeployment
+ };
+})();
+
+export default UIControls;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/deployment/utils.js b/nebula/frontend/static/js/deployment/utils.js
new file mode 100644
index 000000000..55e618494
--- /dev/null
+++ b/nebula/frontend/static/js/deployment/utils.js
@@ -0,0 +1,63 @@
+// Utility Functions Module
+const Utils = (function() {
+ function showAlert(type, message) {
+ // Implementation of alert display
+ console.log(`${type}: ${message}`);
+ }
+
+ function greaterThan0(input) {
+ const value = parseInt(input.value);
+ if(value < 1 && !isNaN(value)) {
+ input.value = 1;
+ }
+ }
+
+ function isInRange(input, min, max) {
+ const value = parseFloat(input.value);
+ if(isNaN(value)) {
+ input.value = min;
+ } else {
+ input.value = Math.min(Math.max(value, min), max);
+ }
+ }
+
+ function handleProbabilityChange(input) {
+ let value = parseFloat(input.value);
+ if (isNaN(value)) {
+ value = 0.5;
+ } else {
+ value = Math.min(Math.max(value, 0), 1);
+ }
+ input.value = value.toFixed(1);
+
+ // Trigger topology update if Random is selected
+ const topologySelect = document.getElementById('predefined-topology-select');
+ if (topologySelect && topologySelect.value === 'Random') {
+ window.TopologyManager.generatePredefinedTopology();
+ }
+ }
+
+ function atLeastOneChecked(checkboxIds) {
+ return checkboxIds.some(function(id) {
+ const checkbox = document.getElementById(id);
+ return checkbox && checkbox.checked;
+ });
+ }
+
+ function selectALL(checkboxIds, checked) {
+ for (let i = 0; i < checkboxIds.length; i++) {
+ document.getElementById(checkboxIds[i]).checked = checked;
+ }
+ }
+
+ return {
+ showAlert,
+ greaterThan0,
+ isInRange,
+ atLeastOneChecked,
+ selectALL,
+ handleProbabilityChange
+ };
+})();
+
+export default Utils;
\ No newline at end of file
diff --git a/nebula/frontend/static/js/monitor/monitor.js b/nebula/frontend/static/js/monitor/monitor.js
new file mode 100644
index 000000000..0da34f016
--- /dev/null
+++ b/nebula/frontend/static/js/monitor/monitor.js
@@ -0,0 +1,1289 @@
+// Monitor page functionality
+class Monitor {
+ constructor() {
+ // Get scenario name from URL path
+ const pathParts = window.location.pathname.split('/');
+ this.scenarioName = pathParts[pathParts.indexOf('dashboard') + 1];
+ this.offlineNodes = new Set();
+ this.droneMarkers = {};
+ this.droneLines = {};
+ this.updateQueue = [];
+ this.gData = {
+ nodes: [],
+ links: []
+ };
+ this.nodeTimestamps = new Map(); // Track last update time for each node
+
+ this.initializeMap();
+ this.initializeGraph();
+ this.initializeWebSocket();
+ this.initializeEventListeners();
+ this.initializeDownloadHandlers();
+ this.loadInitialData();
+
+ this.startStaleNodeCheck();
+ }
+
+ initializeMap() {
+ console.log('Initializing map...');
+ this.map = L.map('map', {
+ center: [38.023522, -1.174389],
+ zoom: 17,
+ minZoom: 2,
+ maxZoom: 18,
+ maxBounds: [[-90, -180], [90, 180]],
+ maxBoundsViscosity: 1.0,
+ zoomControl: true,
+ worldCopyJump: false,
+ });
+
+ console.log('Adding tile layer...');
+ L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
+ attribution: '© enriquetomasmb.com'
+ }).addTo(this.map);
+
+ // Initialize line layer
+ console.log('Initializing line layer...');
+ this.lineLayer = L.layerGroup().addTo(this.map);
+ console.log('Line layer added to map:', this.lineLayer);
+
+ // Initialize drone icons
+ console.log('Initializing drone icons...');
+ this.droneIcon = L.icon({
+ iconUrl: '/platform/static/images/drone.svg',
+ iconSize: [28, 28],
+ iconAnchor: [19, 19],
+ popupAnchor: [0, -19]
+ });
+
+ this.droneIconOffline = L.icon({
+ iconUrl: '/platform/static/images/drone_offline.svg',
+ iconSize: [28, 28],
+ iconAnchor: [19, 19],
+ popupAnchor: [0, -19]
+ });
+
+ // Add CSS to style the offline drone icon
+ const style = document.createElement('style');
+ style.textContent = `
+ .leaflet-marker-icon.drone-offline {
+ filter: brightness(0) saturate(100%) invert(15%) sepia(100%) saturate(5000%) hue-rotate(350deg) brightness(90%) contrast(100%);
+ }
+ `;
+ document.head.appendChild(style);
+
+ console.log('Map initialization complete');
+ }
+
+ initializeGraph() {
+ const width = document.getElementById('3d-graph').offsetWidth;
+
+ this.Graph = ForceGraph3D()(document.getElementById('3d-graph'))
+ .width(width)
+ .height(600)
+ .backgroundColor('#ffffff')
+ .nodeId('ipport')
+ .nodeLabel(node => this.createNodeLabel(node))
+ .onNodeClick(node => this.handleNodeClick(node))
+ .nodeThreeObject(node => this.createNodeObject(node))
+ .linkSource('source')
+ .linkTarget('target')
+ .linkColor(link => {
+ const sourceNode = this.gData.nodes.find(n => n.ipport === link.source);
+ const targetNode = this.gData.nodes.find(n => n.ipport === link.target);
+ return (sourceNode && this.offlineNodes.has(sourceNode.ipport)) ||
+ (targetNode && this.offlineNodes.has(targetNode.ipport)) ? '#ff0000' : '#999';
+ })
+ .linkOpacity(0.6)
+ .linkWidth(2)
+ .linkDirectionalParticles(2)
+ .linkDirectionalParticleSpeed(0.005)
+ .linkDirectionalParticleWidth(2)
+ .d3AlphaDecay(0.01) // Slower decay for smoother updates
+ .d3VelocityDecay(0.3) // Less damping for more dynamic movement
+ .warmupTicks(100) // Add warmup for better initial layout
+ .cooldownTicks(100) // Add cooldown for smoother transitions
+ .d3Force('center', d3.forceCenter().strength(0)) // Even stronger center force
+ .d3Force('charge', d3.forceManyBody().strength(0)) // Reduced repulsion
+ .d3Force('link', d3.forceLink().id(d => d.ipport).distance(30)); // Reduced link distance
+
+ this.Graph.cameraPosition({ x: 0, y: 0, z: 300 }, { x: 0, y: 0, z: 0 }, 0);
+
+ const navInfo = document.getElementsByClassName("scene-nav-info")[0];
+ if (navInfo) {
+ navInfo.style.display = 'none';
+ }
+
+ window.addEventListener("resize", () => {
+ this.Graph.width(document.getElementById('3d-graph').offsetWidth);
+ });
+ }
+
+ layoutNodes(nodes) {
+ const radius = 50; // Radius of the circle
+ const center = { x: 0, y: 0, z: 0 };
+
+ nodes.forEach((node, i) => {
+ const angle = (i / nodes.length) * 2 * Math.PI;
+ // Add a small offset to ensure nodes don't overlap at center
+ const offset = 5;
+ node.x = center.x + (radius + offset) * Math.cos(angle);
+ node.y = center.y + (radius + offset) * Math.sin(angle);
+ node.z = center.z;
+ });
+
+ return nodes;
+ }
+
+ loadInitialData() {
+ if (!this.scenarioName) {
+ console.error('No scenario name found in URL');
+ return;
+ }
+
+ console.log('Loading initial data for scenario:', this.scenarioName);
+ fetch(`/platform/api/dashboard/${this.scenarioName}/monitor`)
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then(data => {
+ console.log('Received initial data:', data);
+ this.processInitialData(data);
+ })
+ .catch(error => {
+ console.error('Error loading initial data:', error);
+ showAlert('danger', 'Error loading initial data. Please refresh the page.');
+ });
+ }
+
+ processInitialData(data) {
+ console.log('Processing initial data:', data);
+ if (!data.nodes_table) {
+ console.warn('No nodes table in initial data');
+ return;
+ }
+
+ // Clear existing data
+ this.gData.nodes = [];
+ this.gData.links = [];
+ this.droneMarkers = {};
+ this.droneLines = {};
+ this.lineLayer.clearLayers();
+ this.offlineNodes.clear(); // Clear offline nodes set
+ this.nodeTimestamps.clear(); // Clear node timestamps
+
+ // Create a map to track unique nodes by IP:port
+ const uniqueNodes = new Map();
+
+ // First pass: create all nodes and track offline nodes
+ data.nodes_table.forEach(node => {
+ try {
+ console.log('Processing node:', node);
+ const nodeData = {
+ uid: node[0],
+ idx: node[1],
+ ip: node[2],
+ port: node[3],
+ role: node[4],
+ neighbors: node[5] || "",
+ latitude: parseFloat(node[6]) || 0,
+ longitude: parseFloat(node[7]) || 0,
+ timestamp: node[8],
+ federation: node[9],
+ round: node[10],
+ malicious: node[13],
+ status: node[14]
+ };
+
+ console.log('Processed node data:', nodeData);
+
+ // Validate coordinates
+ if (isNaN(nodeData.latitude) || isNaN(nodeData.longitude)) {
+ console.warn('Invalid coordinates in initial data for node:', nodeData.uid);
+ // Use default coordinates if invalid
+ nodeData.latitude = 38.023522;
+ nodeData.longitude = -1.174389;
+ }
+
+ // Track offline nodes
+ if (!nodeData.status) {
+ this.offlineNodes.add(nodeData.ip);
+ console.log('Node marked as offline during initialization:', nodeData.ip);
+ }
+
+ // Set initial timestamp
+ const initialNodeId = `${nodeData.ip}:${nodeData.port}`;
+ this.nodeTimestamps.set(initialNodeId, Date.now());
+
+ // Update table
+ this.updateNode(nodeData);
+
+ // Update map
+ this.updateQueue.push(nodeData);
+ console.log('Added node to update queue:', nodeData.uid);
+
+ // Add node to graph data - ensure uniqueness
+ const uniqueNodeId = `${nodeData.ip}:${nodeData.port}`;
+ if (!uniqueNodes.has(uniqueNodeId)) {
+ uniqueNodes.set(uniqueNodeId, {
+ id: nodeData.idx,
+ ip: nodeData.ip,
+ port: nodeData.port,
+ ipport: uniqueNodeId,
+ role: nodeData.role,
+ color: this.getNodeColor({ ipport: uniqueNodeId, role: nodeData.role })
+ });
+ console.log('Added unique node:', uniqueNodeId);
+ } else {
+ console.log('Skipping duplicate node:', uniqueNodeId);
+ }
+ } catch (error) {
+ console.error('Error processing node data:', error);
+ }
+ });
+
+ // Convert unique nodes map to array
+ this.gData.nodes = Array.from(uniqueNodes.values());
+ console.log('Total unique nodes:', this.gData.nodes.length);
+
+ // Second pass: create links only between online nodes
+ console.log('Creating graph with', this.gData.nodes.length, 'nodes');
+ for (let i = 0; i < this.gData.nodes.length; i++) {
+ const sourceNode = this.gData.nodes[i];
+ const sourceIP = sourceNode.ip;
+
+ // Skip if source node is offline
+ if (this.offlineNodes.has(sourceIP)) {
+ console.log('Skipping links for offline source node:', sourceIP);
+ continue;
+ }
+
+ for (let j = i + 1; j < this.gData.nodes.length; j++) {
+ const targetNode = this.gData.nodes[j];
+ const targetIP = targetNode.ip;
+
+ // Skip if target node is offline
+ if (this.offlineNodes.has(targetIP)) {
+ console.log('Skipping link to offline target node:', targetIP);
+ continue;
+ }
+
+ // Add bidirectional links only between online nodes
+ this.gData.links.push({
+ source: sourceNode.ipport,
+ target: targetNode.ipport,
+ value: this.randomFloatFromInterval(1.0, 1.3)
+ });
+
+ this.gData.links.push({
+ source: targetNode.ipport,
+ target: sourceNode.ipport,
+ value: this.randomFloatFromInterval(1.0, 1.3)
+ });
+ }
+ }
+
+ // Process queue immediately
+ this.processQueue();
+
+ // Initial graph update
+ this.updateGraph();
+ console.log('Initial data processing complete. Total links:', this.gData.links.length);
+ }
+
+ updateGraphData(data) {
+ const nodeId = `${data.ip}:${data.port}`;
+ console.log('Updating graph data for node:', nodeId);
+
+ // Add or update node - ensure no duplication
+ const existingNodeIndex = this.gData.nodes.findIndex(n => n.ipport === nodeId);
+ if (existingNodeIndex === -1) {
+ // Only add if node doesn't exist
+ this.gData.nodes.push({
+ id: data.idx,
+ ip: data.ip,
+ port: data.port,
+ ipport: nodeId,
+ role: data.role,
+ color: this.getNodeColor({ ipport: nodeId, role: data.role })
+ });
+ console.log('Added new node:', nodeId);
+ } else {
+ // Update existing node
+ this.gData.nodes[existingNodeIndex] = {
+ ...this.gData.nodes[existingNodeIndex],
+ role: data.role,
+ color: this.getNodeColor({ ipport: nodeId, role: data.role })
+ };
+ console.log('Updated existing node:', nodeId);
+ }
+
+ // Helper function to get IP from source/target
+ const getIPFromLink = (linkEnd) => {
+ if (typeof linkEnd === 'string') {
+ return linkEnd.split(':')[0];
+ } else if (linkEnd && typeof linkEnd === 'object') {
+ return linkEnd.ipport ? linkEnd.ipport.split(':')[0] : linkEnd.ip;
+ }
+ return '';
+ };
+
+ // Normalize all existing links to use string IDs
+ this.gData.links = this.gData.links.map(link => ({
+ source: typeof link.source === 'object' ? link.source.ipport : link.source,
+ target: typeof link.target === 'object' ? link.target.ipport : link.target,
+ value: link.value
+ }));
+
+ // If node is offline, remove its links but preserve others
+ if (!data.status || this.offlineNodes.has(data.ip)) {
+ console.log('Node is offline, removing its links');
+ this.gData.links = this.gData.links.filter(link => {
+ const sourceIP = getIPFromLink(link.source);
+ const targetIP = getIPFromLink(link.target);
+ return sourceIP !== data.ip && targetIP !== data.ip;
+ });
+ return;
+ }
+
+ // For online nodes, update their connections
+ if (data.neighbors) {
+ // Parse neighbors using consistent format
+ const neighbors = data.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== '');
+ console.log('Processing neighbors:', neighbors);
+
+ // Get current links for this node
+ const currentLinks = this.gData.links.filter(link => {
+ const sourceIP = getIPFromLink(link.source);
+ const targetIP = getIPFromLink(link.target);
+ return sourceIP === data.ip || targetIP === data.ip;
+ });
+
+ // Remove links to offline neighbors
+ this.gData.links = this.gData.links.filter(link => {
+ if (getIPFromLink(link.source) === data.ip || getIPFromLink(link.target) === data.ip) {
+ const neighborId = getIPFromLink(link.source) === data.ip ? link.target : link.source;
+ const neighborIP = getIPFromLink(neighborId);
+ return !this.offlineNodes.has(neighborIP);
+ }
+ return true;
+ });
+
+ // Add new links to online neighbors
+ neighbors.forEach(neighbor => {
+ const neighborIP = neighbor.split(':')[0];
+ if (this.offlineNodes.has(neighborIP)) {
+ console.log('Skipping offline neighbor:', neighbor);
+ return;
+ }
+
+ const normalizedNeighbor = neighbor.includes(':') ? neighbor : `${neighbor}:${data.port}`;
+ const neighborNode = this.gData.nodes.find(n =>
+ n.ipport === normalizedNeighbor ||
+ n.ipport.split(':')[0] === neighborIP
+ );
+
+ if (neighborNode) {
+ // Check if link already exists
+ const linkExists = this.gData.links.some(link => {
+ const sourceIP = getIPFromLink(link.source);
+ const targetIP = getIPFromLink(link.target);
+ return (sourceIP === data.ip && targetIP === neighborIP) ||
+ (sourceIP === neighborIP && targetIP === data.ip);
+ });
+
+ if (!linkExists) {
+ console.log('Adding new link between', nodeId, 'and', normalizedNeighbor);
+ this.gData.links.push({
+ source: nodeId,
+ target: normalizedNeighbor,
+ value: this.randomFloatFromInterval(1.0, 1.3)
+ });
+ }
+ }
+ });
+ }
+
+ // Remove duplicate links
+ this.gData.links = this.gData.links.filter((link, index, self) =>
+ index === self.findIndex((l) => {
+ const sourceIP1 = getIPFromLink(link.source);
+ const targetIP1 = getIPFromLink(link.target);
+ const sourceIP2 = getIPFromLink(l.source);
+ const targetIP2 = getIPFromLink(l.target);
+ return (sourceIP1 === sourceIP2 && targetIP1 === targetIP2) ||
+ (sourceIP1 === targetIP2 && targetIP1 === sourceIP2);
+ })
+ );
+
+ console.log('Final links after update:', this.gData.links);
+ }
+
+ randomFloatFromInterval(min, max) {
+ return Math.random() * (max - min + 1) + min;
+ }
+
+ createNodeLabel(node) {
+ return `
+ ID: ${node.id}
+ IP: ${node.ipport}
+ Role: ${node.role}
+
`;
+ }
+
+ handleNodeClick(node) {
+ const distance = 40;
+ const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
+ const newPos = node.x || node.y || node.z
+ ? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }
+ : { x: 0, y: 0, z: distance };
+
+ this.Graph.cameraPosition(newPos, node, 3000);
+ }
+
+ createNodeObject(node) {
+ const group = new THREE.Group();
+ const nodeColor = this.getNodeColor(node);
+ const sphereRadius = 5;
+
+ const material = new THREE.MeshBasicMaterial({
+ color: nodeColor,
+ transparent: true,
+ opacity: 0.8,
+ });
+
+ const sphere = new THREE.Mesh(
+ new THREE.SphereGeometry(sphereRadius, 32, 32),
+ material
+ );
+ group.add(sphere);
+
+ const sprite = new THREE.Sprite(
+ new THREE.SpriteMaterial({
+ map: this.createTextTexture(`NODE ${node.id}`),
+ depthWrite: false,
+ depthTest: false
+ })
+ );
+
+ sprite.scale.set(10, 10 * 0.7, 5);
+ sprite.position.set(0, sphereRadius + 2, 0);
+ group.add(sprite);
+
+ return group;
+ }
+
+ getNodeColor(node) {
+ // Check if the node is offline using the IP
+ if (this.offlineNodes.has(node.ip)) {
+ return '#ff0000'; // Red color for offline nodes
+ }
+
+ switch(node.role) {
+ case 'trainer': return '#7570b3';
+ case 'aggregator': return '#d95f02';
+ case 'server': return '#1b9e77';
+ default: return '#68B0AB';
+ }
+ }
+
+ createTextTexture(text) {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ context.font = '40px Arial';
+ context.fillStyle = 'black';
+ context.fillText(text, 0, 40);
+
+ const texture = new THREE.Texture(canvas);
+ texture.needsUpdate = true;
+ return texture;
+ }
+
+ initializeWebSocket() {
+ if (!this.scenarioName) return;
+
+ socket.addEventListener("message", (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.scenario_name !== this.scenarioName) return;
+
+ switch(data.type) {
+ case 'node_update':
+ this.handleNodeUpdate(data);
+ break;
+ case 'node_remove':
+ this.handleNodeRemove(data);
+ break;
+ case 'control':
+ console.log('Control message received:', data);
+ break;
+ default:
+ console.log('Unknown message type:', data.type);
+ }
+ } catch (e) {
+ console.error('Error parsing WebSocket message:', e);
+ }
+ });
+ }
+
+ handleNodeUpdate(data) {
+ try {
+ // Validate required fields
+ if (!data.uid || !data.ip) {
+ console.warn('Missing required fields for node update:', data);
+ return;
+ }
+
+ // Update timestamp for this node
+ const nodeId = `${data.ip}:${data.port}`;
+ this.nodeTimestamps.set(nodeId, Date.now());
+
+ // Update node status
+ this.updateNode(data);
+
+ // Update graph data
+ const existingNodeIndex = this.gData.nodes.findIndex(n => n.ipport === nodeId);
+ if (existingNodeIndex === -1) {
+ this.gData.nodes.push({
+ id: data.idx,
+ ip: data.ip,
+ port: data.port,
+ ipport: nodeId,
+ role: data.role,
+ color: this.getNodeColor({ ipport: nodeId, role: data.role })
+ });
+ } else {
+ this.gData.nodes[existingNodeIndex] = {
+ ...this.gData.nodes[existingNodeIndex],
+ role: data.role,
+ color: this.getNodeColor({ ipport: nodeId, role: data.role })
+ };
+ }
+
+ // Handle links
+ if (data.neighbors) {
+ const neighbors = data.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== '');
+
+ // Remove existing links for this node
+ this.gData.links = this.gData.links.filter(link => {
+ const sourceIP = typeof link.source === 'object' ? link.source.ipport : link.source;
+ const targetIP = typeof link.target === 'object' ? link.target.ipport : link.target;
+ return sourceIP !== nodeId && targetIP !== nodeId;
+ });
+
+ // Add new links for online neighbors
+ neighbors.forEach(neighbor => {
+ const neighborIP = neighbor.split(':')[0];
+ if (!this.offlineNodes.has(neighborIP)) {
+ const normalizedNeighbor = neighbor.includes(':') ? neighbor : `${neighbor}:${data.port}`;
+ const neighborNode = this.gData.nodes.find(n =>
+ n.ipport === normalizedNeighbor ||
+ n.ipport.split(':')[0] === neighborIP
+ );
+
+ if (neighborNode) {
+ this.gData.links.push({
+ source: nodeId,
+ target: normalizedNeighbor,
+ value: this.randomFloatFromInterval(1.0, 1.3)
+ });
+ }
+ }
+ });
+ }
+
+ // Update the graph
+ this.updateGraph();
+ } catch (error) {
+ console.error('Error handling node update:', error);
+ }
+ }
+
+ hasGraphChanges(data) {
+ // If no data is provided, return false
+ if (!data) return false;
+
+ const nodeId = `${data.ip}:${data.port}`;
+ const currentLinks = this.gData.links.filter(link =>
+ link.source === nodeId || link.target === nodeId
+ );
+
+ if (!data.neighbors) return false;
+
+ // Parse neighbors using consistent format
+ const neighbors = data.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== '');
+
+ // Create sets of current and new neighbors for comparison
+ const currentNeighbors = new Set(
+ currentLinks.map(link => {
+ const neighborId = link.source === nodeId ? link.target : link.source;
+ return neighborId.split(':')[0]; // Compare only IPs
+ })
+ );
+
+ const newNeighbors = new Set(
+ neighbors.map(neighbor => neighbor.split(':')[0]) // Compare only IPs
+ );
+
+ // Check if there are any differences in the sets
+ if (currentNeighbors.size !== newNeighbors.size) return true;
+
+ for (const neighbor of newNeighbors) {
+ if (!currentNeighbors.has(neighbor)) return true;
+ }
+
+ return false;
+ }
+
+ handleNodeRemove(data) {
+ try {
+ // Validate required fields
+ if (!data.uid || !data.ip) {
+ console.warn('Missing required fields for node removal:', data);
+ return;
+ }
+
+ this.updateNode(data);
+ this.removeNodeLinks(data);
+ // Update graph data after removing links
+ this.updateGraphData(data);
+ this.updateGraph();
+ } catch (error) {
+ console.error('Error handling node removal:', error);
+ }
+ }
+
+ updateNode(data) {
+ const nodeRow = document.querySelector(`#node-${data.uid}`);
+ if (!nodeRow) return;
+
+ const nodeId = `${data.ip}:${data.port}`; // Use full IP:port as nodeId
+ const wasOffline = this.offlineNodes.has(nodeId);
+ const isNowOffline = !data.status;
+
+ // Update offlineNodes set based on status
+ if (isNowOffline) {
+ this.offlineNodes.add(nodeId);
+ console.log('Node marked as offline:', nodeId);
+
+ // Remove all links for this node
+ this.removeNodeLinks(data);
+
+ // Force immediate graph update when node goes offline
+ this.updateGraphData(data);
+ this.updateGraph();
+
+ // Update marker appearance
+ if (this.droneMarkers[data.uid]) {
+ this.droneMarkers[data.uid].setIcon(this.droneIconOffline);
+ this.droneMarkers[data.uid].getElement().classList.add('drone-offline');
+ }
+ } else {
+ this.offlineNodes.delete(nodeId);
+ console.log('Node marked as online:', nodeId);
+
+ // Update marker appearance
+ if (this.droneMarkers[data.uid]) {
+ this.droneMarkers[data.uid].setIcon(this.droneIcon);
+ this.droneMarkers[data.uid].getElement().classList.remove('drone-offline');
+ }
+ }
+
+ // Update all table cells
+ // Update IDX
+ const idxCell = nodeRow.querySelector('td:nth-child(1)');
+ if (idxCell) {
+ idxCell.textContent = data.idx;
+ }
+
+ // Update IP
+ const ipCell = nodeRow.querySelector('td:nth-child(2)');
+ if (ipCell) {
+ ipCell.textContent = data.ip;
+ }
+
+ // Update Role
+ const roleCell = nodeRow.querySelector('td:nth-child(3)');
+ if (roleCell) {
+ roleCell.innerHTML = `
+
+ ${data.role}
+
+ `;
+ }
+
+ // Update Round
+ const roundCell = nodeRow.querySelector('td:nth-child(4)');
+ if (roundCell) {
+ roundCell.textContent = data.round;
+ }
+
+ // Update Behavior
+ const behaviorCell = nodeRow.querySelector('td:nth-child(5)');
+ if (behaviorCell) {
+ behaviorCell.innerHTML = data.malicious === "True"
+ ? 'Malicious'
+ : 'Benign';
+ }
+
+ // Update Status
+ const statusCell = nodeRow.querySelector('td:nth-child(6)');
+ if (statusCell) {
+ statusCell.innerHTML = data.status
+ ? 'Online'
+ : 'Offline';
+ }
+
+ // Update map position
+ this.updateQueue.push(data);
+ }
+
+ removeNodeLinks(data) {
+ if (!data || !data.ip) {
+ console.warn('Invalid data provided to removeNodeLinks:', data);
+ return;
+ }
+
+ const nodeId = `${data.ip}:${data.port}`;
+ console.log('Removing links for node:', nodeId);
+
+ // Remove links from graph data
+ const previousLinkCount = this.gData.links.length;
+
+ // Remove all links where this node is either source or target
+ this.gData.links = this.gData.links.filter(link => {
+ const sourceId = typeof link.source === 'object' ? link.source.ipport : link.source;
+ const targetId = typeof link.target === 'object' ? link.target.ipport : link.target;
+ return sourceId !== nodeId && targetId !== nodeId;
+ });
+
+ console.log(`Removed ${previousLinkCount - this.gData.links.length} links for node ${nodeId}`);
+
+ // Remove lines from map
+ if (data.uid && this.droneLines[data.uid]) {
+ this.cleanupDroneLines(data.uid);
+ }
+
+ // Update any related lines from other nodes
+ if (data.uid) {
+ this.updateAllRelatedLines(data.uid);
+ }
+
+ // Also remove links from other nodes to this offline node
+ Object.entries(this.droneMarkers).forEach(([uid, marker]) => {
+ if (marker.neighbors) {
+ const neighbors = Array.isArray(marker.neighbors)
+ ? marker.neighbors
+ : (typeof marker.neighbors === 'string'
+ ? marker.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== '')
+ : []);
+
+ // If this marker has the offline node as a neighbor, update its lines
+ if (neighbors.some(ip => ip.startsWith(data.ip))) {
+ this.updateNeighborLines(uid, marker.getLatLng(), neighbors, true);
+ }
+ }
+ });
+ }
+
+ updateGraph(data) {
+ if (data) {
+ // Update graph data with new node information
+ this.updateGraphData(data);
+ }
+
+ // Helper function to get IP from source/target
+ const getIPFromLink = (linkEnd) => {
+ if (typeof linkEnd === 'string') {
+ return linkEnd.split(':')[0];
+ } else if (linkEnd && typeof linkEnd === 'object') {
+ return linkEnd.ipport ? linkEnd.ipport.split(':')[0] : linkEnd.ip;
+ }
+ return '';
+ };
+
+ // First, remove all links involving offline nodes
+ this.gData.links = this.gData.links.filter(link => {
+ const sourceIP = getIPFromLink(link.source);
+ const targetIP = getIPFromLink(link.target);
+ const sourceOffline = this.offlineNodes.has(sourceIP);
+ const targetOffline = this.offlineNodes.has(targetIP);
+
+ // Remove link if either source or target is offline
+ if (sourceOffline || targetOffline) {
+ console.log(`Removing link between ${sourceIP} and ${targetIP} due to offline node`);
+ return false;
+ }
+ return true;
+ });
+
+ // Ensure all links have valid source and target nodes
+ this.gData.links = this.gData.links.filter(link => {
+ const sourceExists = this.gData.nodes.some(n => n.ipport === link.source);
+ const targetExists = this.gData.nodes.some(n => n.ipport === link.target);
+ return sourceExists && targetExists;
+ });
+
+ // Add links only between online nodes
+ this.gData.nodes.forEach(node => {
+ if (!this.offlineNodes.has(node.ip)) {
+ const nodeId = node.ipport;
+ // Find all online neighbors
+ const onlineNeighbors = this.gData.nodes.filter(n =>
+ !this.offlineNodes.has(n.ip) && n.ipport !== nodeId
+ );
+
+ // Add links to online neighbors if they don't exist
+ onlineNeighbors.forEach(neighbor => {
+ const linkExists = this.gData.links.some(link =>
+ (link.source === nodeId && link.target === neighbor.ipport) ||
+ (link.source === neighbor.ipport && link.target === nodeId)
+ );
+
+ if (!linkExists) {
+ this.gData.links.push({
+ source: nodeId,
+ target: neighbor.ipport,
+ value: this.randomFloatFromInterval(1.0, 1.3)
+ });
+ }
+ });
+ }
+ });
+
+ // Apply layout to nodes
+ const layoutedNodes = this.layoutNodes([...this.gData.nodes]);
+
+ // Update the graph with new data
+ this.Graph.graphData({
+ nodes: layoutedNodes,
+ links: this.gData.links
+ });
+
+ // Reset the force simulation to ensure proper layout
+ this.Graph.d3ReheatSimulation();
+
+ // Force a redraw
+ this.Graph.refresh();
+ }
+
+ initializeEventListeners() {
+ setInterval(() => this.processQueue(), 100);
+ }
+
+ initializeDownloadHandlers() {
+ const downloadLinks = document.getElementsByClassName('download');
+ Array.from(downloadLinks).forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ fetch(link.href)
+ .then(response => {
+ if (!response.ok) {
+ showAlert('danger', 'File not found');
+ } else {
+ window.location.href = link.href;
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ showAlert('danger', 'Error downloading file');
+ });
+ });
+ });
+ }
+
+ processQueue() {
+ while (this.updateQueue.length > 0) {
+ const data = this.updateQueue.shift();
+ console.log('Processing queue item:', data);
+ this.processUpdate(data);
+ }
+ }
+
+ processUpdate(data) {
+ try {
+ console.log('Processing update for node:', data.uid);
+
+ // Validate required fields
+ if (!data.uid || !data.ip) {
+ console.warn('Missing required fields for node update:', data);
+ return;
+ }
+
+ // Convert and validate coordinates
+ const lat = parseFloat(data.latitude);
+ const lng = parseFloat(data.longitude);
+
+ console.log('Coordinates:', { lat, lng });
+
+ if (isNaN(lat) || isNaN(lng)) {
+ console.warn('Invalid coordinates for node:', data.uid, 'lat:', data.latitude, 'lng:', data.longitude);
+ return;
+ }
+
+ // Create validated node data
+ const nodeData = {
+ ...data,
+ latitude: lat,
+ longitude: lng,
+ neighbors: data.neighbors || ""
+ };
+
+ console.log('Validated node data:', nodeData);
+
+ const newLatLng = new L.LatLng(lat, lng);
+
+ // Parse neighbors string into array, handling both space and comma separators
+ const neighborsIPs = nodeData.neighbors
+ ? nodeData.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== '')
+ : [];
+
+ console.log('Parsed neighbor IPs:', neighborsIPs);
+
+ // First update the marker
+ console.log('Updating drone position for node:', nodeData.uid);
+ this.updateDronePosition(
+ nodeData.uid,
+ nodeData.ip,
+ lat,
+ lng,
+ neighborsIPs,
+ nodeData.neighbors_distance
+ );
+
+ // Then immediately update the lines for this node
+ if (neighborsIPs.length > 0) {
+ console.log('Updating neighbor lines for node:', nodeData.uid);
+ this.updateNeighborLines(nodeData.uid, newLatLng, neighborsIPs, true);
+ }
+
+ // Finally update any related lines from other nodes
+ this.updateAllRelatedLines(nodeData.uid);
+ } catch (error) {
+ console.error('Error processing update:', error);
+ }
+ }
+
+ updateDronePosition(uid, ip, lat, lng, neighborIPs, neighborsDistance) {
+ console.log('Updating drone position:', { uid, ip, lat, lng });
+ const droneId = uid;
+ const newLatLng = new L.LatLng(lat, lng);
+
+ // Create popup content with node information
+ const popupContent = `
+ `;
+
+ if (!this.droneMarkers[droneId]) {
+ console.log('Creating new marker for node:', droneId);
+ // Create new marker
+ const marker = L.marker(newLatLng, {
+ icon: this.offlineNodes.has(ip) ? this.droneIconOffline : this.droneIcon,
+ title: `Node ${uid}`,
+ alt: `Node ${uid}`,
+ className: this.offlineNodes.has(ip) ? 'drone-offline' : ''
+ }).addTo(this.map);
+
+ marker.bindPopup(popupContent, {
+ maxWidth: 300,
+ className: 'drone-popup'
+ });
+
+ marker.on('mouseover', function() {
+ this.openPopup();
+ });
+
+ marker.on('mouseout', function() {
+ this.closePopup();
+ });
+
+ marker.ip = ip;
+ marker.neighbors = neighborIPs;
+ marker.neighbors_distance = neighborsDistance;
+ this.droneMarkers[droneId] = marker;
+ console.log('Marker created and added to map:', marker);
+ } else {
+ console.log('Updating existing marker for node:', droneId);
+ // Update existing marker
+ if (this.offlineNodes.has(ip)) {
+ this.droneMarkers[droneId].setIcon(this.droneIconOffline);
+ this.droneMarkers[droneId].getElement().classList.add('drone-offline');
+ } else {
+ this.droneMarkers[droneId].setIcon(this.droneIcon);
+ this.droneMarkers[droneId].getElement().classList.remove('drone-offline');
+ }
+
+ this.droneMarkers[droneId].setLatLng(newLatLng);
+ this.droneMarkers[droneId].getPopup().setContent(popupContent);
+ this.droneMarkers[droneId].neighbors = neighborIPs;
+ this.droneMarkers[droneId].neighbors_distance = neighborsDistance;
+ console.log('Marker updated:', this.droneMarkers[droneId]);
+ }
+ }
+
+ updateNeighborLines(droneId, droneLatLng, neighborsIPs, condition) {
+ console.log('Updating neighbor lines for drone:', droneId, 'with neighbors:', neighborsIPs);
+ console.log('Current drone position:', droneLatLng);
+
+ // Clean up existing lines for this drone
+ this.cleanupDroneLines(droneId);
+
+ if (!this.droneMarkers[droneId]) {
+ console.warn('No marker found for drone:', droneId);
+ return;
+ }
+
+ // Skip if current drone is offline
+ if (this.offlineNodes.has(this.droneMarkers[droneId].ip)) {
+ console.log('Skipping line creation - current drone is offline:', this.droneMarkers[droneId].ip);
+ return;
+ }
+
+ // Initialize droneLines array if it doesn't exist
+ if (!this.droneLines[droneId]) {
+ this.droneLines[droneId] = [];
+ }
+
+ // Create new lines
+ neighborsIPs.forEach(neighborIP => {
+ // Extract IP from IP:port format if present
+ const neighborIPOnly = neighborIP.split(':')[0];
+ const neighborMarker = this.findMarkerByIP(neighborIPOnly);
+
+ if (neighborMarker) {
+ // Skip if neighbor is offline
+ if (this.offlineNodes.has(neighborIPOnly)) {
+ console.log('Skipping line creation - neighbor is offline:', neighborIPOnly);
+ return;
+ }
+
+ console.log('Found neighbor marker for IP:', neighborIPOnly);
+ const neighborLatLng = neighborMarker.getLatLng();
+ console.log('Neighbor position:', neighborLatLng);
+
+ console.log('Creating line between:', droneLatLng, 'and', neighborLatLng);
+
+ try {
+ // Create the line with explicit coordinates
+ const line = L.polyline(
+ [
+ [droneLatLng.lat, droneLatLng.lng],
+ [neighborLatLng.lat, neighborLatLng.lng]
+ ],
+ {
+ color: '#4CAF50',
+ weight: 3,
+ opacity: 1.0,
+ interactive: true
+ }
+ );
+
+ // Add popup with distance information
+ try {
+ const distance = condition
+ ? (this.droneMarkers[droneId].neighbors_distance &&
+ this.droneMarkers[droneId].neighbors_distance[neighborIP])
+ : (neighborMarker.neighbors_distance &&
+ neighborMarker.neighbors_distance[this.droneMarkers[droneId].ip]);
+
+ line.bindPopup(`
+
+ `);
+ } catch (err) {
+ console.warn('Error binding popup to line:', err);
+ line.bindPopup('Distance: Calculating...');
+ }
+
+ // Add hover behavior
+ line.on('mouseover', function() {
+ this.openPopup();
+ });
+
+ // Add the line to the layer group
+ this.lineLayer.addLayer(line);
+ console.log('Line added to line layer');
+
+ // Store the line
+ this.droneLines[droneId].push(line);
+ console.log('Line stored in droneLines array');
+
+ } catch (error) {
+ console.error('Error creating/adding line:', error);
+ }
+ } else {
+ console.warn('No marker found for neighbor IP:', neighborIPOnly);
+ }
+ });
+ }
+
+ cleanupDroneLines(droneId) {
+ console.log('Cleaning up lines for drone:', droneId);
+ if (this.droneLines[droneId]) {
+ this.droneLines[droneId].forEach(line => {
+ if (line) {
+ try {
+ // Remove from layer group
+ this.lineLayer.removeLayer(line);
+ console.log('Line removed from layer');
+ } catch (error) {
+ console.error('Error removing line:', error);
+ }
+ }
+ });
+ }
+ this.droneLines[droneId] = [];
+ }
+
+ updateAllRelatedLines(droneId) {
+ // Get the current drone's IP
+ const currentDroneIP = this.droneMarkers[droneId]?.ip;
+ if (!currentDroneIP) {
+ console.warn('No IP found for drone:', droneId);
+ return;
+ }
+
+ console.log('Updating related lines for drone:', droneId, 'with IP:', currentDroneIP);
+
+ // Update lines for all drones that have this drone as a neighbor
+ Object.entries(this.droneMarkers).forEach(([id, marker]) => {
+ if (id !== droneId && marker.neighbors) {
+ console.log('Processing marker:', id, 'with neighbors:', marker.neighbors, 'type:', typeof marker.neighbors);
+
+ // Handle both string and array formats for neighbors
+ const neighborIPs = Array.isArray(marker.neighbors)
+ ? marker.neighbors
+ : (typeof marker.neighbors === 'string'
+ ? marker.neighbors.split(/[\s,]+/).filter(ip => ip.trim() !== '')
+ : []);
+
+ console.log('Processed neighbor IPs:', neighborIPs);
+
+ if (neighborIPs.some(ip => ip.startsWith(currentDroneIP))) {
+ console.log('Found matching neighbor, updating lines');
+ this.updateNeighborLines(
+ id,
+ marker.getLatLng(),
+ neighborIPs,
+ false
+ );
+ }
+ }
+ });
+ }
+
+ findMarkerByIP(ip) {
+ // Handle both IP and IP:port formats
+ const ipOnly = ip.split(':')[0];
+ console.log('Looking for marker with IP:', ipOnly);
+ console.log('Available markers:', Object.values(this.droneMarkers).map(m => m.ip));
+
+ const marker = Object.values(this.droneMarkers).find(marker => {
+ const markerIP = marker.ip.split(':')[0];
+ const matches = markerIP === ipOnly;
+ if (matches) {
+ console.log('Found matching marker:', markerIP);
+ }
+ return matches;
+ });
+
+ if (!marker) {
+ console.warn('No marker found for IP:', ipOnly);
+ }
+ return marker;
+ }
+
+ startStaleNodeCheck() {
+ // Check for stale nodes every 5 seconds
+ setInterval(() => {
+ const currentTime = Date.now();
+ const staleThreshold = 20000; // 20 seconds in milliseconds
+
+ // Check all nodes for staleness
+ this.nodeTimestamps.forEach((timestamp, nodeId) => {
+ const timeSinceLastUpdate = currentTime - timestamp;
+ if (timeSinceLastUpdate > staleThreshold) {
+ console.log(`Node ${nodeId} is stale (${timeSinceLastUpdate}ms since last update)`);
+ this.markNodeAsOffline(nodeId);
+ }
+ });
+ }, 5000); // Check every 5 seconds
+ }
+
+ markNodeAsOffline(nodeId) {
+ // Find the node data from our existing data structures
+ const node = this.gData.nodes.find(n => n.ipport === nodeId);
+ if (!node) {
+ console.warn(`Node ${nodeId} not found in graph data`);
+ return;
+ }
+
+ console.log(`Marking node ${nodeId} as offline`);
+
+ // Add to offline nodes set
+ this.offlineNodes.add(node.ip);
+
+ // Update node color in graph data
+ const nodeIndex = this.gData.nodes.findIndex(n => n.ipport === nodeId);
+ if (nodeIndex !== -1) {
+ this.gData.nodes[nodeIndex].color = '#ff0000'; // Red color for offline nodes
+ }
+
+ // Remove all links involving this node
+ this.gData.links = this.gData.links.filter(link => {
+ const sourceIP = typeof link.source === 'object' ? link.source.ipport : link.source;
+ const targetIP = typeof link.target === 'object' ? link.target.ipport : link.target;
+ return sourceIP !== nodeId && targetIP !== nodeId;
+ });
+
+ // Update marker appearance if it exists
+ const marker = Object.entries(this.droneMarkers).find(([_, m]) => m.ip === node.ip)?.[1];
+ if (marker) {
+ marker.setIcon(this.droneIconOffline);
+ marker.getElement().classList.add('drone-offline');
+
+ // Remove all lines connected to this node
+ if (this.droneLines[marker.uid]) {
+ this.cleanupDroneLines(marker.uid);
+ }
+ }
+
+ // Find the node's UID from the markers
+ const nodeEntry = Object.entries(this.droneMarkers).find(([_, m]) => m.ip === node.ip);
+ if (!nodeEntry) {
+ console.warn(`No marker found for node ${nodeId}`);
+ return;
+ }
+
+ const [uid, _] = nodeEntry;
+
+ // Update table status
+ const nodeRow = document.querySelector(`#node-${uid}`);
+ if (nodeRow) {
+ const statusCell = nodeRow.querySelector('td:nth-child(6)');
+ if (statusCell) {
+ statusCell.innerHTML = 'Offline';
+ }
+ }
+
+ // Update graph visualization
+ this.updateGraph();
+
+ // Update map visualization
+ this.updateAllRelatedLines(uid);
+ }
+}
+
+// Initialize monitor when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ new Monitor();
+});
\ No newline at end of file
diff --git a/nebula/frontend/static/js/particles.json b/nebula/frontend/static/js/particles.json
new file mode 100644
index 000000000..a65537db0
--- /dev/null
+++ b/nebula/frontend/static/js/particles.json
@@ -0,0 +1,116 @@
+{
+ "particles": {
+ "number": {
+ "value": 40,
+ "density": {
+ "enable": true,
+ "value_area": 800
+ }
+ },
+ "color": {
+ "value": "#1d2253"
+ },
+ "shape": {
+ "type": "circle",
+ "stroke": {
+ "width": 0,
+ "color": "#000000"
+ },
+ "polygon": {
+ "nb_sides": 5
+ },
+ "image": {
+ "src": "img/github.svg",
+ "width": 100,
+ "height": 100
+ }
+ },
+ "opacity": {
+ "value": 0.5,
+ "random": false,
+ "anim": {
+ "enable": false,
+ "speed": 1,
+ "opacity_min": 0.1,
+ "sync": false
+ }
+ },
+ "size": {
+ "value": 5,
+ "random": true,
+ "anim": {
+ "enable": false,
+ "speed": 40,
+ "size_min": 0.1,
+ "sync": false
+ }
+ },
+ "line_linked": {
+ "enable": true,
+ "distance": 150,
+ "color": "#1d2253",
+ "opacity": 0.4,
+ "width": 1
+ },
+ "move": {
+ "enable": true,
+ "speed": 2,
+ "direction": "none",
+ "random": false,
+ "straight": false,
+ "out_mode": "out",
+ "attract": {
+ "enable": false,
+ "rotateX": 600,
+ "rotateY": 1200
+ }
+ }
+ },
+ "interactivity": {
+ "detect_on": "window",
+ "events": {
+ "onhover": {
+ "enable": true,
+ "mode": "repulse"
+ },
+ "onclick": {
+ "enable": true,
+ "mode": "push"
+ },
+ "resize": true
+ },
+ "modes": {
+ "grab": {
+ "distance": 400,
+ "line_linked": {
+ "opacity": 1
+ }
+ },
+ "bubble": {
+ "distance": 400,
+ "size": 40,
+ "duration": 2,
+ "opacity": 8,
+ "speed": 3
+ },
+ "repulse": {
+ "distance": 100
+ },
+ "push": {
+ "particles_nb": 4
+ },
+ "remove": {
+ "particles_nb": 2
+ }
+ }
+ },
+ "retina_detect": true,
+ "config_demo": {
+ "hide_card": false,
+ "background_color": "#1d2253",
+ "background_image": "",
+ "background_position": "50% 50%",
+ "background_repeat": "no-repeat",
+ "background_size": "cover"
+ }
+}
\ No newline at end of file
diff --git a/nebula/frontend/static/js/particles.min.js b/nebula/frontend/static/js/particles.min.js
new file mode 100644
index 000000000..b3d46d127
--- /dev/null
+++ b/nebula/frontend/static/js/particles.min.js
@@ -0,0 +1,9 @@
+/* -----------------------------------------------
+/* Author : Vincent Garreau - vincentgarreau.com
+/* MIT license: http://opensource.org/licenses/MIT
+/* Demo / Generator : vincentgarreau.com/particles.js
+/* GitHub : github.com/VincentGarreau/particles.js
+/* How to use? : Check the GitHub README
+/* v2.0.0
+/* ----------------------------------------------- */
+function hexToRgb(e){var a=/^#?([a-f\d])([a-f\d])([a-f\d])$/i;e=e.replace(a,function(e,a,t,i){return a+a+t+t+i+i});var t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null}function clamp(e,a,t){return Math.min(Math.max(e,a),t)}function isInArray(e,a){return a.indexOf(e)>-1}var pJS=function(e,a){var t=document.querySelector("#"+e+" > .particles-js-canvas-el");this.pJS={canvas:{el:t,w:t.offsetWidth,h:t.offsetHeight},particles:{number:{value:400,density:{enable:!0,value_area:800}},color:{value:"#fff"},shape:{type:"circle",stroke:{width:0,color:"#ff0000"},polygon:{nb_sides:5},image:{src:"",width:100,height:100}},opacity:{value:1,random:!1,anim:{enable:!1,speed:2,opacity_min:0,sync:!1}},size:{value:20,random:!1,anim:{enable:!1,speed:20,size_min:0,sync:!1}},line_linked:{enable:!0,distance:100,color:"#fff",opacity:1,width:1},move:{enable:!0,speed:2,direction:"none",random:!1,straight:!1,out_mode:"out",bounce:!1,attract:{enable:!1,rotateX:3e3,rotateY:3e3}},array:[]},interactivity:{detect_on:"canvas",events:{onhover:{enable:!0,mode:"grab"},onclick:{enable:!0,mode:"push"},resize:!0},modes:{grab:{distance:100,line_linked:{opacity:1}},bubble:{distance:200,size:80,duration:.4},repulse:{distance:200,duration:.4},push:{particles_nb:4},remove:{particles_nb:2}},mouse:{}},retina_detect:!1,fn:{interact:{},modes:{},vendors:{}},tmp:{}};var i=this.pJS;a&&Object.deepExtend(i,a),i.tmp.obj={size_value:i.particles.size.value,size_anim_speed:i.particles.size.anim.speed,move_speed:i.particles.move.speed,line_linked_distance:i.particles.line_linked.distance,line_linked_width:i.particles.line_linked.width,mode_grab_distance:i.interactivity.modes.grab.distance,mode_bubble_distance:i.interactivity.modes.bubble.distance,mode_bubble_size:i.interactivity.modes.bubble.size,mode_repulse_distance:i.interactivity.modes.repulse.distance},i.fn.retinaInit=function(){i.retina_detect&&window.devicePixelRatio>1?(i.canvas.pxratio=window.devicePixelRatio,i.tmp.retina=!0):(i.canvas.pxratio=1,i.tmp.retina=!1),i.canvas.w=i.canvas.el.offsetWidth*i.canvas.pxratio,i.canvas.h=i.canvas.el.offsetHeight*i.canvas.pxratio,i.particles.size.value=i.tmp.obj.size_value*i.canvas.pxratio,i.particles.size.anim.speed=i.tmp.obj.size_anim_speed*i.canvas.pxratio,i.particles.move.speed=i.tmp.obj.move_speed*i.canvas.pxratio,i.particles.line_linked.distance=i.tmp.obj.line_linked_distance*i.canvas.pxratio,i.interactivity.modes.grab.distance=i.tmp.obj.mode_grab_distance*i.canvas.pxratio,i.interactivity.modes.bubble.distance=i.tmp.obj.mode_bubble_distance*i.canvas.pxratio,i.particles.line_linked.width=i.tmp.obj.line_linked_width*i.canvas.pxratio,i.interactivity.modes.bubble.size=i.tmp.obj.mode_bubble_size*i.canvas.pxratio,i.interactivity.modes.repulse.distance=i.tmp.obj.mode_repulse_distance*i.canvas.pxratio},i.fn.canvasInit=function(){i.canvas.ctx=i.canvas.el.getContext("2d")},i.fn.canvasSize=function(){i.canvas.el.width=i.canvas.w,i.canvas.el.height=i.canvas.h,i&&i.interactivity.events.resize&&window.addEventListener("resize",function(){i.canvas.w=i.canvas.el.offsetWidth,i.canvas.h=i.canvas.el.offsetHeight,i.tmp.retina&&(i.canvas.w*=i.canvas.pxratio,i.canvas.h*=i.canvas.pxratio),i.canvas.el.width=i.canvas.w,i.canvas.el.height=i.canvas.h,i.particles.move.enable||(i.fn.particlesEmpty(),i.fn.particlesCreate(),i.fn.particlesDraw(),i.fn.vendors.densityAutoParticles()),i.fn.vendors.densityAutoParticles()})},i.fn.canvasPaint=function(){i.canvas.ctx.fillRect(0,0,i.canvas.w,i.canvas.h)},i.fn.canvasClear=function(){i.canvas.ctx.clearRect(0,0,i.canvas.w,i.canvas.h)},i.fn.particle=function(e,a,t){if(this.radius=(i.particles.size.random?Math.random():1)*i.particles.size.value,i.particles.size.anim.enable&&(this.size_status=!1,this.vs=i.particles.size.anim.speed/100,i.particles.size.anim.sync||(this.vs=this.vs*Math.random())),this.x=t?t.x:Math.random()*i.canvas.w,this.y=t?t.y:Math.random()*i.canvas.h,this.x>i.canvas.w-2*this.radius?this.x=this.x-this.radius:this.x<2*this.radius&&(this.x=this.x+this.radius),this.y>i.canvas.h-2*this.radius?this.y=this.y-this.radius:this.y<2*this.radius&&(this.y=this.y+this.radius),i.particles.move.bounce&&i.fn.vendors.checkOverlap(this,t),this.color={},"object"==typeof e.value)if(e.value instanceof Array){var s=e.value[Math.floor(Math.random()*i.particles.color.value.length)];this.color.rgb=hexToRgb(s)}else void 0!=e.value.r&&void 0!=e.value.g&&void 0!=e.value.b&&(this.color.rgb={r:e.value.r,g:e.value.g,b:e.value.b}),void 0!=e.value.h&&void 0!=e.value.s&&void 0!=e.value.l&&(this.color.hsl={h:e.value.h,s:e.value.s,l:e.value.l});else"random"==e.value?this.color.rgb={r:Math.floor(256*Math.random())+0,g:Math.floor(256*Math.random())+0,b:Math.floor(256*Math.random())+0}:"string"==typeof e.value&&(this.color=e,this.color.rgb=hexToRgb(this.color.value));this.opacity=(i.particles.opacity.random?Math.random():1)*i.particles.opacity.value,i.particles.opacity.anim.enable&&(this.opacity_status=!1,this.vo=i.particles.opacity.anim.speed/100,i.particles.opacity.anim.sync||(this.vo=this.vo*Math.random()));var n={};switch(i.particles.move.direction){case"top":n={x:0,y:-1};break;case"top-right":n={x:.5,y:-.5};break;case"right":n={x:1,y:-0};break;case"bottom-right":n={x:.5,y:.5};break;case"bottom":n={x:0,y:1};break;case"bottom-left":n={x:-.5,y:1};break;case"left":n={x:-1,y:0};break;case"top-left":n={x:-.5,y:-.5};break;default:n={x:0,y:0}}i.particles.move.straight?(this.vx=n.x,this.vy=n.y,i.particles.move.random&&(this.vx=this.vx*Math.random(),this.vy=this.vy*Math.random())):(this.vx=n.x+Math.random()-.5,this.vy=n.y+Math.random()-.5),this.vx_i=this.vx,this.vy_i=this.vy;var r=i.particles.shape.type;if("object"==typeof r){if(r instanceof Array){var c=r[Math.floor(Math.random()*r.length)];this.shape=c}}else this.shape=r;if("image"==this.shape){var o=i.particles.shape;this.img={src:o.image.src,ratio:o.image.width/o.image.height},this.img.ratio||(this.img.ratio=1),"svg"==i.tmp.img_type&&void 0!=i.tmp.source_svg&&(i.fn.vendors.createSvgImg(this),i.tmp.pushing&&(this.img.loaded=!1))}},i.fn.particle.prototype.draw=function(){function e(){i.canvas.ctx.drawImage(r,a.x-t,a.y-t,2*t,2*t/a.img.ratio)}var a=this;if(void 0!=a.radius_bubble)var t=a.radius_bubble;else var t=a.radius;if(void 0!=a.opacity_bubble)var s=a.opacity_bubble;else var s=a.opacity;if(a.color.rgb)var n="rgba("+a.color.rgb.r+","+a.color.rgb.g+","+a.color.rgb.b+","+s+")";else var n="hsla("+a.color.hsl.h+","+a.color.hsl.s+"%,"+a.color.hsl.l+"%,"+s+")";switch(i.canvas.ctx.fillStyle=n,i.canvas.ctx.beginPath(),a.shape){case"circle":i.canvas.ctx.arc(a.x,a.y,t,0,2*Math.PI,!1);break;case"edge":i.canvas.ctx.rect(a.x-t,a.y-t,2*t,2*t);break;case"triangle":i.fn.vendors.drawShape(i.canvas.ctx,a.x-t,a.y+t/1.66,2*t,3,2);break;case"polygon":i.fn.vendors.drawShape(i.canvas.ctx,a.x-t/(i.particles.shape.polygon.nb_sides/3.5),a.y-t/.76,2.66*t/(i.particles.shape.polygon.nb_sides/3),i.particles.shape.polygon.nb_sides,1);break;case"star":i.fn.vendors.drawShape(i.canvas.ctx,a.x-2*t/(i.particles.shape.polygon.nb_sides/4),a.y-t/1.52,2*t*2.66/(i.particles.shape.polygon.nb_sides/3),i.particles.shape.polygon.nb_sides,2);break;case"image":if("svg"==i.tmp.img_type)var r=a.img.obj;else var r=i.tmp.img_obj;r&&e()}i.canvas.ctx.closePath(),i.particles.shape.stroke.width>0&&(i.canvas.ctx.strokeStyle=i.particles.shape.stroke.color,i.canvas.ctx.lineWidth=i.particles.shape.stroke.width,i.canvas.ctx.stroke()),i.canvas.ctx.fill()},i.fn.particlesCreate=function(){for(var e=0;e=i.particles.opacity.value&&(a.opacity_status=!1),a.opacity+=a.vo):(a.opacity<=i.particles.opacity.anim.opacity_min&&(a.opacity_status=!0),a.opacity-=a.vo),a.opacity<0&&(a.opacity=0)),i.particles.size.anim.enable&&(1==a.size_status?(a.radius>=i.particles.size.value&&(a.size_status=!1),a.radius+=a.vs):(a.radius<=i.particles.size.anim.size_min&&(a.size_status=!0),a.radius-=a.vs),a.radius<0&&(a.radius=0)),"bounce"==i.particles.move.out_mode)var s={x_left:a.radius,x_right:i.canvas.w,y_top:a.radius,y_bottom:i.canvas.h};else var s={x_left:-a.radius,x_right:i.canvas.w+a.radius,y_top:-a.radius,y_bottom:i.canvas.h+a.radius};switch(a.x-a.radius>i.canvas.w?(a.x=s.x_left,a.y=Math.random()*i.canvas.h):a.x+a.radius<0&&(a.x=s.x_right,a.y=Math.random()*i.canvas.h),a.y-a.radius>i.canvas.h?(a.y=s.y_top,a.x=Math.random()*i.canvas.w):a.y+a.radius<0&&(a.y=s.y_bottom,a.x=Math.random()*i.canvas.w),i.particles.move.out_mode){case"bounce":a.x+a.radius>i.canvas.w?a.vx=-a.vx:a.x-a.radius<0&&(a.vx=-a.vx),a.y+a.radius>i.canvas.h?a.vy=-a.vy:a.y-a.radius<0&&(a.vy=-a.vy)}if(isInArray("grab",i.interactivity.events.onhover.mode)&&i.fn.modes.grabParticle(a),(isInArray("bubble",i.interactivity.events.onhover.mode)||isInArray("bubble",i.interactivity.events.onclick.mode))&&i.fn.modes.bubbleParticle(a),(isInArray("repulse",i.interactivity.events.onhover.mode)||isInArray("repulse",i.interactivity.events.onclick.mode))&&i.fn.modes.repulseParticle(a),i.particles.line_linked.enable||i.particles.move.attract.enable)for(var n=e+1;n0){var c=i.particles.line_linked.color_rgb_line;i.canvas.ctx.strokeStyle="rgba("+c.r+","+c.g+","+c.b+","+r+")",i.canvas.ctx.lineWidth=i.particles.line_linked.width,i.canvas.ctx.beginPath(),i.canvas.ctx.moveTo(e.x,e.y),i.canvas.ctx.lineTo(a.x,a.y),i.canvas.ctx.stroke(),i.canvas.ctx.closePath()}}},i.fn.interact.attractParticles=function(e,a){var t=e.x-a.x,s=e.y-a.y,n=Math.sqrt(t*t+s*s);if(n<=i.particles.line_linked.distance){var r=t/(1e3*i.particles.move.attract.rotateX),c=s/(1e3*i.particles.move.attract.rotateY);e.vx-=r,e.vy-=c,a.vx+=r,a.vy+=c}},i.fn.interact.bounceParticles=function(e,a){var t=e.x-a.x,i=e.y-a.y,s=Math.sqrt(t*t+i*i),n=e.radius+a.radius;n>=s&&(e.vx=-e.vx,e.vy=-e.vy,a.vx=-a.vx,a.vy=-a.vy)},i.fn.modes.pushParticles=function(e,a){i.tmp.pushing=!0;for(var t=0;e>t;t++)i.particles.array.push(new i.fn.particle(i.particles.color,i.particles.opacity.value,{x:a?a.pos_x:Math.random()*i.canvas.w,y:a?a.pos_y:Math.random()*i.canvas.h})),t==e-1&&(i.particles.move.enable||i.fn.particlesDraw(),i.tmp.pushing=!1)},i.fn.modes.removeParticles=function(e){i.particles.array.splice(0,e),i.particles.move.enable||i.fn.particlesDraw()},i.fn.modes.bubbleParticle=function(e){function a(){e.opacity_bubble=e.opacity,e.radius_bubble=e.radius}function t(a,t,s,n,c){if(a!=t)if(i.tmp.bubble_duration_end){if(void 0!=s){var o=n-p*(n-a)/i.interactivity.modes.bubble.duration,l=a-o;d=a+l,"size"==c&&(e.radius_bubble=d),"opacity"==c&&(e.opacity_bubble=d)}}else if(r<=i.interactivity.modes.bubble.distance){if(void 0!=s)var v=s;else var v=n;if(v!=a){var d=n-p*(n-a)/i.interactivity.modes.bubble.duration;"size"==c&&(e.radius_bubble=d),"opacity"==c&&(e.opacity_bubble=d)}}else"size"==c&&(e.radius_bubble=void 0),"opacity"==c&&(e.opacity_bubble=void 0)}if(i.interactivity.events.onhover.enable&&isInArray("bubble",i.interactivity.events.onhover.mode)){var s=e.x-i.interactivity.mouse.pos_x,n=e.y-i.interactivity.mouse.pos_y,r=Math.sqrt(s*s+n*n),c=1-r/i.interactivity.modes.bubble.distance;if(r<=i.interactivity.modes.bubble.distance){if(c>=0&&"mousemove"==i.interactivity.status){if(i.interactivity.modes.bubble.size!=i.particles.size.value)if(i.interactivity.modes.bubble.size>i.particles.size.value){var o=e.radius+i.interactivity.modes.bubble.size*c;o>=0&&(e.radius_bubble=o)}else{var l=e.radius-i.interactivity.modes.bubble.size,o=e.radius-l*c;o>0?e.radius_bubble=o:e.radius_bubble=0}if(i.interactivity.modes.bubble.opacity!=i.particles.opacity.value)if(i.interactivity.modes.bubble.opacity>i.particles.opacity.value){var v=i.interactivity.modes.bubble.opacity*c;v>e.opacity&&v<=i.interactivity.modes.bubble.opacity&&(e.opacity_bubble=v)}else{var v=e.opacity-(i.particles.opacity.value-i.interactivity.modes.bubble.opacity)*c;v=i.interactivity.modes.bubble.opacity&&(e.opacity_bubble=v)}}}else a();"mouseleave"==i.interactivity.status&&a()}else if(i.interactivity.events.onclick.enable&&isInArray("bubble",i.interactivity.events.onclick.mode)){if(i.tmp.bubble_clicking){var s=e.x-i.interactivity.mouse.click_pos_x,n=e.y-i.interactivity.mouse.click_pos_y,r=Math.sqrt(s*s+n*n),p=((new Date).getTime()-i.interactivity.mouse.click_time)/1e3;p>i.interactivity.modes.bubble.duration&&(i.tmp.bubble_duration_end=!0),p>2*i.interactivity.modes.bubble.duration&&(i.tmp.bubble_clicking=!1,i.tmp.bubble_duration_end=!1)}i.tmp.bubble_clicking&&(t(i.interactivity.modes.bubble.size,i.particles.size.value,e.radius_bubble,e.radius,"size"),t(i.interactivity.modes.bubble.opacity,i.particles.opacity.value,e.opacity_bubble,e.opacity,"opacity"))}},i.fn.modes.repulseParticle=function(e){function a(){var a=Math.atan2(d,p);if(e.vx=u*Math.cos(a),e.vy=u*Math.sin(a),"bounce"==i.particles.move.out_mode){var t={x:e.x+e.vx,y:e.y+e.vy};t.x+e.radius>i.canvas.w?e.vx=-e.vx:t.x-e.radius<0&&(e.vx=-e.vx),t.y+e.radius>i.canvas.h?e.vy=-e.vy:t.y-e.radius<0&&(e.vy=-e.vy)}}if(i.interactivity.events.onhover.enable&&isInArray("repulse",i.interactivity.events.onhover.mode)&&"mousemove"==i.interactivity.status){var t=e.x-i.interactivity.mouse.pos_x,s=e.y-i.interactivity.mouse.pos_y,n=Math.sqrt(t*t+s*s),r={x:t/n,y:s/n},c=i.interactivity.modes.repulse.distance,o=100,l=clamp(1/c*(-1*Math.pow(n/c,2)+1)*c*o,0,50),v={x:e.x+r.x*l,y:e.y+r.y*l};"bounce"==i.particles.move.out_mode?(v.x-e.radius>0&&v.x+e.radius0&&v.y+e.radius=m&&a()}else 0==i.tmp.repulse_clicking&&(e.vx=e.vx_i,e.vy=e.vy_i)},i.fn.modes.grabParticle=function(e){if(i.interactivity.events.onhover.enable&&"mousemove"==i.interactivity.status){var a=e.x-i.interactivity.mouse.pos_x,t=e.y-i.interactivity.mouse.pos_y,s=Math.sqrt(a*a+t*t);if(s<=i.interactivity.modes.grab.distance){var n=i.interactivity.modes.grab.line_linked.opacity-s/(1/i.interactivity.modes.grab.line_linked.opacity)/i.interactivity.modes.grab.distance;if(n>0){var r=i.particles.line_linked.color_rgb_line;i.canvas.ctx.strokeStyle="rgba("+r.r+","+r.g+","+r.b+","+n+")",i.canvas.ctx.lineWidth=i.particles.line_linked.width,i.canvas.ctx.beginPath(),i.canvas.ctx.moveTo(e.x,e.y),i.canvas.ctx.lineTo(i.interactivity.mouse.pos_x,i.interactivity.mouse.pos_y),i.canvas.ctx.stroke(),i.canvas.ctx.closePath()}}}},i.fn.vendors.eventsListeners=function(){"window"==i.interactivity.detect_on?i.interactivity.el=window:i.interactivity.el=i.canvas.el,(i.interactivity.events.onhover.enable||i.interactivity.events.onclick.enable)&&(i.interactivity.el.addEventListener("mousemove",function(e){if(i.interactivity.el==window)var a=e.clientX,t=e.clientY;else var a=e.offsetX||e.clientX,t=e.offsetY||e.clientY;i.interactivity.mouse.pos_x=a,i.interactivity.mouse.pos_y=t,i.tmp.retina&&(i.interactivity.mouse.pos_x*=i.canvas.pxratio,i.interactivity.mouse.pos_y*=i.canvas.pxratio),i.interactivity.status="mousemove"}),i.interactivity.el.addEventListener("mouseleave",function(e){i.interactivity.mouse.pos_x=null,i.interactivity.mouse.pos_y=null,i.interactivity.status="mouseleave"})),i.interactivity.events.onclick.enable&&i.interactivity.el.addEventListener("click",function(){if(i.interactivity.mouse.click_pos_x=i.interactivity.mouse.pos_x,i.interactivity.mouse.click_pos_y=i.interactivity.mouse.pos_y,i.interactivity.mouse.click_time=(new Date).getTime(),i.interactivity.events.onclick.enable)switch(i.interactivity.events.onclick.mode){case"push":i.particles.move.enable?i.fn.modes.pushParticles(i.interactivity.modes.push.particles_nb,i.interactivity.mouse):1==i.interactivity.modes.push.particles_nb?i.fn.modes.pushParticles(i.interactivity.modes.push.particles_nb,i.interactivity.mouse):i.interactivity.modes.push.particles_nb>1&&i.fn.modes.pushParticles(i.interactivity.modes.push.particles_nb);break;case"remove":i.fn.modes.removeParticles(i.interactivity.modes.remove.particles_nb);break;case"bubble":i.tmp.bubble_clicking=!0;break;case"repulse":i.tmp.repulse_clicking=!0,i.tmp.repulse_count=0,i.tmp.repulse_finish=!1,setTimeout(function(){i.tmp.repulse_clicking=!1},1e3*i.interactivity.modes.repulse.duration)}})},i.fn.vendors.densityAutoParticles=function(){if(i.particles.number.density.enable){var e=i.canvas.el.width*i.canvas.el.height/1e3;i.tmp.retina&&(e/=2*i.canvas.pxratio);var a=e*i.particles.number.value/i.particles.number.density.value_area,t=i.particles.array.length-a;0>t?i.fn.modes.pushParticles(Math.abs(t)):i.fn.modes.removeParticles(t)}},i.fn.vendors.checkOverlap=function(e,a){for(var t=0;tv;v++)e.lineTo(i,0),e.translate(i,0),e.rotate(l);e.fill(),e.restore()},i.fn.vendors.exportImg=function(){window.open(i.canvas.el.toDataURL("image/png"),"_blank")},i.fn.vendors.loadImg=function(e){if(i.tmp.img_error=void 0,""!=i.particles.shape.image.src)if("svg"==e){var a=new XMLHttpRequest;a.open("GET",i.particles.shape.image.src),a.onreadystatechange=function(e){4==a.readyState&&(200==a.status?(i.tmp.source_svg=e.currentTarget.response,i.fn.vendors.checkBeforeDraw()):(console.log("Error pJS - Image not found"),i.tmp.img_error=!0))},a.send()}else{var t=new Image;t.addEventListener("load",function(){i.tmp.img_obj=t,i.fn.vendors.checkBeforeDraw()}),t.src=i.particles.shape.image.src}else console.log("Error pJS - No image.src"),i.tmp.img_error=!0},i.fn.vendors.draw=function(){"image"==i.particles.shape.type?"svg"==i.tmp.img_type?i.tmp.count_svg>=i.particles.number.value?(i.fn.particlesDraw(),i.particles.move.enable?i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw):cancelRequestAnimFrame(i.fn.drawAnimFrame)):i.tmp.img_error||(i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw)):void 0!=i.tmp.img_obj?(i.fn.particlesDraw(),i.particles.move.enable?i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw):cancelRequestAnimFrame(i.fn.drawAnimFrame)):i.tmp.img_error||(i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw)):(i.fn.particlesDraw(),i.particles.move.enable?i.fn.drawAnimFrame=requestAnimFrame(i.fn.vendors.draw):cancelRequestAnimFrame(i.fn.drawAnimFrame))},i.fn.vendors.checkBeforeDraw=function(){"image"==i.particles.shape.type?"svg"==i.tmp.img_type&&void 0==i.tmp.source_svg?i.tmp.checkAnimFrame=requestAnimFrame(check):(cancelRequestAnimFrame(i.tmp.checkAnimFrame),i.tmp.img_error||(i.fn.vendors.init(),i.fn.vendors.draw())):(i.fn.vendors.init(),i.fn.vendors.draw())},i.fn.vendors.init=function(){i.fn.retinaInit(),i.fn.canvasInit(),i.fn.canvasSize(),i.fn.canvasPaint(),i.fn.particlesCreate(),i.fn.vendors.densityAutoParticles(),i.particles.line_linked.color_rgb_line=hexToRgb(i.particles.line_linked.color)},i.fn.vendors.start=function(){isInArray("image",i.particles.shape.type)?(i.tmp.img_type=i.particles.shape.image.src.substr(i.particles.shape.image.src.length-3),i.fn.vendors.loadImg(i.tmp.img_type)):i.fn.vendors.checkBeforeDraw()},i.fn.vendors.eventsListeners(),i.fn.vendors.start()};Object.deepExtend=function(e,a){for(var t in a)a[t]&&a[t].constructor&&a[t].constructor===Object?(e[t]=e[t]||{},arguments.callee(e[t],a[t])):e[t]=a[t];return e},window.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(e){window.setTimeout(e,1e3/60)}}(),window.cancelRequestAnimFrame=function(){return window.cancelAnimationFrame||window.webkitCancelRequestAnimationFrame||window.mozCancelRequestAnimationFrame||window.oCancelRequestAnimationFrame||window.msCancelRequestAnimationFrame||clearTimeout}(),window.pJSDom=[],window.particlesJS=function(e,a){"string"!=typeof e&&(a=e,e="particles-js"),e||(e="particles-js");var t=document.getElementById(e),i="particles-js-canvas-el",s=t.getElementsByClassName(i);if(s.length)for(;s.length>0;)t.removeChild(s[0]);var n=document.createElement("canvas");n.className=i,n.style.width="100%",n.style.height="100%";var r=document.getElementById(e).appendChild(n);null!=r&&pJSDom.push(new pJS(e,a))},window.particlesJS.load=function(e,a,t){var i=new XMLHttpRequest;i.open("GET",a),i.onreadystatechange=function(a){if(4==i.readyState)if(200==i.status){var s=JSON.parse(a.currentTarget.response);window.particlesJS(e,s),t&&t()}else console.log("Error pJS - XMLHttpRequest status: "+i.status),console.log("Error pJS - File config not found")},i.send()};
\ No newline at end of file
diff --git a/nebula/frontend/templates/dashboard.html b/nebula/frontend/templates/dashboard.html
index 0f1eb0230..b65809a1d 100755
--- a/nebula/frontend/templates/dashboard.html
+++ b/nebula/frontend/templates/dashboard.html
@@ -2,18 +2,20 @@
{% block body %}
{{ super() }}
+
+
-
@@ -23,159 +25,251 @@
-
-
-
-
Dashboard
-
Deploy, analyze and monitor scenarios
-
-
-
-
- {% if scenario_running %}
- {% if scenario_completed %}
-
-
There is a scenario completed
-
The federation has reached the maximum number of rounds
-
- {% else %}
-
-
There is a scenario running
-
The federation is currently running
-
- {% endif %}
- {% if scenario_running %}
-
-
Scenarios queue {{ scenarios_finished
- }}/{{ scenarios_list_length }}
- {% if scenarios_finished != scenarios_list_length %}
-
Stop
- scenario queue
- {% endif %}
-
- {% endif %}
-
-
Scenario name: {{ scenario_running[0] }}
-
Scenario title: {{ scenario_running[3] }}
-
Scenario description: {{ scenario_running[4] }}
-
Scenario start time: {{ scenario_running[1] }}
-
-
Deploy new scenario
- {% if scenarios %}
-
Compare scenarios
- {% endif %}
+ {% if scenario_running %}
+
+
+
+
+ {% if scenario_completed %}
+
+
+ Completed
+
+
+ {% endif %}
+
+ {% if scenario_running %}
+
+
+
+ {{ scenarios_finished }}/{{ scenarios_list_length }}
+
+
+ {% if scenarios_finished != scenarios_list_length %}
+
+ Stop Queue
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
+
Scenario Name
+
{{ scenario_running[0] }}
+
+
+
+
+
Title
+
{{ scenario_running[3] }}
+
+
+
+
+
Description
+
{{ scenario_running[4] }}
+
+
+
+
+
Start Time
+
{{ scenario_running[1] }}
+
+
+
+
+
Status
+
+ Running
+
+
+
+
+
+
+
+
+
- {% else %}
-
There are no deployed scenarios
-
+ {% else %}
+
+
+
+
+
+
+
+
+
Get started by deploying your first scenario to begin monitoring and analyzing your federated learning process.
+
+
+
+ Deploy a Scenario
+
+ {% if scenarios %}
+
+ Compare Scenarios
+
+ {% endif %}
+
+
+
- {% endif %}
+ {% endif %}
{% if scenarios %}
-
-
Scenarios in the database
-
-
-
-
- {% if user_role == "admin" %}
- | User |
- {% endif %}
- Title |
- Start time |
- Model |
- Dataset |
- Rounds |
- Status |
- Action |
-
-
- {% for name, start_time, end_time, title, description, status, network_subnet, model, dataset,
- rounds, role, username, gpu_id in scenarios %}
-
- {% if user_role == "admin" %}
- | {{ username|lower }} |
- {% endif %}
- {{ title }} |
- {{ start_time }} |
- {{ model }} |
- {{ dataset }} |
- {{ rounds }} |
- {% if status == "running" %}
- Running |
- {% elif status == "completed" %}
- Completed |
- {% else %}
- Finished |
- {% endif %}
-
- Monitor
- Real-time metrics
-
-
- {% if status == "running" %}
- Stop scenario
- {% elif status == "completed" %}
- Stop scenario
- Stop scenario queue
- {% else %}
-
-
- {% endif %}
- |
-
-
- |
-
-
- |
-
-
- |
-
- |
-
- {% endfor %}
-
+
+
+
+
+
+
+
+
+ {% if user_role == "admin" %}
+ | User |
+ {% endif %}
+ Title |
+ Start Time |
+ Model |
+ Dataset |
+ Rounds |
+ Status |
+ Actions |
+
+
+
+ {% for name, start_time, end_time, title, description, status, network_subnet, model, dataset,
+ rounds, role, username, gpu_id in scenarios %}
+
+ {% if user_role == "admin" %}
+ | {{ username|lower }} |
+ {% endif %}
+ {{ title }} |
+ {{ start_time }} |
+ {{ model }} |
+ {{ dataset }} |
+ {{ rounds }} |
+
+ {% if status == "running" %}
+
+ Running
+
+ {% elif status == "completed" %}
+
+ Completed
+
+ {% else %}
+
+ Finished
+
+ {% endif %}
+ |
+
+
+
+
+
+
+
+
+
+
+ {% if status == "running" %}
+
+
+
+ {% elif status == "completed" %}
+
+
+
+
+
+
+ {% else %}
+
+
+ {% endif %}
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+
+ |
+
+ |
+
+ {% endfor %}
+
+
+
+
+
@@ -183,205 +277,33 @@
Scenarios in the database
{% endif %}
-
-{% if not user_logged_in %}
-
-{% endif %}
-
-
-
-
-
-
- if (notesRow.style.display === 'none') {
- try {
- const response = await fetch('/platform/dashboard/' + scenarioName + '/notes');
- const data = await response.json();
- console.log(data);
-
- if (data.status === 'success') {
- notesTextElement.value = data.notes;
- } else {
- notesTextElement.value = '';
- }
- } catch (error) {
- console.error('Error:', error);
- alert('An error occurred while retrieving the notes.');
- return;
- }
- }
-
- notesRow.style.display = notesRow.style.display === 'none' ? '' : 'none';
- }
-
- async function toggleConfigRow(scenarioName) {
- const configRow = document.getElementById('config-row-' + scenarioName);
- const configTextElement = document.getElementById('config-text-' + scenarioName);
-
- if (configRow.style.display === 'none') {
- try {
- const response = await fetch(`/platform/dashboard/${scenarioName}/config`);
- const data = await response.json();
-
- if (data.status === 'success') {
- configTextElement.value = JSON.stringify(data.config, null, 2);
- } else {
- configTextElement.value = 'No configuration available.';
- }
- } catch (error) {
- console.error('Error:', error);
- alert('An error occurred while retrieving the configuration.');
- return;
- }
- }
- configRow.style.display = configRow.style.display === 'none' ? '' : 'none';
- }
-
- async function saveNotes(scenarioName) {
- const notesText = document.getElementById('notes-text-' + scenarioName).value;
-
- const response = await fetch('/platform/dashboard/' + scenarioName + '/save_note', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ notes: notesText }),
- });
-
- const data = await response.json();
- // console.log(data);
- if (data.status === 'success') {
- showAlert('success', 'Notes saved successfully');
- } else {
- if (data.code === 401) {
- showAlert('info', 'Some functionalities are disabled in the demo version. Please, log in to access all functionalities.');
- } else {
- showAlert('error', 'Failed to save notes');
- }
- }
- }
-
+{% if scenario_running %}
+
+
+
+
Scenario Running
+
{{ scenario_running[0] }}
+
+
+{% endif %}
{% endblock %}
diff --git a/nebula/frontend/templates/deployment.html b/nebula/frontend/templates/deployment.html
index 123bd11e5..6a8bbe244 100755
--- a/nebula/frontend/templates/deployment.html
+++ b/nebula/frontend/templates/deployment.html
@@ -3,6 +3,8 @@
{{ super() }}
+
+
Deployment
@@ -11,21 +13,18 @@
Deployment
-
-
+
@@ -48,11 +47,11 @@
Confirm the deployment of the d
-
+
@@ -219,11 +218,7 @@
Deployment type