diff --git a/README.md b/README.md
index ffcca628..3f760b48 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,17 @@ Common use cases:
State machine-related issues often require several data checks and conditional logic to identify. These issues are typically difficult to capture using standard logs and metrics but can be easily addressed using Sentinela Monitoring.
+# Dashboard
+Sentinela provides a web dashboard with 2 sections:
+1. an overview of the monitors and their alerts and issues
+2. a monitor editor, where you can create and edit monitors directly from the browser
+
+**Overview**
+
+
+**Editor**
+
+
# Documentation
1. [Overview](./docs/overview.md)
2. [Building a Monitor](./docs/monitor.md)
diff --git a/configs/configs-scalable.yaml b/configs/configs-scalable.yaml
index 597cef06..a8f5fe30 100644
--- a/configs/configs-scalable.yaml
+++ b/configs/configs-scalable.yaml
@@ -34,6 +34,7 @@ application_queue:
http_server:
port: 8000
log_level: error
+ dashboard_enabled: true
time_zone: America/Sao_Paulo
diff --git a/configs/configs.yaml b/configs/configs.yaml
index 70415594..e612a65f 100644
--- a/configs/configs.yaml
+++ b/configs/configs.yaml
@@ -29,6 +29,7 @@ application_queue:
http_server:
port: 8000
log_level: error
+ dashboard_enabled: true
time_zone: America/Sao_Paulo
diff --git a/docs/configuration_file.md b/docs/configuration_file.md
index 51264fb2..bcde4b2d 100644
--- a/docs/configuration_file.md
+++ b/docs/configuration_file.md
@@ -57,7 +57,10 @@ application_queue:
```
## HTTP Server
-- `http_server.port`: Integer. Port for the HTTP server.
+- `http_server`:
+ - `port`: Integer. Port for the HTTP server.
+ - `log_level`: String. Log level for the HTTP server. Can be `default`, `warning`, `error` or `none`. Defaults to `default`.
+ - `dashboard_enabled`: Boolean. Flag to enable the Sentinela dashboard. Defaults to `false`.
## Time Zone
- `time_zone`: String. Time zone to use for cron scheduling and notification messages.
diff --git a/docs/http_server.md b/docs/http_server.md
index 3a34a883..35b2e80e 100644
--- a/docs/http_server.md
+++ b/docs/http_server.md
@@ -1,7 +1,7 @@
# HTTP server
The HTTP server provides an API to interact with Sentinela. The available routes are organized into two main categories, based on the deployment setup.
-If the container is deployed with the **Controller** (either standalone or alongside the Executor in the same container), all routes are available, allowing interactions with Monitors, Issues, and Alerts.
+If the container is deployed with the **Controller** (either standalone or alongside the Executor in the same container), all routes are available, allowing interactions with Monitors, Issues, Alerts and the dashboard.
If the container is deployed with only the **Executor**, only base routes are available.
@@ -23,6 +23,11 @@ Exposes Prometheus-formatted metrics, enabling external monitoring and observabi
# Interaction routes
These routes are available only when the container deployment includes the **Controller** component.
+## Dashboard
+**`/dashboard`**
+
+Serves a simple dashboard interface, providing a quick way to create, enable or disable and change the monitors code.
+
## List monitors
**`GET /monitors/list`**
diff --git a/docs/images/dashboard_editor.png b/docs/images/dashboard_editor.png
new file mode 100644
index 00000000..34661283
Binary files /dev/null and b/docs/images/dashboard_editor.png differ
diff --git a/docs/images/dashboard_overview.png b/docs/images/dashboard_overview.png
new file mode 100644
index 00000000..33e7283d
Binary files /dev/null and b/docs/images/dashboard_overview.png differ
diff --git a/resources/kubernetes_template/config_map.yaml b/resources/kubernetes_template/config_map.yaml
index 3e276dae..394900b3 100644
--- a/resources/kubernetes_template/config_map.yaml
+++ b/resources/kubernetes_template/config_map.yaml
@@ -40,6 +40,7 @@ data:
http_server:
port: 8000
log_level: error
+ dashboard_enabled: true
time_zone: America/Sao_Paulo
diff --git a/src/components/http_server/dashboard/css/styles.css b/src/components/http_server/dashboard/css/styles.css
new file mode 100644
index 00000000..d3add474
--- /dev/null
+++ b/src/components/http_server/dashboard/css/styles.css
@@ -0,0 +1,871 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+[x-cloak] {
+ display: none !important;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background-color: #0d1117;
+ color: #f0f6fc;
+}
+
+.top-bar {
+ display: flex;
+ gap: 0;
+ background-color: #161b22;
+ border-bottom: 1px solid #30363d;
+ padding: 0;
+ align-items: center;
+}
+
+.top-bar h1 {
+ color: #ffffff;
+ font-size: 20px;
+ font-weight: 600;
+ margin: 0 30px 0 20px;
+}
+
+.nav-btn {
+ padding: 15px 30px;
+ background: none;
+ border: none;
+ color: #8b949e;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s;
+ border-bottom: 2px solid transparent;
+}
+
+.nav-btn:hover {
+ color: #f0f6fc;
+ background-color: #21262d;
+}
+
+.nav-btn.active {
+ color: #58a6ff;
+ border-bottom-color: #58a6ff;
+}
+
+.overview-container {
+ display: flex;
+ gap: 0;
+ padding: 5px;
+ height: calc(100vh - 50px);
+ overflow: hidden;
+ position: relative;
+}
+
+.overview-column {
+ flex: 1;
+ min-width: 250px;
+ display: flex;
+ flex-direction: column;
+ background-color: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+#monitors-column,
+#alerts-column {
+ max-width: 500px;
+}
+
+.resize-handle {
+ width: 10px;
+ cursor: col-resize;
+ flex-shrink: 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ user-select: none;
+}
+
+.resize-handle::before {
+ width: 1px;
+ height: 100%;
+ background-color: #30363d;
+ transition: background-color 0.2s;
+}
+
+.resize-handle:hover::before,
+.resize-handle.dragging::before {
+ background-color: #58a6ff;
+}
+
+.overview-column h2 {
+ color: #f0f6fc;
+ font-size: 16px;
+ font-weight: 600;
+ padding: 15px 20px;
+ margin: 0;
+ border-bottom: 1px solid #30363d;
+ background-color: #1c2128;
+}
+
+.monitor-filters {
+ padding: 12px 20px;
+ background-color: #0d1117;
+ border-bottom: 1px solid #30363d;
+ display: flex;
+ gap: 20px;
+}
+
+.filter-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ color: #f0f6fc;
+ font-size: 13px;
+ user-select: none;
+}
+
+.filter-checkbox input[type="checkbox"] {
+ cursor: pointer;
+}
+
+.filter-checkbox:hover span {
+ color: #58a6ff;
+}
+
+.alert-info-section {
+ padding: 15px 20px;
+ background-color: #0d1117;
+ border-bottom: 1px solid #30363d;
+}
+
+.alert-info-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 0;
+ font-size: 13px;
+}
+
+.alert-info-label {
+ color: #8b949e;
+ font-weight: 500;
+}
+
+.alert-info-value {
+ color: #f0f6fc;
+ font-weight: 500;
+}
+
+.list-container {
+ flex: 1;
+ overflow-y: auto;
+ padding: 5px;
+}
+
+.list-item {
+ padding: 12px 15px;
+ margin-bottom: 8px;
+ background-color: #0d1117;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.list-item.monitor-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.list-item:hover {
+ background-color: #21262d;
+ border-color: #58a6ff;
+}
+
+.list-item.selected {
+ background-color: #1c2128;
+ border-color: #58a6ff;
+ box-shadow: 0 0 0 1px #58a6ff;
+}
+
+.list-item-name {
+ color: #f0f6fc;
+ font-weight: 500;
+ font-size: 14px;
+ margin-bottom: 4px;
+}
+
+.list-item-info {
+ color: #8b949e;
+ font-size: 12px;
+}
+
+.list-item-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ margin-left: 8px;
+}
+
+.badge-enabled {
+ background-color: #238636;
+ color: #fff;
+}
+
+.badge-disabled {
+ background-color: #6e7681;
+ color: #fff;
+}
+
+.badge-priority-low {
+ background-color: #58a6ff;
+ color: #0d1117;
+}
+
+.badge-priority-moderate {
+ background-color: #d29922;
+ color: #0d1117;
+}
+
+.badge-priority-high {
+ background-color: #db6d28;
+ color: #fff;
+}
+
+.badge-priority-critical {
+ background-color: #f85149;
+ color: #fff;
+}
+
+.badge-status-active {
+ background-color: #238636;
+ color: #fff;
+}
+
+.badge-status-inactive {
+ background-color: #30363d;
+ color: #8b949e;
+}
+
+.badge-alert {
+ background-color: #da3633;
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.placeholder-text,
+.loading-text {
+ color: #8b949e;
+ text-align: center;
+ padding: 40px 20px;
+ font-size: 14px;
+}
+
+.alert-actions {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid #30363d;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.alert-action-btn {
+ padding: 6px 12px;
+ background-color: #238636;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.alert-action-btn:hover:not(:disabled) {
+ background-color: #2ea043;
+}
+
+.alert-action-btn:disabled {
+ background-color: #30363d;
+ color: #8b949e;
+ cursor: not-allowed;
+}
+
+.issue-metadata {
+ margin-top: 8px;
+}
+
+.issue-metadata pre {
+ margin: 0;
+ color: #f0f6fc;
+ font-size: 11px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
+}
+
+.container {
+ display: flex;
+ height: calc(100vh - 50px);
+}
+
+.sidebar {
+ width: 300px;
+ background-color: #161b22;
+ border-right: 1px solid #30363d;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+}
+
+.main-content {
+ flex: 1;
+ padding: 5px 5px 0 5px;
+ background-color: #0d1117;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.sidebar h1 {
+ color: #58a6ff;
+ margin-bottom: 30px;
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: #f0f6fc;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ font-size: 14px;
+ background-color: #21262d;
+ color: #f0f6fc;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #58a6ff;
+ box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
+}
+
+option.monitor-enabled {
+ color: white;
+}
+
+option.monitor-disabled {
+ color: gray;
+}
+
+.btn {
+ background-color: #238636;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: background-color 0.2s;
+}
+
+.btn:hover {
+ background-color: #2ea043;
+}
+
+.btn:disabled {
+ background-color: #484f58;
+ cursor: not-allowed;
+ color: #8b949e;
+}
+
+.btn-secondary {
+ background-color: #21262d;
+ border: 1px solid #30363d;
+ color: #f0f6fc;
+}
+
+.btn-secondary:hover {
+ background-color: #30363d;
+}
+
+.hidden {
+ display: none;
+}
+
+#monitor-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+#monitor-form {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.monitor-actions {
+ margin-top: auto;
+ padding-top: 20px;
+}
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.checkbox-group input[type="checkbox"] {
+ width: auto;
+ margin-right: 10px;
+}
+
+.toggle-button {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ min-width: 120px;
+}
+
+.toggle-button.enabled {
+ background-color: #238636;
+ color: #ffffff;
+}
+
+.toggle-button.enabled:hover {
+ background-color: #2ea043;
+ box-shadow: 0 0 0 3px rgba(35, 134, 54, 0.15);
+}
+
+.toggle-button.disabled {
+ background-color: #da3633;
+ color: #ffffff;
+}
+
+.toggle-button.disabled:hover {
+ background-color: #f85149;
+ box-shadow: 0 0 0 3px rgba(218, 54, 51, 0.15);
+}
+
+.pending-change-message {
+ margin-bottom: 10px;
+ font-size: 12px;
+ color: #8b949e;
+ font-style: italic;
+ animation: fadeIn 0.5s;
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.divider {
+ height: 1px;
+ background-color: #30363d;
+ margin: 20px 0;
+}
+
+.button-group {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 30px;
+}
+
+.button-group .btn {
+ flex: 1;
+}
+
+.tabs {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.tab-buttons {
+ display: flex;
+ background-color: #161b22;
+ border-radius: 6px 6px 0 0;
+ border: 1px solid #30363d;
+ border-bottom: none;
+ position: relative;
+}
+
+.tab-button {
+ padding: 12px 20px;
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ border-right: 1px solid #30363d;
+ border-radius: 5px 5px 0 0;
+ transition: background-color 0.2s;
+ color: #f0f6fc;
+}
+
+.tab-button:last-child {
+ border-right: none;
+}
+
+.tab-button.active {
+ background-color: #58a6ff;
+ color: white;
+}
+
+.tab-button:hover:not(.active) {
+ background-color: #21262d;
+}
+
+.add-file-btn {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ background-color: #238636;
+ color: white;
+ border: none;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s;
+}
+
+.add-file-btn:hover {
+ background-color: #2ea043;
+}
+
+.delete-file-btn {
+ position: absolute;
+ right: 40px;
+ top: 50%;
+ transform: translateY(-50%);
+ background-color: #da3633;
+ color: white;
+ border: none;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: bold;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s;
+}
+
+.delete-file-btn:hover {
+ background-color: #b62324;
+}
+
+.delete-file-btn.show {
+ display: flex;
+}
+
+.add-file-popover {
+ position: absolute;
+ top: 100%;
+ right: 10px;
+ background-color: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 15px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.3);
+ z-index: 1000;
+ min-width: 250px;
+ display: none;
+}
+
+.add-file-popover.active {
+ display: block;
+}
+
+.tab-content {
+ background-color: #0d1117;
+ border: 1px solid #30363d;
+ border-top: none;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+.tab-pane {
+ display: none;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.tab-pane.active {
+ display: flex;
+}
+
+.code-editor-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.validation-errors {
+ background-color: #2c1618;
+ border: 1px solid #da3633;
+ border-radius: 6px;
+ padding: 15px;
+ margin-top: 2px;
+ display: none;
+ flex-shrink: 0;
+ max-height: 20vh;
+ overflow-y: auto;
+}
+
+.validation-errors.show {
+ display: block;
+}
+
+.validation-errors h4 {
+ color: #ffa198;
+ margin: 0 0 10px 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.validation-errors .error-message {
+ color: #ffa198;
+ font-size: 13px;
+ line-height: 1.4;
+ margin-bottom: 8px;
+}
+
+.validation-errors .error-message:last-child {
+ margin-bottom: 0;
+}
+
+.validation-errors .error-details {
+ background-color: #1c0f11;
+ border: 1px solid #8b4348;
+ border-radius: 4px;
+ padding: 8px;
+ margin-top: 5px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+ white-space: pre-wrap;
+}
+
+.CodeMirror {
+ border-radius: 0 0 6px 6px;
+ background-color: #0d1117 !important;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ height: 100%;
+}
+
+.CodeMirror-sizer {
+ min-height: auto !important;
+}
+
+.CodeMirror-gutters {
+ background-color: #161b22 !important;
+ border-right: 1px solid #30363d !important;
+}
+
+.CodeMirror-linenumber {
+ color: #6e7681 !important;
+}
+
+.CodeMirror,
+.CodeMirror-scroll {
+ scrollbar-color: #6e7681 #0d1117;
+ scrollbar-width: thin;
+}
+
+.CodeMirror::-webkit-scrollbar,
+.CodeMirror-scroll::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+.CodeMirror::-webkit-scrollbar-track,
+.CodeMirror-scroll::-webkit-scrollbar-track {
+ background: #0d1117;
+}
+
+.CodeMirror::-webkit-scrollbar-thumb,
+.CodeMirror-scroll::-webkit-scrollbar-thumb {
+ background-color: #6e7681;
+ border-radius: 6px;
+ border: 2px solid #0d1117;
+}
+
+.CodeMirror::-webkit-scrollbar-thumb:hover,
+.CodeMirror-scroll::-webkit-scrollbar-thumb:hover {
+ background-color: #58a6ff;
+}
+
+/* List container scrollbar styling (overview columns) */
+.list-container {
+ scrollbar-color: #6e7681 #161b22;
+ scrollbar-width: thin;
+}
+
+.list-container::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+.list-container::-webkit-scrollbar-track {
+ background: #161b22;
+}
+
+.list-container::-webkit-scrollbar-thumb {
+ background-color: #6e7681;
+ border-radius: 6px;
+ border: 2px solid #0d1117;
+}
+
+.list-container::-webkit-scrollbar-thumb:hover {
+ background-color: #58a6ff;
+}
+
+.file-editor {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.file-editor-content {
+ flex: 1;
+ height: 100%;
+}
+
+.popover {
+ position: relative;
+ display: inline-block;
+ margin-top: 20px;
+}
+
+.popover-content {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ background-color: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 15px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.3);
+ z-index: 1000;
+ min-width: 250px;
+}
+
+.popover.active .popover-content {
+ display: block;
+}
+
+.toast {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ padding: 15px 20px;
+ border-radius: 6px;
+ color: white;
+ font-weight: 500;
+ z-index: 1001;
+ transform: translateX(150%);
+ transition: transform 0.3s ease;
+}
+
+.toast.show {
+ transform: translateX(0);
+}
+
+.toast.info {
+ background-color: #1887b3;
+}
+
+.toast.success {
+ background-color: #238636;
+}
+
+.toast.error {
+ background-color: #da3633;
+}
+
+.json-highlight {
+ background-color: #0d1117;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 5px;
+ overflow-x: auto;
+ line-height: 1.5;
+ font-size: 13px;
+ font-family: 'Courier New', 'Courier', monospace;
+ color: #f0f6fc;
+}
+
+.json-key {
+ color: #79c0ff;
+ font-weight: 600;
+}
+
+.json-string {
+ color: #a371f7;
+}
+
+.json-number {
+ color: #79f0ca;
+}
+
+.json-boolean {
+ color: #ff7b72;
+ font-weight: 600;
+}
+
+.json-null {
+ color: #ff7b72;
+ font-style: italic;
+}
diff --git a/src/components/http_server/dashboard/index.html b/src/components/http_server/dashboard/index.html
new file mode 100644
index 00000000..6a3fb6fb
--- /dev/null
+++ b/src/components/http_server/dashboard/index.html
@@ -0,0 +1,248 @@
+
+
+
+
+
+ Sentinela Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active Monitors
+
+
+
+
+
+
+ Loading monitors...
+
+
+ No monitors match the filters
+
+
+
+
+
+
+
+
+
Active Alerts
+
+
+ Select a monitor to view alerts
+
+
+ Loading alerts...
+
+
+ No active alerts for this monitor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active Issues
+
+
+ Select an alert to view issues
+
+
+ Loading issues...
+
+
+ No active issues for this alert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/http_server/dashboard/js/dashboard.js b/src/components/http_server/dashboard/js/dashboard.js
new file mode 100644
index 00000000..2f27f703
--- /dev/null
+++ b/src/components/http_server/dashboard/js/dashboard.js
@@ -0,0 +1,534 @@
+function dashboardApp() {
+ return {
+ currentSection: 'overview',
+ monitors: [],
+ alerts: [],
+ issues: [],
+ selectedMonitor: null,
+ selectedAlert: null,
+ selectedIssue: null,
+ includeInternal: true,
+ withAlerts: false,
+ editorMonitors: {},
+ monitorsLoading: false,
+ alertsLoading: false,
+ issuesLoading: false,
+ refreshInterval: null,
+
+ currentMonitor: null,
+ additionalFiles: {},
+ activeTab: 'code-tab',
+ monitorHasPendingChanges: false,
+ showAddFilePopover: false,
+ newFileName: '',
+
+ switchTab(tabId) {
+ this.activeTab = tabId;
+ this.$nextTick(() => {
+ const editor = tabId === 'code-tab' ? getCodeEditor('main') : getCodeEditor(tabId);
+ if (editor) refreshEditor(editor);
+ });
+ },
+
+ deleteCurrentFile() {
+ const fileName = this.activeTab;
+ if (fileName === 'code-tab' || !fileName || !(fileName in this.additionalFiles)) return;
+ delete this.additionalFiles[fileName];
+ this.$nextTick(() => {
+ deleteCodeEditor(fileName);
+ });
+ this.switchTab('code-tab');
+ this.monitorHasPendingChanges = true;
+ },
+
+ async createAdditionalFile() {
+ const fileName = this.newFileName.trim();
+ if (!fileName) return;
+ if (fileName in this.additionalFiles) {
+ showToast('File already exists', 'error');
+ return;
+ }
+ this.additionalFiles[fileName] = '';
+ this.newFileName = '';
+ this.showAddFilePopover = false;
+ await this.$nextTick();
+ initializeAdditionalFileEditor(fileName);
+ this.switchTab(fileName);
+ this.monitorHasPendingChanges = true;
+ },
+
+ init() {
+ window.dashboardAppInstance = this;
+ this.restoreFilters();
+ this.restoreColumnWidths();
+ this.restoreActiveTab();
+ this.showSection(this.currentSection);
+ initializeCodeEditor();
+ this.loadMonitorsForEditor();
+ this.$nextTick(() => this.initializeResizeHandles());
+ },
+
+ restoreActiveTab() {
+ const savedSection = localStorage.getItem('current-section');
+ if (savedSection) {
+ this.currentSection = savedSection;
+ }
+ },
+
+ showSection(sectionName) {
+ this.currentSection = sectionName;
+ localStorage.setItem('current-section', sectionName);
+ sectionName === 'overview' ? this.loadOverview() : this.stopAutoRefresh();
+ },
+
+ async loadOverview() {
+ await this.loadActiveMonitors();
+ this.startAutoRefresh();
+ },
+
+ restoreFilters() {
+ const includeInternal = localStorage.getItem('monitor-filter-include-internal');
+ const withAlerts = localStorage.getItem('monitor-filter-with-alerts');
+ if (includeInternal !== null)
+ this.includeInternal = includeInternal === 'true';
+ if (withAlerts !== null)
+ this.withAlerts = withAlerts === 'true';
+ },
+
+ saveFilters() {
+ localStorage.setItem('monitor-filter-include-internal', this.includeInternal);
+ localStorage.setItem('monitor-filter-with-alerts', this.withAlerts);
+ },
+
+ restoreColumnWidths() {
+ ['monitors', 'alerts', 'issues'].forEach(name => {
+ const width = localStorage.getItem(`column-width-${name}`);
+ if (width) {
+ const col = document.getElementById(`${name}-column`);
+ if (col) {
+ col.style.width = width;
+ col.style.flex = 'none';
+ }
+ }
+ });
+ },
+
+ saveColumnWidth(columnId, width) {
+ const key = `column-width-${columnId.replace('-column', '')}`;
+ localStorage.setItem(key, width);
+ },
+
+ initializeResizeHandles() {
+ const handles = document.querySelectorAll('.resize-handle');
+ if (!handles.length) return;
+
+ const getMaxWidth = (columnId) => {
+ return (columnId === 'monitors-column' || columnId === 'alerts-column') ? 600 : Infinity;
+ };
+
+ handles.forEach(handle => {
+ let startX, startWidth, column;
+
+ const onMouseMove = (e) => {
+ if (!column) return;
+ const maxWidth = getMaxWidth(column.id);
+ const newWidth = Math.max(250, Math.min(maxWidth, startWidth + (e.clientX - startX)));
+ column.style.width = `${newWidth}px`;
+ column.style.flex = 'none';
+ e.preventDefault();
+ };
+
+ const onMouseUp = () => {
+ if (column) this.saveColumnWidth(column.id, column.style.width);
+ handle.classList.remove('dragging');
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ column = null;
+ };
+
+ handle.addEventListener('mousedown', (e) => {
+ column = document.getElementById(handle.dataset.column);
+ if (!column) return;
+
+ startX = e.clientX;
+ startWidth = column.offsetWidth;
+ handle.classList.add('dragging');
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ });
+ },
+
+ onFilterChange() {
+ this.saveFilters();
+ this.loadActiveMonitors();
+ },
+
+ async fetchData(url, errorMessage) {
+ const response = await fetch(url);
+ if (!response.ok)
+ throw new Error(errorMessage || `HTTP ${response.status}`);
+ return response.json();
+ },
+
+ updateIfChanged(currentData, newData) {
+ return JSON.stringify(currentData) !== JSON.stringify(newData);
+ },
+
+ async loadData(url, dataKey, loadingKey, showLoading, processData) {
+ if (showLoading)
+ this[loadingKey] = true;
+
+ const loadData = async () => {
+ const data = await this.fetchData(url);
+ const processed = processData ? processData(data) : data;
+
+ if (this.updateIfChanged(this[dataKey], processed))
+ this[dataKey] = processed;
+ };
+
+ const handleError = (error) => {
+ console.error(`Error loading ${dataKey}:`, error);
+ if (showLoading)
+ this[dataKey] = [];
+ };
+
+ try {
+ await loadData();
+ } catch (error) {
+ handleError(error);
+ } finally {
+ if (showLoading) this[loadingKey] = false;
+ }
+ },
+
+ async loadActiveMonitors(showLoading = true) {
+ await this.loadData(
+ '/monitor/list',
+ 'monitors',
+ 'monitorsLoading',
+ showLoading,
+ (monitors) => {
+ let filtered = monitors.filter(m => m.enabled);
+ if (!this.includeInternal)
+ filtered = filtered.filter(m => !m.name.startsWith('internal.'));
+ if (this.withAlerts)
+ filtered = filtered.filter(m => m.active_alerts > 0);
+ const regular = filtered.filter(m => !m.name.startsWith('internal.')).sort((a, b) => a.name.localeCompare(b.name));
+ const internal = filtered.filter(m => m.name.startsWith('internal.')).sort((a, b) => a.name.localeCompare(b.name));
+ return [...regular, ...internal];
+ }
+ );
+ },
+
+ async loadAlertsForMonitor(monitorId, showLoading = true) {
+ await this.loadData(`/monitor/${monitorId}/alerts`, 'alerts', 'alertsLoading', showLoading);
+ },
+
+ async loadIssuesForAlert(alertId, showLoading = true) {
+ await this.loadData(`/alert/${alertId}/issues`, 'issues', 'issuesLoading', showLoading);
+ },
+
+ selectMonitor(monitor) {
+ this.selectedMonitor = monitor;
+ this.selectedAlert = null;
+ this.selectedIssue = null;
+ this.issues = [];
+ this.loadAlertsForMonitor(monitor.id);
+ },
+
+ selectAlert(alert) {
+ this.selectedAlert = alert;
+ this.selectedIssue = null;
+ this.loadIssuesForAlert(alert.id);
+ },
+
+ toggleIssue(issue) {
+ this.selectedIssue = this.selectedIssue?.id === issue.id ? null : issue;
+ },
+
+ async performAlertAction(alert, action, successMessage) {
+ const response = await fetch(`/alert/${alert.id}/${action}`, { method: 'POST' });
+
+ if (response.ok) {
+ showToast(successMessage);
+ return true;
+ }
+
+ showToast(`Failed to ${action} alert`, 'error');
+ return false;
+ },
+
+ async acknowledgeAlert(alert, event) {
+ event?.stopPropagation();
+ const success = await this.performAlertAction(alert, 'acknowledge', 'Alert acknowledged successfully');
+ if (success) {
+ alert.acknowledged = true;
+ this.startAutoRefresh();
+ }
+ },
+
+ async lockAlert(alert, event) {
+ event?.stopPropagation();
+ const success = await this.performAlertAction(alert, 'lock', 'Alert locked successfully');
+ if (success) {
+ alert.locked = true;
+ this.startAutoRefresh();
+ }
+ },
+
+ async solveAlert(alert, event) {
+ event?.stopPropagation();
+ const success = await this.performAlertAction(alert, 'solve', 'Alert solved successfully');
+ if (success && this.selectedMonitor) {
+ this.loadAlertsForMonitor(this.selectedMonitor.id);
+ this.startAutoRefresh();
+ }
+ },
+
+ startAutoRefresh() {
+ this.stopAutoRefresh();
+ this.refreshInterval = setInterval(() => {
+ this.loadActiveMonitors(false);
+ if (this.selectedMonitor)
+ this.loadAlertsForMonitor(this.selectedMonitor.id, false);
+ if (this.selectedAlert)
+ this.loadIssuesForAlert(this.selectedAlert.id, false);
+ }, 5000);
+ },
+
+ stopAutoRefresh() {
+ if (this.refreshInterval) {
+ clearInterval(this.refreshInterval);
+ this.refreshInterval = null;
+ }
+ },
+
+ getPriorityBadge(priority) {
+ const priorities = {
+ 1: { text: 'Critical', class: 'badge-priority-critical' },
+ 2: { text: 'High', class: 'badge-priority-high' },
+ 3: { text: 'Moderate', class: 'badge-priority-moderate' },
+ 4: { text: 'Low', class: 'badge-priority-low' },
+ 5: { text: 'Informational', class: 'badge-priority-low' }
+ };
+ return priorities[priority] || priorities[5];
+ },
+
+ getStatusBadgeClass(isActive) {
+ return isActive ? 'badge-status-active' : 'badge-status-inactive';
+ },
+
+ async loadMonitorsForEditor() {
+ const data = await this.fetchData(`${window.location.origin}/monitor/list`, 'Connection failed');
+ this.editorMonitors = {};
+ data.forEach(monitor => {
+ if (!monitor.name.startsWith('internal.')) {
+ this.editorMonitors[monitor.name] = monitor;
+ }
+ });
+ },
+
+ get monitorsList() {
+ return Object.values(this.editorMonitors);
+ },
+
+ async onMonitorSelect(event) {
+ const monitorName = event.target.value;
+
+ if (!monitorName) {
+ this.currentMonitor = null;
+ return;
+ }
+
+ if (monitorName === '___CREATE_NEW___') {
+ this.currentMonitor = { isNew: true };
+ return;
+ }
+
+ const existsOnServer = this.editorMonitors[monitorName]?.id !== undefined;
+ existsOnServer ? await this.loadExistingMonitor(monitorName) : this.setNewMonitor();
+ },
+
+ async loadExistingMonitor(monitorName) {
+ const data = await this.fetchData(`${window.location.origin}/monitor/${monitorName}`);
+ this.currentMonitor = data;
+ this.additionalFiles = data.additional_files || {};
+ this.activeTab = 'code-tab';
+
+ await this.$nextTick();
+
+ const mainEditor = getCodeEditor('main');
+ if (mainEditor) {
+ mainEditor.setValue(this.currentMonitor.code);
+ refreshEditor(mainEditor);
+ }
+
+ Object.keys(this.additionalFiles).forEach(fileName => {
+ initializeAdditionalFileEditor(fileName);
+ });
+
+ this.monitorHasPendingChanges = false;
+ },
+
+ async setNewMonitor() {
+ this.currentMonitor = { enabled: true, code: MONITOR_TEMPLATE, additional_files: {} };
+ this.additionalFiles = {};
+ this.activeTab = 'code-tab';
+
+ await this.$nextTick();
+
+ const mainEditor = getCodeEditor('main');
+ if (mainEditor) {
+ mainEditor.setValue(this.currentMonitor.code);
+ refreshEditor(mainEditor);
+ }
+
+ this.monitorHasPendingChanges = false;
+ },
+
+ async createNewMonitor() {
+ const monitorName = document.getElementById('new-monitor-name-input').value.trim();
+ if (!monitorName) {
+ showToast('Monitor name is required', 'error');
+ return;
+ }
+
+ try {
+ const formatResponse = await fetch(`${window.location.origin}/monitor/format_name/${encodeURIComponent(monitorName)}`, {
+ method: 'POST'
+ });
+
+ if (!formatResponse.ok) {
+ throw new Error(`HTTP ${formatResponse.status}: ${formatResponse.statusText}`);
+ }
+
+ const formatResult = await formatResponse.json();
+ const formattedName = formatResult.formatted_name;
+
+ const existingMonitor = this.editorMonitors[formattedName];
+
+ if (existingMonitor && existingMonitor.id !== undefined) {
+ showToast(`Monitor with formatted name "${formattedName}" already exists. Loading existing monitor.`, 'info');
+ document.getElementById('new-monitor-name-input').value = '';
+ document.getElementById('monitor-select').value = formattedName;
+ await this.loadExistingMonitor(formattedName);
+ return;
+ }
+
+ this.editorMonitors[formattedName] = { name: formattedName, enabled: true };
+
+ await this.$nextTick();
+ document.getElementById('monitor-select').value = formattedName;
+ document.getElementById('new-monitor-name-input').value = '';
+
+ this.currentMonitor = { name: formattedName, enabled: true, code: MONITOR_TEMPLATE, additional_files: {} };
+ this.additionalFiles = {};
+ this.activeTab = 'code-tab';
+
+ await this.$nextTick();
+
+ const mainEditor = getCodeEditor('main');
+ if (mainEditor) {
+ mainEditor.setValue(this.currentMonitor.code);
+ refreshEditor(mainEditor);
+ }
+
+ if (formattedName !== monitorName) {
+ showToast(`Monitor name formatted from "${monitorName}" to "${formattedName}"`, 'info');
+ }
+
+ } catch (error) {
+ console.error('Error creating monitor:', error);
+ showToast(`Error creating monitor: ${error.message}`, 'error');
+ }
+ },
+
+ cancelNewMonitor() {
+ document.getElementById('monitor-select').value = '';
+ document.getElementById('new-monitor-name-input').value = '';
+ this.currentMonitor = null;
+ },
+
+ async validateMonitor() {
+ const code = document.getElementById('monitor-code').value;
+
+ if (!code.trim()) {
+ showValidationErrors('Monitor code is required');
+ return;
+ }
+
+ hideValidationErrors();
+
+ try {
+ const response = await fetch(`${window.location.origin}/monitor/validate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ monitor_code: code })
+ });
+
+ const result = await response.json();
+ if (response.ok) {
+ showToast('Monitor validated successfully!');
+ } else {
+ showValidationErrors(result);
+ }
+ } catch (error) {
+ console.error('Validation error:', error);
+ showValidationErrors(`Network error: ${error.message}`);
+ }
+ },
+
+ async saveMonitor() {
+ const monitorName = document.getElementById('monitor-select').value;
+ const code = document.getElementById('monitor-code').value;
+ const enabled = this.currentMonitor?.enabled ?? document.getElementById('monitor-enabled').checked;
+
+ if (!monitorName || !code.trim()) {
+ showValidationErrors('Monitor name and code are required');
+ return;
+ }
+
+ hideValidationErrors();
+
+ try {
+ const response = await fetch(`${window.location.origin}/monitor/register/${monitorName}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ monitor_code: code,
+ additional_files: this.additionalFiles
+ })
+ });
+
+ const result = await response.json();
+
+ if (response.ok) {
+ showToast('Monitor saved successfully!');
+ const endpoint = enabled ? 'enable' : 'disable';
+ await fetch(`${window.location.origin}/monitor/${monitorName}/${endpoint}`, { method: 'POST' })
+ .catch(error => console.error(`Error ${endpoint}ing monitor:`, error));
+
+ this.monitorHasPendingChanges = false;
+ await this.loadMonitorsForEditor();
+ } else {
+ showValidationErrors(result);
+ }
+ } catch (error) {
+ console.error('Save error:', error);
+ showValidationErrors(`Network error: ${error.message}`);
+ }
+ },
+
+ toggleMonitorEnabled() {
+ if (this.currentMonitor) {
+ this.currentMonitor.enabled = !this.currentMonitor.enabled;
+ this.monitorHasPendingChanges = true;
+ }
+ },
+ };
+}
diff --git a/src/components/http_server/dashboard/js/editor.js b/src/components/http_server/dashboard/js/editor.js
new file mode 100644
index 00000000..145838cc
--- /dev/null
+++ b/src/components/http_server/dashboard/js/editor.js
@@ -0,0 +1,90 @@
+const codeEditorInstances = {};
+
+const CODEMIRROR_CONFIG = {
+ theme: 'material-darker',
+ lineNumbers: true,
+ indentUnit: 4,
+ tabSize: 4,
+ indentWithTabs: false,
+ lineWrapping: true,
+ autoCloseBrackets: true,
+ matchBrackets: true,
+};
+
+function getLanguageFromFileName(fileName) {
+ const extension = fileName.split('.').pop().toLowerCase();
+ const languageMap = {
+ 'py': 'python',
+ 'json': { name: 'javascript', json: true },
+ 'yaml': 'yaml',
+ 'yml': 'yaml',
+ 'sql': 'sql',
+ 'md': 'markdown',
+ };
+ return languageMap[extension] || null;
+}
+
+function createCodeEditor(element, mode, value = '') {
+ return CodeMirror.fromTextArea(element, {
+ ...CODEMIRROR_CONFIG,
+ mode: mode,
+ value: value
+ });
+}
+
+function markMonitorChanges() {
+ if (window.dashboardAppInstance?.currentMonitor) {
+ window.dashboardAppInstance.monitorHasPendingChanges = true;
+ }
+}
+
+function initializeCodeEditor() {
+ const mainCodeEditor = document.getElementById('monitor-code');
+ const editor = createCodeEditor(mainCodeEditor, 'python');
+ codeEditorInstances.main = editor;
+
+ editor.on('change', () => {
+ mainCodeEditor.value = editor.getValue();
+ markMonitorChanges();
+ });
+}
+
+function initializeAdditionalFileEditor(fileName) {
+ const container = document.getElementById(fileName);
+ if (!container) return;
+
+ const textarea = document.createElement('textarea');
+ textarea.id = `editor-${fileName}`;
+ textarea.value = window.dashboardAppInstance.additionalFiles[fileName];
+
+ const contentDiv = container.querySelector('.file-editor-content');
+ contentDiv.appendChild(textarea);
+
+ const mode = getLanguageFromFileName(fileName);
+ const editor = createCodeEditor(textarea, mode, window.dashboardAppInstance.additionalFiles[fileName]);
+ codeEditorInstances[fileName] = editor;
+
+ editor.on('change', () => {
+ window.dashboardAppInstance.additionalFiles[fileName] = editor.getValue();
+ markMonitorChanges();
+ });
+
+ refreshEditor(editor);
+}
+
+function getCodeEditor(editorKey) {
+ return codeEditorInstances[editorKey];
+}
+
+function deleteCodeEditor(editorKey) {
+ if (codeEditorInstances[editorKey]) {
+ codeEditorInstances[editorKey].toTextArea();
+ delete codeEditorInstances[editorKey];
+ }
+}
+
+function refreshEditor(editor) {
+ if (editor && editor.refresh) {
+ editor.refresh();
+ }
+}
diff --git a/src/components/http_server/dashboard/js/monitor-template.js b/src/components/http_server/dashboard/js/monitor-template.js
new file mode 100644
index 00000000..f849f297
--- /dev/null
+++ b/src/components/http_server/dashboard/js/monitor-template.js
@@ -0,0 +1,57 @@
+const MONITOR_TEMPLATE = `"""
+Monitor template
+Read the documentation to learn how to configure each field.
+"""
+
+from typing import TypedDict
+
+from monitor_utils import AlertOptions, CountRule, IssueOptions, MonitorOptions, PriorityLevels
+
+
+# Define the data structure of the issues
+class IssueDataType(TypedDict):
+ pass
+
+
+monitor_options = MonitorOptions(
+ search_cron="*/15 * * * *",
+ update_cron="*/5 * * * *",
+)
+
+# Define the behavior expected for the issues
+issue_options = IssueOptions(
+ model_id_key="id",
+ solvable=True,
+)
+
+# Define the alert triggering options
+alert_options = AlertOptions(
+ rule=CountRule(
+ priority_levels=PriorityLevels(
+ low=0,
+ moderate=1,
+ high=2,
+ critical=3,
+ )
+ )
+)
+
+
+async def search() -> list[IssueDataType] | None:
+ """Logic to search for the issues"""
+ pass
+
+
+async def update(issues_data: list[IssueDataType]) -> list[IssueDataType] | None:
+ """Logic to update the issues data"""
+ pass
+
+
+def is_solved(issue_data: IssueDataType) -> bool:
+ """Logic to determine if the issue is solved, based on its data"""
+ return True
+
+
+# Notification configurations
+notification_options = []
+`;
diff --git a/src/components/http_server/dashboard/js/preload.js b/src/components/http_server/dashboard/js/preload.js
new file mode 100644
index 00000000..78071ce7
--- /dev/null
+++ b/src/components/http_server/dashboard/js/preload.js
@@ -0,0 +1,13 @@
+// Apply stored column widths before page render to prevent flicker
+(function() {
+ const columns = ['monitors', 'alerts', 'issues'];
+
+ columns.forEach(name => {
+ const width = localStorage.getItem(`column-width-${name}`);
+ if (width) {
+ const style = document.createElement('style');
+ style.textContent = `#${name}-column { width: ${width}; flex: none; }`;
+ document.head.appendChild(style);
+ }
+ })
+})()
diff --git a/src/components/http_server/dashboard/js/utils.js b/src/components/http_server/dashboard/js/utils.js
new file mode 100644
index 00000000..9b70cd7f
--- /dev/null
+++ b/src/components/http_server/dashboard/js/utils.js
@@ -0,0 +1,53 @@
+function showToast(message, type = 'success') {
+ const toast = document.getElementById('toast');
+ toast.textContent = message;
+ toast.className = `toast ${type} show`;
+ setTimeout(() => toast.classList.remove('show'), 5000);
+}
+
+function toggleVisibility(elementId, show) {
+ document.getElementById(elementId).classList.toggle('hidden', !show);
+}
+
+function refreshEditor(editor) {
+ if (editor) {
+ requestAnimationFrame(() => editor.refresh());
+ }
+}
+
+function clearSelection(selector) {
+ document.querySelectorAll(selector).forEach(el => el.classList.remove('selected'));
+}
+
+function findBadgeByText(element, text) {
+ return Array.from(element.querySelectorAll('.list-item-badge')).find(badge => badge.textContent === text);
+}
+
+function updateBadgeStatus(badge, isActive) {
+ if (badge) {
+ badge.classList.toggle('badge-status-inactive', !isActive);
+ badge.classList.toggle('badge-status-active', isActive);
+ }
+}
+
+function syntaxHighlightJSON(obj) {
+ const json = JSON.stringify(obj, null, 2);
+ return json.replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => {
+ let cls = 'number';
+ if (/^"/.test(match)) {
+ if (/:$/.test(match)) {
+ cls = 'key';
+ } else {
+ cls = 'string';
+ }
+ } else if (/true|false/.test(match)) {
+ cls = 'boolean';
+ } else if (/null/.test(match)) {
+ cls = 'null';
+ }
+ return `${match}`;
+ });
+}
diff --git a/src/components/http_server/dashboard/js/validation.js b/src/components/http_server/dashboard/js/validation.js
new file mode 100644
index 00000000..f39f3097
--- /dev/null
+++ b/src/components/http_server/dashboard/js/validation.js
@@ -0,0 +1,38 @@
+function hideValidationErrors() {
+ document.getElementById('validation-errors').classList.remove('show');
+}
+
+function showValidationErrors(result) {
+ const errorsContainer = document.getElementById('validation-errors');
+ const errorsContent = document.getElementById('validation-errors-content');
+
+ let html = '';
+
+ if (result.message) {
+ html += `${result.message.replace(/\n/g, '
')}
`;
+ }
+
+ if (result.error) {
+ html += '';
+
+ if (typeof result.error === 'string') {
+ html += result.error.replace(/\n/g, '
');
+ } else if (Array.isArray(result.error)) {
+ result.error.forEach((error) => {
+ let parts = [];
+ if (error.loc && error.loc.length > 0)
+ parts.push(`Location: ${error.loc.join('.')}`);
+ if (error.type)
+ parts.push(`Type: ${error.type}`);
+ if (error.msg)
+ parts.push(`Message: ${error.msg}`);
+ html += `
${parts.join(' | ')}
`;
+ });
+ }
+
+ html += '
';
+ }
+
+ errorsContent.innerHTML = html;
+ errorsContainer.classList.add('show');
+}
diff --git a/src/components/http_server/dashboard_routes.py b/src/components/http_server/dashboard_routes.py
new file mode 100644
index 00000000..675f11c8
--- /dev/null
+++ b/src/components/http_server/dashboard_routes.py
@@ -0,0 +1,48 @@
+import logging
+from pathlib import Path
+
+from aiohttp import web
+from aiohttp.web_request import Request
+from aiohttp.web_response import Response
+
+import commands as commands
+
+DASHBOARD_FILES_PATH = Path(__file__).parent / "dashboard"
+EXTENSIONS_TYPE = {
+ ".css": "text/css",
+ ".js": "application/javascript",
+ ".html": "text/html",
+}
+
+_logger = logging.getLogger("dashboard_routes")
+
+dashboard_routes = web.RouteTableDef()
+base_route = "/dashboard"
+
+
+@dashboard_routes.get(base_route)
+@dashboard_routes.get(base_route + "/")
+async def get_dashboard(request: Request) -> Response:
+ """Serve the dashboard page"""
+ dashboard_path = DASHBOARD_FILES_PATH / "index.html"
+
+ with open(dashboard_path, "r") as file:
+ html_content = file.read()
+ return web.Response(text=html_content, content_type="text/html")
+
+
+@dashboard_routes.get(base_route + "/{path:.*}")
+@dashboard_routes.get(base_route + "/{path:.*}/")
+async def get_asset(request: Request) -> Response:
+ """Serve dashboard static assets"""
+ asset_path = request.match_info["path"]
+
+ path = DASHBOARD_FILES_PATH / asset_path
+ try:
+ with open(path, "r") as file:
+ content = file.read()
+ except FileNotFoundError:
+ return web.Response(text="Asset not found", status=404)
+
+ content_type = EXTENSIONS_TYPE.get(path.suffix, "text/plain")
+ return web.Response(text=content, content_type=content_type)
diff --git a/src/components/http_server/server.py b/src/components/http_server/server.py
index e1983924..bd580573 100644
--- a/src/components/http_server/server.py
+++ b/src/components/http_server/server.py
@@ -11,6 +11,7 @@
import components.executor.executor as executor
import registry as registry
from components.http_server.alert_routes import alert_routes
+from components.http_server.dashboard_routes import dashboard_routes
from components.http_server.issue_routes import issue_routes
from components.http_server.monitor_routes import monitor_routes
from configs import configs
@@ -81,11 +82,13 @@ async def init(controller_enabled: bool = False) -> None:
app.add_routes(base_routes)
- # Only the controller can receive action requests
+ # Only the controller can receive action requests and serve the dashboard
if controller_enabled:
app.add_routes(alert_routes)
app.add_routes(issue_routes)
app.add_routes(monitor_routes)
+ if configs.http_server.dashboard_enabled:
+ app.add_routes(dashboard_routes)
_runner = web.AppRunner(app)
await _runner.setup()
diff --git a/src/configs/configs_loader.py b/src/configs/configs_loader.py
index 7b69dfc6..de831604 100644
--- a/src/configs/configs_loader.py
+++ b/src/configs/configs_loader.py
@@ -34,6 +34,7 @@ class ApplicationDatabaseConfig:
class HttpServerConfig:
port: int
log_level: Literal["default", "warning", "error", "none"] = "default"
+ dashboard_enabled: bool = False
@dataclass
diff --git a/tests/components/http_server/test_dashboard_routes.py b/tests/components/http_server/test_dashboard_routes.py
new file mode 100644
index 00000000..73eb7793
--- /dev/null
+++ b/tests/components/http_server/test_dashboard_routes.py
@@ -0,0 +1,59 @@
+import aiohttp
+import pytest
+import pytest_asyncio
+
+import components.http_server as http_server
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+BASE_URL = "http://localhost:8000/dashboard"
+
+
+@pytest_asyncio.fixture(loop_scope="session", scope="module", autouse=True)
+async def setup_http_server():
+ """Start the HTTP server"""
+ await http_server.init(controller_enabled=True)
+ yield
+ await http_server.wait_stop()
+
+
+async def test_get_dashboard():
+ """The 'dashboard' route should serve the index.html file"""
+ async with aiohttp.ClientSession() as session:
+ async with session.get(BASE_URL) as response:
+ assert response.status == 200
+ assert response.content_type == "text/html"
+ content = await response.text()
+ assert "" in content or "