diff --git a/package-lock.json b/package-lock.json
index ffcd0c7..a6cc5af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"marked": "^18.0.5",
"pinia": "^2.1.7",
"primeicons": "^6.0.1",
+ "uplot": "^1.6.32",
"vue": "^3.4.21",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.3.0"
@@ -4411,6 +4412,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/uplot": {
+ "version": "1.6.32",
+ "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
+ "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
+ "license": "MIT"
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
diff --git a/package.json b/package.json
index 6f7f6ab..985375c 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"marked": "^18.0.5",
"pinia": "^2.1.7",
"primeicons": "^6.0.1",
+ "uplot": "^1.6.32",
"vue": "^3.4.21",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.3.0"
diff --git a/src/components/NotificationsSettings.vue b/src/components/NotificationsSettings.vue
new file mode 100644
index 0000000..feda15e
--- /dev/null
+++ b/src/components/NotificationsSettings.vue
@@ -0,0 +1,416 @@
+
+
+
+
+
+
Apps (like Observability) send alerts here, for example when a container is auto-restarted.
+
+
+
+
+
+
+
+ {{ t.name || "Untitled" }}
+ {{ kindOf(t.url) }}
+
+
+
+
+
No targets yet. Add one below.
+
+
+
+
+
+
Add a target
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/base/TimeRangePicker.vue b/src/components/base/TimeRangePicker.vue
new file mode 100644
index 0000000..c61a2b4
--- /dev/null
+++ b/src/components/base/TimeRangePicker.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/charts/TimeSeriesChart.vue b/src/components/charts/TimeSeriesChart.vue
new file mode 100644
index 0000000..8a06007
--- /dev/null
+++ b/src/components/charts/TimeSeriesChart.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
diff --git a/src/components/plugins/PluginSlot.vue b/src/components/plugins/PluginSlot.vue
new file mode 100644
index 0000000..1cc841f
--- /dev/null
+++ b/src/components/plugins/PluginSlot.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/plugins/kinds/MetricsPanel.vue b/src/components/plugins/kinds/MetricsPanel.vue
new file mode 100644
index 0000000..738173a
--- /dev/null
+++ b/src/components/plugins/kinds/MetricsPanel.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+ Auto-restarted {{ recoveries.length }} time{{ recoveries.length > 1 ? "s" : "" }} recently.
+
+
+
+
+
Metrics unavailable. Is the Observability app running?
+
+
+
+
No metrics for this deployment yet.
+
+
+
+
+
{{ panel.title }}
+
+
No data
+
+
+
+
+
+
+
+
diff --git a/src/components/plugins/kinds/SchemaForm.vue b/src/components/plugins/kinds/SchemaForm.vue
new file mode 100644
index 0000000..03a1024
--- /dev/null
+++ b/src/components/plugins/kinds/SchemaForm.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue
index 5078e85..a52b644 100644
--- a/src/layouts/DashboardLayout.vue
+++ b/src/layouts/DashboardLayout.vue
@@ -260,12 +260,16 @@
+
+ Observability
+
- Security & Monitoring
+ Security
{
const currentPageTitle = computed(() => {
const titles: Record = {
home: "Dashboard",
+ observability: "Observability",
deployments: "Deployments",
"deployment-detail": "Deployment Details",
containers: "Containers",
diff --git a/src/router/index.ts b/src/router/index.ts
index 3c2ea95..b78810b 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -106,6 +106,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/PluginsView.vue"),
meta: { permission: "templates:read" },
},
+ {
+ path: "observability",
+ name: "observability",
+ component: () => import("@/views/ObservabilityView.vue"),
+ meta: { permission: "deployments:read" },
+ },
{
path: "templates",
name: "templates",
diff --git a/src/services/api.ts b/src/services/api.ts
index 12225c2..b9bd698 100755
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -332,6 +332,19 @@ export const settingsApi = {
generateSubdomain: () => apiClient.get("/subdomain/generate"),
};
+export interface NotificationTarget {
+ id: string;
+ name: string;
+ url: string;
+ enabled: boolean;
+}
+
+export const notificationsApi = {
+ getTargets: () => apiClient.get<{ targets: NotificationTarget[] }>("/notifications/targets"),
+ updateTargets: (targets: NotificationTarget[]) => apiClient.put("/notifications/targets", { targets }),
+ test: (url: string) => apiClient.post("/notifications/test", { url }),
+};
+
export const configApi = {
list: () => apiClient.get<{ config: ConfigEntry[]; runtime: Record }>("/config"),
get: (key: string) => apiClient.get<{ entry: ConfigEntry; runtime: boolean }>(`/config/${key}`),
diff --git a/src/services/observability.ts b/src/services/observability.ts
new file mode 100644
index 0000000..0c2ec22
--- /dev/null
+++ b/src/services/observability.ts
@@ -0,0 +1,61 @@
+import { apiClient } from "./api";
+
+// Metric names follow the OpenTelemetry container semantic conventions.
+export const METRIC = {
+ cpu: "container.cpu.usage",
+ memUsage: "container.memory.usage",
+ memLimit: "container.memory.limit",
+ netRx: "container.network.io.rx",
+ netTx: "container.network.io.tx",
+} as const;
+
+export interface ContainerMetrics {
+ container: string;
+ metrics: Record;
+ updated: string;
+}
+
+export interface DeploymentMetrics {
+ deployment: string;
+ containers: ContainerMetrics[];
+}
+
+export interface Sample {
+ time: string;
+ value: number;
+}
+
+export interface ContainerHealth {
+ container: string;
+ deployment: string;
+ status: "healthy" | "unhealthy" | "starting" | "none";
+}
+
+export interface RecoveryEvent {
+ container: string;
+ deployment: string;
+ at: string;
+}
+
+// Served by the observability plugin, reached through the agent's plugin proxy.
+const base = "/plugin/observability";
+
+export interface MetricSeries {
+ containers: string[];
+ timestamps: number[];
+ values: (number | null)[][];
+}
+
+export const observabilityApi = {
+ latest: () => apiClient.get(`${base}/metrics/latest`),
+ timeseries: (deployment: string, sinceRange = "15m") =>
+ apiClient.get<{ metrics: Record }>(`${base}/metrics/timeseries`, {
+ params: { deployment, since: sinceRange },
+ }),
+ series: (deployment: string, container: string, metric: string, since = "15m") =>
+ apiClient.get<{ samples: Sample[] }>(`${base}/metrics/series`, {
+ params: { deployment, container, metric, since },
+ }),
+ health: () => apiClient.get(`${base}/health`),
+ recoveries: () => apiClient.get(`${base}/health/events`),
+};
diff --git a/src/services/pluginApi.ts b/src/services/pluginApi.ts
new file mode 100644
index 0000000..c4e6e3b
--- /dev/null
+++ b/src/services/pluginApi.ts
@@ -0,0 +1,11 @@
+import { apiClient } from "./api";
+
+// pluginApi builds calls to a plugin's own API, which the agent proxies at /plugin//.
+export function pluginApi(name: string) {
+ const base = `/plugin/${name}`;
+ return {
+ get: (path: string, params?: Record) => apiClient.get(`${base}${path}`, { params }),
+ put: (path: string, body?: unknown) => apiClient.put(`${base}${path}`, body),
+ post: (path: string, body?: unknown) => apiClient.post(`${base}${path}`, body),
+ };
+}
diff --git a/src/stores/plugins.ts b/src/stores/plugins.ts
index e0debb4..baff616 100644
--- a/src/stores/plugins.ts
+++ b/src/stores/plugins.ts
@@ -2,6 +2,16 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { pluginsApi } from "@/services/api";
+export type PluginSlot = "deployment.detail" | "settings" | "overview";
+
+export interface UIExtension {
+ slot: PluginSlot;
+ kind: "metrics-panel" | "form" | "timeline";
+ title?: string;
+ icon?: string;
+ endpoint?: string;
+}
+
export interface Plugin {
name: string;
version: string;
@@ -13,6 +23,7 @@ export interface Plugin {
enabled: boolean;
capabilities?: string[];
config_schema?: Record;
+ ui_extensions?: UIExtension[];
widget?: {
enabled: boolean;
position: string;
@@ -66,6 +77,12 @@ export const usePluginsStore = defineStore("plugins", () => {
return plugins.value.filter((p) => p.widget?.enabled);
};
+ // Plugins contributing UI to a slot, paired with the matching extension(s).
+ const getPluginsForSlot = (slot: PluginSlot) =>
+ plugins.value.flatMap((p) =>
+ (p.ui_extensions || []).filter((e) => e.slot === slot).map((extension) => ({ plugin: p, extension })),
+ );
+
return {
plugins,
loading,
@@ -74,5 +91,6 @@ export const usePluginsStore = defineStore("plugins", () => {
getPluginsByLocation,
getPluginsByCapability,
getWidgetPlugins,
+ getPluginsForSlot,
};
});
diff --git a/src/utils/uuid.test.ts b/src/utils/uuid.test.ts
new file mode 100644
index 0000000..ff0d101
--- /dev/null
+++ b/src/utils/uuid.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { randomUUID } from "./uuid";
+
+const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+const original = globalThis.crypto.randomUUID;
+
+afterEach(() => {
+ globalThis.crypto.randomUUID = original;
+});
+
+describe("randomUUID", () => {
+ it("returns a v4 UUID via crypto.randomUUID when available", () => {
+ expect(randomUUID()).toMatch(V4);
+ });
+
+ it("falls back to getRandomValues in a non-secure context", () => {
+ // Simulate plain HTTP on a LAN address, where crypto.randomUUID is undefined.
+ (globalThis.crypto as unknown as { randomUUID: undefined }).randomUUID = undefined;
+ const id = randomUUID();
+ expect(id).toMatch(V4);
+ });
+
+ it("produces distinct values", () => {
+ expect(randomUUID()).not.toBe(randomUUID());
+ });
+});
diff --git a/src/utils/uuid.ts b/src/utils/uuid.ts
new file mode 100644
index 0000000..2cdef6b
--- /dev/null
+++ b/src/utils/uuid.ts
@@ -0,0 +1,19 @@
+// randomUUID returns a v4 UUID. crypto.randomUUID only exists in secure contexts
+// (HTTPS or localhost), so over plain HTTP on a LAN address it is undefined; fall back
+// to crypto.getRandomValues, which is available everywhere.
+export function randomUUID(): string {
+ const c = globalThis.crypto;
+ if (c && typeof c.randomUUID === "function") {
+ return c.randomUUID();
+ }
+
+ const bytes = new Uint8Array(16);
+ c.getRandomValues(bytes);
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
+ .slice(6, 8)
+ .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
+}
diff --git a/src/views/DatabasesView.vue b/src/views/DatabasesView.vue
index 36bde68..b7dadd7 100644
--- a/src/views/DatabasesView.vue
+++ b/src/views/DatabasesView.vue
@@ -182,6 +182,7 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useNotificationsStore } from "@/stores/notifications";
import { containersApi, databasesApi, type DatabaseConnectionConfig } from "@/services/api";
+import { randomUUID } from "@/utils/uuid";
import DataTable from "@/components/DataTable.vue";
import ConfirmModal from "@/components/ConfirmModal.vue";
import ConnectionStatus from "@/components/database/ConnectionStatus.vue";
@@ -433,7 +434,7 @@ const saveConnection = async () => {
await new Promise((r) => setTimeout(r, 500));
const newConnection: DatabaseConnection = {
- id: editingConnection.value?.id || crypto.randomUUID(),
+ id: editingConnection.value?.id || randomUUID(),
name: connectionForm.value.name,
type: connectionForm.value.type,
host: connectionForm.value.host,
diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue
index 99c7b06..83cf4ed 100755
--- a/src/views/DeploymentDetailView.vue
+++ b/src/views/DeploymentDetailView.vue
@@ -89,6 +89,16 @@
{{ tab.label }}
+
@@ -730,6 +740,15 @@
+
+
@@ -1820,6 +1839,8 @@ import LogViewer from "@/components/LogViewer.vue";
import ConfirmModal from "@/components/ConfirmModal.vue";
import ContainerTerminal from "@/components/ContainerTerminal.vue";
import BackupsTab from "@/components/BackupsTab.vue";
+import PluginSlot from "@/components/plugins/PluginSlot.vue";
+import { usePluginsStore } from "@/stores/plugins";
import DomainsManager from "@/components/DomainsManager.vue";
import DomainFormModal from "@/components/DomainFormModal.vue";
import ContainerResourcesModal from "@/components/ContainerResourcesModal.vue";
@@ -1870,7 +1891,7 @@ const backLabel = computed(() => {
const deployment = ref
(null);
const loading = ref(false);
const error = ref("");
-const activeTab = ref("overview");
+const activeTab = ref((route.query.tab as string) || "overview");
const proxyStatus = ref(null);
const settingUpProxy = ref(false);
const requestingCert = ref(false);
@@ -1938,6 +1959,16 @@ const tabs = [
{ id: "config", label: "Configuration", icon: "pi pi-cog" },
];
+const pluginsStore = usePluginsStore();
+const pluginTabs = computed(() =>
+ (pluginsStore.getPluginsForSlot("deployment.detail") || []).map((e) => ({
+ id: `plugin:${e.plugin.name}`,
+ label: e.extension.title || e.plugin.display_name,
+ icon: e.extension.icon,
+ plugin: e.plugin.name,
+ })),
+);
+
const services = ref([]);
const resourceUsage = ref({
cpu: 0,
@@ -3163,6 +3194,13 @@ watch(activeTab, (newTab) => {
onMounted(() => {
fetchDeployment();
+ Promise.resolve(pluginsStore.fetchPlugins()).then(() => {
+ // A deep-link may point at a plugin tab that is not available (plugin not installed);
+ // fall back to Overview rather than showing an empty tab.
+ if (activeTab.value.startsWith("plugin:") && !pluginTabs.value.some((t) => t.id === activeTab.value)) {
+ activeTab.value = "overview";
+ }
+ });
deploymentJob.resume(route.params.name as string);
credentialsApi
.list()
@@ -3422,6 +3460,10 @@ onUnmounted(() => {
min-height: 500px;
}
+.plugin-tab {
+ padding: var(--space-5);
+}
+
.overview-tab {
padding: var(--space-5);
}
@@ -5110,8 +5152,8 @@ onUnmounted(() => {
}
.enable-bar-status.active {
- color: #059669;
- background: #d1fae5;
+ color: var(--color-success-700);
+ background: var(--color-success-50);
}
.security-grid {
@@ -5142,7 +5184,7 @@ onUnmounted(() => {
}
.settings-row-header:hover {
- background: #fafafa;
+ background: var(--surface-sunken);
}
.row-title {
@@ -5208,7 +5250,7 @@ onUnmounted(() => {
align-items: center;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--border-subtle);
- background: #fafafa;
+ background: var(--surface-sunken);
}
.section-title {
@@ -5289,7 +5331,7 @@ onUnmounted(() => {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.75rem;
- color: #374151;
+ color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
@@ -5299,9 +5341,9 @@ onUnmounted(() => {
}
.preset-btn.active {
- background: #dbeafe;
- border-color: #93c5fd;
- color: #1d4ed8;
+ background: var(--color-info-50, #eff6ff);
+ border-color: var(--color-primary-300, #93c5fd);
+ color: var(--color-primary-700, #1d4ed8);
}
.preset-btn i {
@@ -5395,7 +5437,7 @@ onUnmounted(() => {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.8125rem;
background: transparent;
- color: #374151;
+ color: var(--text);
}
.item-actions {
@@ -5434,8 +5476,8 @@ onUnmounted(() => {
}
.rate-badge {
- background: #dbeafe;
- color: #1d4ed8;
+ background: var(--color-info-50, #eff6ff);
+ color: var(--color-primary-700, #1d4ed8);
}
.burst-badge {
@@ -5705,7 +5747,7 @@ onUnmounted(() => {
}
.col-type {
- color: #374151;
+ color: var(--text);
}
.col-ip,
@@ -5735,23 +5777,23 @@ onUnmounted(() => {
}
.severity-badge.critical {
- background: #fee2e2;
- color: #991b1b;
+ background: var(--color-danger-50, #fef2f2);
+ color: var(--color-danger-700, #b91c1c);
}
.severity-badge.high {
- background: #ffedd5;
- color: #9a3412;
+ background: var(--color-warning-50);
+ color: var(--color-warning-700);
}
.severity-badge.medium {
- background: #fef3c7;
- color: #92400e;
+ background: var(--color-warning-50);
+ color: var(--color-warning-600);
}
.severity-badge.low {
background: var(--surface-inset);
- color: #4b5563;
+ color: var(--text-muted);
}
@media (max-width: 1024px) {
diff --git a/src/views/ObservabilityView.vue b/src/views/ObservabilityView.vue
new file mode 100644
index 0000000..0333605
--- /dev/null
+++ b/src/views/ObservabilityView.vue
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+
+
+
Observability app not running
+
Enable the Observability app to collect metrics and health.
+
Marketplace
+
+
+
+
+
+
+ {{ containerCount }}
+ Containers
+
+
+ {{ healthyCount }}
+ Healthy
+
+
+ {{ unhealthy.length }}
+ Unhealthy
+
+
+ {{ recoveries.length }}
+ Auto-recovered
+
+
+
+
+
+ Needs attention
+
+
+ {{ u.container }}
+ {{ deploymentLabel(u.deployment) }}
+
+
+
+
+
+
Fleet resources
+
+
+
+
+
+
+ Recent auto-recoveries
+
+ {{ r.container }}
+ {{ r.deployment || "unassigned" }}
+ {{ relTime(r.at) }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/SettingsView.test.ts b/src/views/SettingsView.test.ts
index 01db6d3..5424551 100644
--- a/src/views/SettingsView.test.ts
+++ b/src/views/SettingsView.test.ts
@@ -97,10 +97,10 @@ describe("SettingsView", () => {
});
describe("Tab navigation", () => {
- it("displays all eight tabs", () => {
+ it("displays all nine tabs", () => {
const wrapper = mountView();
const tabs = wrapper.findAll(".tab");
- expect(tabs.length).toBe(8);
+ expect(tabs.length).toBe(9);
});
it("has General tab", () => {
@@ -380,6 +380,7 @@ describe("SettingsView", () => {
{ id: "security", label: "Security & Monitoring", icon: "pi pi-shield" },
{ id: "terminal", label: "Terminal", icon: "pi pi-desktop" },
{ id: "healthchecks", label: "Health Checks", icon: "pi pi-heart" },
+ { id: "notifications", label: "Notifications", icon: "" },
{ id: "credentials", label: "Credentials", icon: "pi pi-key" },
{ id: "ai", label: "AI Assistant", icon: "" },
]);
diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue
index 99741f7..638e65f 100644
--- a/src/views/SettingsView.vue
+++ b/src/views/SettingsView.vue
@@ -13,6 +13,16 @@
{{ tab.label }}
+
+
+
+
+
+
+