diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4073f73..9b79f3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## [0.4.0-beta.5] - 2026-08-22
+
+### Added
+- Guided Fleet setup, peer access controls, runtime provider selection, and remote deployment inventories
+- Deployment autoscaling configuration with workload compatibility and activation guidance
+- Grouped notification incidents with editable targets and delivery rules
+
+### Changed
+- Deployments show the selected server in the navigation and remain local by default
+- Deployment configuration keeps scaling beside settings while service image changes remain in the overview
+
## [0.4.0-beta.4] - 2026-08-21
### Added
diff --git a/package-lock.json b/package-lock.json
index 568a2a2..27210da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@flatrun/ui",
- "version": "0.4.0-beta.4",
+ "version": "0.4.0-beta.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@flatrun/ui",
- "version": "0.4.0-beta.4",
+ "version": "0.4.0-beta.5",
"license": "MIT",
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
diff --git a/package.json b/package.json
index c2ca773..3684a89 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@flatrun/ui",
- "version": "0.4.0-beta.4",
+ "version": "0.4.0-beta.5",
"description": "Web interface for FlatRun container orchestration",
"author": "FlatRun",
"license": "MIT",
diff --git a/src/assets/design-system.css b/src/assets/design-system.css
index c9e84dc..d22dccb 100644
--- a/src/assets/design-system.css
+++ b/src/assets/design-system.css
@@ -52,14 +52,14 @@
--color-info-600: #2563eb;
--color-info-700: #1e40af;
- /* Border Radius (scale: 2, 4, 6, 8, 12) */
+ /* Border Radius */
--radius-xs: 2px;
- --radius-sm: 4px;
- --radius-md: 6px;
- --radius-lg: 8px;
- --radius-xl: 12px;
+ --radius-sm: 3px;
+ --radius-md: 4px;
+ --radius-lg: 6px;
+ --radius-xl: 8px;
--radius-modal: 14px;
- --radius-full: 9999px;
+ --radius-full: 4px;
/* Spacing */
--space-1: 0.25rem;
@@ -332,7 +332,7 @@
display: inline-block;
width: 10px;
height: 10px;
- border-radius: 50%;
+ border-radius: var(--radius-xs);
}
.status-indicator.running {
diff --git a/src/components/DeploymentAutoscaleCard.test.ts b/src/components/DeploymentAutoscaleCard.test.ts
new file mode 100644
index 0000000..8afc5b9
--- /dev/null
+++ b/src/components/DeploymentAutoscaleCard.test.ts
@@ -0,0 +1,124 @@
+import { flushPromises, mount } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import DeploymentAutoscaleCard from "./DeploymentAutoscaleCard.vue";
+
+vi.mock("@/stores/notifications", () => ({
+ useNotificationsStore: () => ({ success: vi.fn(), error: vi.fn() }),
+}));
+
+vi.mock("@/services/api", () => ({
+ autoscaleApi: {
+ getPolicy: vi.fn(),
+ updatePolicy: vi.fn(),
+ getCompatibility: vi.fn(),
+ updateWorkload: vi.fn(),
+ activate: vi.fn(),
+ },
+}));
+
+describe("DeploymentAutoscaleCard", () => {
+ beforeEach(async () => {
+ window.history.replaceState({}, "", "/");
+ vi.clearAllMocks();
+ const { autoscaleApi } = await import("@/services/api");
+ vi.mocked(autoscaleApi.getPolicy).mockResolvedValue({
+ data: {
+ enabled: true,
+ min_replicas: 1,
+ max_replicas: 3,
+ scale_up_percent: 80,
+ scale_down_percent: 30,
+ scale_up_windows: 3,
+ scale_down_windows: 10,
+ cooldown_seconds: 300,
+ allow_fleet_capacity: false,
+ state: { high_windows: 0, low_windows: 0 },
+ },
+ } as any);
+ vi.mocked(autoscaleApi.updatePolicy).mockImplementation(
+ async (_deployment, policy) =>
+ ({
+ data: { ...policy, state: { high_windows: 0, low_windows: 0 } },
+ }) as any,
+ );
+ vi.mocked(autoscaleApi.getCompatibility).mockResolvedValue({
+ data: {
+ compatible: true,
+ service: "app",
+ image: "nginx:alpine",
+ services: ["app"],
+ blockers: [],
+ warnings: [],
+ workload: { service: "app", stateless: true, storage: { mode: "none", class: "" } },
+ },
+ } as any);
+ vi.mocked(autoscaleApi.updateWorkload).mockResolvedValue({
+ data: {
+ compatible: true,
+ service: "app",
+ image: "nginx:alpine",
+ services: ["app"],
+ blockers: [],
+ warnings: [],
+ workload: { service: "app", stateless: true, storage: { mode: "none", class: "" } },
+ },
+ } as any);
+ vi.mocked(autoscaleApi.activate).mockResolvedValue({
+ data: { workload: { workload: "shop", desired: 1, available: 1 }, route: { id: "shop" } },
+ } as any);
+ });
+
+ it("activates managed scaling through a confirmation modal", async () => {
+ const { autoscaleApi } = await import("@/services/api");
+ const wrapper = mount(DeploymentAutoscaleCard, {
+ props: { deployment: "shop", canWrite: true },
+ global: {
+ stubs: {
+ BaseModal: {
+ props: ["visible"],
+ template: '
',
+ },
+ },
+ },
+ });
+ await flushPromises();
+ await wrapper.find(".activate-button").trigger("click");
+ await wrapper
+ .findAll("button")
+ .find((button) => button.text() === "Activate scaling")
+ ?.trigger("click");
+ await flushPromises();
+
+ expect(autoscaleApi.activate).toHaveBeenCalledWith("shop");
+ });
+
+ it("updates the policy through the deployment UI", async () => {
+ const { autoscaleApi } = await import("@/services/api");
+ const wrapper = mount(DeploymentAutoscaleCard, {
+ props: { deployment: "shop", canWrite: true },
+ global: {
+ stubs: {
+ BaseModal: {
+ props: ["visible"],
+ template: '
',
+ },
+ },
+ },
+ });
+ await flushPromises();
+ await wrapper
+ .findAll(".autoscale-header button")
+ .find((button) => button.text() === "Configure")
+ ?.trigger("click");
+ await wrapper.find('input[type="number"]').setValue(2);
+ await wrapper.find("#autoscale-policy-form").trigger("submit");
+ await flushPromises();
+
+ expect(autoscaleApi.updatePolicy).toHaveBeenCalledWith("shop", expect.objectContaining({ min_replicas: 2 }));
+ expect(autoscaleApi.updateWorkload).toHaveBeenCalledWith("shop", {
+ service: "app",
+ stateless: true,
+ storage: { mode: "none", class: "" },
+ });
+ });
+});
diff --git a/src/components/DeploymentAutoscaleCard.vue b/src/components/DeploymentAutoscaleCard.vue
new file mode 100644
index 0000000..1b0d3aa
--- /dev/null
+++ b/src/components/DeploymentAutoscaleCard.vue
@@ -0,0 +1,482 @@
+
+
+
+ Loading policy
+ {{ error }}
+
+
{{
+ policy.state.active ? "Managed" : policy.enabled ? "Enabled" : "Disabled"
+ }}
+
+ Replica range {{ policy.min_replicas }} to {{ policy.max_replicas }}
+
+
+ Scale up {{ policy.scale_up_percent }}% for {{ policy.scale_up_windows }} checks
+
+
+ Scale down {{ policy.scale_down_percent }}% for {{ policy.scale_down_windows }} checks
+
+
+ Fleet capacity {{ policy.allow_fleet_capacity ? "Allowed" : "Local only" }}
+
+
+ Workload
+
+ {{ compatibility?.compatible ? compatibility.service : "Needs configuration" }}
+
+
+
+
+
+ Cancel Save policy
+
+
+
+
FlatRun will create {{ policy?.min_replicas }} ready replicas before switching traffic.
+
The current Compose service stops only after the new route is live.
+
+ {{ activationError }}
+
+
+
+ Cancel
+ Activate scaling
+
+
+
+
+
+
+
+
diff --git a/src/components/GlobalSearch.vue b/src/components/GlobalSearch.vue
index a4149c3..ad00579 100644
--- a/src/components/GlobalSearch.vue
+++ b/src/components/GlobalSearch.vue
@@ -62,7 +62,8 @@ const DESTINATIONS: Destination[] = [
{ label: "Server Info", path: "/server-info", icon: "server", group: "System", perm: "system:read" },
{ label: "Terminal", path: "/system-terminal", icon: "terminal", group: "System", perm: "system:write" },
{ label: "Files", path: "/system/files", icon: "folder", group: "System", perm: "system:files" },
- { label: "Cluster", path: "/cluster", icon: "boxes", group: "System", perm: "cluster:read" },
+ { label: "Fleet", path: "/cluster", icon: "boxes", group: "Services", perm: "cluster:read" },
+ { label: "Notifications", path: "/notifications", icon: "bell", group: "Services", perm: "settings:read" },
{
label: "Infrastructure",
path: "/infrastructure",
diff --git a/src/components/NotificationsSettings.test.ts b/src/components/NotificationsSettings.test.ts
new file mode 100644
index 0000000..e357aab
--- /dev/null
+++ b/src/components/NotificationsSettings.test.ts
@@ -0,0 +1,160 @@
+import { flushPromises, mount } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import NotificationsSettings from "./NotificationsSettings.vue";
+
+vi.mock("@/stores/auth", () => ({
+ useAuthStore: () => ({ hasPermission: () => true }),
+}));
+
+vi.mock("@/stores/notifications", () => ({
+ useNotificationsStore: () => ({ success: vi.fn(), error: vi.fn() }),
+}));
+
+vi.mock("@/services/api", () => ({
+ notificationsApi: {
+ getTargets: vi.fn(),
+ updateTargets: vi.fn(),
+ getRules: vi.fn(),
+ updateRules: vi.fn(),
+ getIncidents: vi.fn(),
+ test: vi.fn(),
+ },
+}));
+
+describe("NotificationsSettings", () => {
+ beforeEach(async () => {
+ window.history.replaceState({}, "", "/");
+ vi.clearAllMocks();
+ const { notificationsApi } = await import("@/services/api");
+ vi.mocked(notificationsApi.getTargets).mockResolvedValue({
+ data: { targets: [{ id: "ops", name: "Operations", url: "********", kind: "email", enabled: true }] },
+ } as any);
+ vi.mocked(notificationsApi.getRules).mockResolvedValue({
+ data: {
+ rules: [
+ {
+ id: "critical-fleet",
+ name: "Critical fleet incidents",
+ enabled: true,
+ topics: ["fleet"],
+ severities: ["critical"],
+ notifications: ["opened", "resolved"],
+ target_ids: ["ops"],
+ },
+ ],
+ },
+ } as any);
+ vi.mocked(notificationsApi.getIncidents).mockResolvedValue({
+ data: {
+ incidents: [
+ {
+ id: "inc-42",
+ correlation_key: "node:prod-1",
+ status: "open",
+ severity: "critical",
+ title: "Node unavailable",
+ event_count: 4,
+ first_event_at: new Date().toISOString(),
+ last_event_at: new Date().toISOString(),
+ last_event: {
+ source: "fleet",
+ type: "node.unavailable",
+ title: "Node unavailable",
+ message: "The node stopped responding.",
+ scope: { node: "prod-1" },
+ },
+ },
+ ],
+ },
+ } as any);
+ vi.mocked(notificationsApi.updateRules).mockResolvedValue({ data: { rules: [] } } as any);
+ vi.mocked(notificationsApi.updateTargets).mockResolvedValue({ data: { targets: [] } } as any);
+ });
+
+ const mountSettings = () =>
+ mount(NotificationsSettings, {
+ global: {
+ stubs: {
+ BaseModal: {
+ props: ["visible", "title", "subtitle"],
+ template:
+ '{{ title }} {{ subtitle }}
',
+ },
+ },
+ },
+ });
+
+ it("loads incidents, rules, and targets as one notification view", async () => {
+ const { notificationsApi } = await import("@/services/api");
+ const wrapper = mountSettings();
+ await flushPromises();
+
+ expect(notificationsApi.getIncidents).toHaveBeenCalledOnce();
+ expect(notificationsApi.getRules).toHaveBeenCalledOnce();
+ expect(notificationsApi.getTargets).toHaveBeenCalledOnce();
+ expect(wrapper.text()).toContain("Node unavailable");
+ expect(wrapper.text()).toContain("inc-42");
+ expect(wrapper.text()).toContain("4 events");
+ expect(wrapper.get('a[href="https://flatrun.dev/docs/ui/notifications"]').text()).toContain("Guide");
+ });
+
+ it("creates a delivery rule through the modal", async () => {
+ const { notificationsApi } = await import("@/services/api");
+ const wrapper = mountSettings();
+ await flushPromises();
+
+ await wrapper.findAll(".section-tabs button")[1].trigger("click");
+ await wrapper.find(".content-header .btn-primary").trigger("click");
+ await wrapper.find("#rule-name").setValue("Capacity warnings");
+ await wrapper.find('input[value="capacity"]').setValue(true);
+ await wrapper.find('input[value="warning"]').setValue(true);
+ await wrapper.find('input[value="ops"]').setValue(true);
+ await wrapper.find("#rule-form").trigger("submit");
+ await flushPromises();
+
+ expect(notificationsApi.updateRules).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: "Critical fleet incidents",
+ }),
+ expect.objectContaining({
+ name: "Capacity warnings",
+ topics: ["capacity"],
+ severities: ["warning"],
+ target_ids: ["ops"],
+ }),
+ ]);
+ });
+
+ it("edits a saved target without replacing its hidden connection", async () => {
+ const { notificationsApi } = await import("@/services/api");
+ const wrapper = mountSettings();
+ await flushPromises();
+
+ await wrapper.findAll(".section-tabs button")[2].trigger("click");
+ await wrapper
+ .findAll("button")
+ .find((button) => button.text() === "Edit")!
+ .trigger("click");
+ await wrapper.find("#target-name").setValue("Primary operations");
+ await wrapper.find("#target-form").trigger("submit");
+ await flushPromises();
+
+ expect(notificationsApi.updateTargets).toHaveBeenCalledWith([
+ expect.objectContaining({ id: "ops", name: "Primary operations", url: "********", kind: "email" }),
+ ]);
+ });
+
+ it("opens incident details from a compact row", async () => {
+ const wrapper = mountSettings();
+ await flushPromises();
+
+ await wrapper
+ .findAll("button")
+ .find((button) => button.text() === "View")!
+ .trigger("click");
+
+ expect(wrapper.text()).toContain("Incident details");
+ expect(wrapper.text()).toContain("The node stopped responding.");
+ expect(wrapper.text()).toContain("inc-42");
+ });
+});
diff --git a/src/components/NotificationsSettings.vue b/src/components/NotificationsSettings.vue
index d791a55..d604791 100644
--- a/src/components/NotificationsSettings.vue
+++ b/src/components/NotificationsSettings.vue
@@ -1,422 +1,1164 @@
-
-
-
-
-
Apps (like Observability) send alerts here, for example when a container is auto-restarted.
+
+
-
+
+
+
+
+
+
+
Notifications could not be loaded
+
{{ loadError }}
+
+
Try again
+
-
-
-
-
- {{ t.name || "Untitled" }}
- {{ kindOf(t.url) }}
-
-
-
-
- Test
-
-
-
-
+
+
+
+
+
+
No incidents recorded
+
New infrastructure and application events will appear here as grouped incidents.
-
-
-
-
-
Add a target
-
-
-
- {{ opt.label }}
-
+
+
+
+
+
+
+
+ {{ incident.title }} {{ incident.status }}
+
+
{{ incident.last_event.message || incident.last_event.type }}
+
+ {{ incident.last_event.scope.node }}
+ {{ incident.last_event.scope.deployment }}
+ {{ incident.event_count }} events
+
+
+
+ {{ formatRelative(incident.last_event_at) }} {{ incident.id }}
+ View
+
+
+
+
+
+
+
+
+
No delivery rules
+
Without rules, enabled targets receive every incident update. Add a rule to filter delivery.
+
Create rule
+
+
+
+
+
+
{{ rule.name }}
+
+ {{ topic }} {{ severity }}
+
+
+
+ {{ rule.target_ids.length }} target{{ rule.target_ids.length === 1 ? "" : "s" }} {{ notificationLabel(rule) }}
+
+
+ {{ canWrite ? "Edit" : "View" }}
+
+ Remove
+
+
+
-
-
Name
-
+
+
+
+
+
No delivery targets
+
Add the first destination before creating delivery rules.
+
Add target
+
+
+
+
+
+
+
+
+ {{ target.name || "Untitled target" }} {{ kindOf(target) }}
+
+ Test
+
+ {{ canWrite ? "Edit" : "View" }}
+
+ Remove
+
+
+
+
-
-
-
-
-
-
-
- {{ authStore.currentUser.username }}
- {{ authStore.currentUser.role }}
-
-
{{ agentOnline ? "Connected" : "Disconnected" }}
-
-
- Sign Out
-
@@ -543,16 +577,16 @@
{{ stats.stoppedContainers }} Stopped
-
+
@@ -582,6 +616,7 @@ import { clusterApi, type ClusterPeer } from "@/services/api";
import Logo from "@/components/base/Logo.vue";
import Icon from "@/components/base/Icon.vue";
import GlobalSearch from "@/components/GlobalSearch.vue";
+import UserMenu from "@/components/UserMenu.vue";
import { useTheme } from "@/composables/useTheme";
const { theme, toggleTheme } = useTheme();
@@ -600,6 +635,7 @@ const isRefreshing = ref(false);
const envDropdownOpen = ref(false);
const currentServerName = ref("Local Server");
const clusterPeers = ref
([]);
+const selectedDeploymentServer = computed(() => (route.name === "deployments" ? String(route.query.server || "") : ""));
const expandedGroups = reactive({
stacks: true,
@@ -637,6 +673,16 @@ const toggleGroup = (group: keyof typeof expandedGroups) => {
expandedGroups[group] = !expandedGroups[group];
};
+const openServer = (server: string) => {
+ envDropdownOpen.value = false;
+ router.push(serverDeploymentRoute(server));
+};
+
+const serverDeploymentRoute = (server: string) => ({
+ path: "/deployments",
+ query: { server },
+});
+
const getUsageClass = (percentage: number) => {
if (percentage > 80) return "critical";
if (percentage > 60) return "warning";
@@ -646,12 +692,13 @@ const getUsageClass = (percentage: number) => {
const currentPageTitle = computed(() => {
const titles: Record = {
home: "Dashboard",
+ agents: "Agents",
observability: "Observability",
logs: "Logs",
alerts: "Alerts",
dashboards: "Dashboards",
"dashboard-detail": "Dashboard",
- deployments: "Deployments",
+ deployments: selectedDeploymentServer.value ? `${selectedDeploymentServer.value} deployments` : "Deployments",
"deployment-detail": "Deployment Details",
containers: "Containers",
images: "Images",
@@ -666,7 +713,8 @@ const currentPageTitle = computed(() => {
updates: "Updates",
"system-terminal": "System Terminal",
"system-files": "System Files",
- cluster: "Cluster",
+ cluster: "Fleet",
+ notifications: "Notifications",
databases: "Database Servers",
security: "Security & Monitoring",
certificates: "SSL Certificates",
@@ -688,7 +736,12 @@ const breadcrumbs = computed(() => {
if (routeName === "deployments") {
crumbs.push({ label: "Stacks", path: "" });
- crumbs.push({ label: "Deployments", path: "" });
+ if (selectedDeploymentServer.value) {
+ crumbs.push({ label: "Deployments", path: "/deployments" });
+ crumbs.push({ label: selectedDeploymentServer.value, path: "" });
+ } else {
+ crumbs.push({ label: "Deployments", path: "" });
+ }
} else if (["containers", "images", "volumes", "networks", "docker-ports"].includes(routeName)) {
crumbs.push({ label: "Docker", path: "" });
crumbs.push({ label: currentPageTitle.value, path: "" });
@@ -701,7 +754,6 @@ const breadcrumbs = computed(() => {
"server-info",
"system-terminal",
"system-files",
- "cluster",
].includes(routeName)
) {
crumbs.push({ label: "System", path: "" });
@@ -856,6 +908,7 @@ onMounted(() => {
}
.env-option {
+ width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -866,6 +919,12 @@ onMounted(() => {
font-size: 0.8125rem;
text-decoration: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ border-top: 0;
+ border-right: 0;
+ border-left: 0;
+ background: transparent;
+ font-family: inherit;
+ text-align: left;
}
.env-option:last-child {
@@ -878,9 +937,15 @@ onMounted(() => {
}
.env-option.active {
+ background: rgba(59, 130, 246, 0.12);
color: var(--sidebar-text-active);
}
+.env-option:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
.env-option.active .pi-check {
margin-left: auto;
font-size: 0.6875rem;
@@ -1087,6 +1152,29 @@ onMounted(() => {
border-left-color: var(--accent);
}
+.nav-server {
+ padding-top: var(--space-2);
+ padding-bottom: var(--space-2);
+ font-size: var(--text-xs);
+}
+
+.nav-server.disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.server-dot {
+ width: 6px;
+ height: 6px;
+ flex: 0 0 6px;
+ background: var(--c-red);
+ border-radius: var(--radius-full);
+}
+
+.server-dot.online {
+ background: var(--c-green);
+}
+
.nav-count {
background: rgba(255, 255, 255, 0.1);
padding: 0.125rem 0.5rem;
@@ -1142,48 +1230,6 @@ onMounted(() => {
background: var(--c-red);
}
-.user-info {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.75rem;
- background: rgba(255, 255, 255, 0.05);
- border-radius: 6px;
- margin-bottom: 0.75rem;
-}
-
-.user-avatar {
- width: 32px;
- height: 32px;
- background: var(--accent);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.user-avatar i {
- font-size: 0.875rem;
- color: var(--sidebar-text-hover);
-}
-
-.user-details {
- display: flex;
- flex-direction: column;
-}
-
-.user-name {
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--sidebar-text-hover);
-}
-
-.user-role {
- font-size: 0.75rem;
- color: var(--sidebar-text);
- text-transform: capitalize;
-}
-
.agent-status {
display: flex;
align-items: center;
@@ -1225,28 +1271,6 @@ onMounted(() => {
justify-content: center;
}
-.logout-btn {
- width: 100%;
- padding: 0.5rem;
- background: rgba(239, 68, 68, 0.1);
- border: none;
- border-radius: var(--radius-sm);
- color: #f87171;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- font-size: 0.8125rem;
- margin-bottom: 0.5rem;
-}
-
-.logout-btn:hover {
- background: rgba(239, 68, 68, 0.2);
- color: #fca5a5;
-}
-
.collapse-btn {
width: 100%;
padding: 0.5rem;
diff --git a/src/router/index.ts b/src/router/index.ts
index ae95572..41fd184 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -216,6 +216,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/ClusterView.vue"),
meta: { permission: "cluster:read" },
},
+ {
+ path: "notifications",
+ name: "notifications",
+ component: () => import("@/views/NotificationsView.vue"),
+ meta: { permission: "settings:read" },
+ },
{
path: "security",
name: "security",
diff --git a/src/services/api.ts b/src/services/api.ts
index 74e3c5e..c1b2cc4 100755
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -525,12 +525,56 @@ export interface NotificationTarget {
name: string;
url: string;
enabled: boolean;
+ kind?: "email" | "webhook" | "custom";
+ topics?: string[];
+ severities?: NotificationSeverity[];
+ nodes?: string[];
+ deployments?: string[];
+}
+
+export type NotificationSeverity = "info" | "warning" | "critical";
+export type IncidentAction = "opened" | "updated" | "resolved";
+
+export interface NotificationRule {
+ id: string;
+ name: string;
+ enabled: boolean;
+ topics?: string[];
+ event_types?: string[];
+ severities?: NotificationSeverity[];
+ nodes?: string[];
+ deployments?: string[];
+ notifications?: IncidentAction[];
+ target_ids: string[];
+}
+
+export interface NotificationIncident {
+ id: string;
+ correlation_key: string;
+ status: "open" | "resolved";
+ severity: NotificationSeverity;
+ title: string;
+ event_count: number;
+ first_event_at: string;
+ last_event_at: string;
+ last_event: {
+ source: string;
+ type: string;
+ title: string;
+ message: string;
+ scope: { node?: string; deployment?: string; container?: string };
+ };
}
export const notificationsApi = {
getTargets: () => apiClient.get<{ targets: NotificationTarget[] }>("/notifications/targets"),
updateTargets: (targets: NotificationTarget[]) => apiClient.put("/notifications/targets", { targets }),
+ getRules: () => apiClient.get<{ rules: NotificationRule[] }>("/notifications/rules"),
+ updateRules: (rules: NotificationRule[]) =>
+ apiClient.put<{ rules: NotificationRule[] }>("/notifications/rules", { rules }),
+ getIncidents: () => apiClient.get<{ incidents: NotificationIncident[] }>("/notifications/incidents"),
test: (url: string) => apiClient.post("/notifications/test", { url }),
+ testTarget: (targetId: string) => apiClient.post("/notifications/test", { target_id: targetId }),
};
export const configApi = {
@@ -818,6 +862,7 @@ export interface NetworkInterface {
export interface ServerInfo {
hostname: string;
+ agent_url: string;
public_ipv4: string;
public_ipv6: string;
interfaces: NetworkInterface[];
@@ -1966,17 +2011,52 @@ export const powerDnsApi = {
export interface ClusterStatus {
enabled: boolean;
server_name?: string;
+ advertise_url?: string;
peer_count?: number;
version?: { version: string; build_time: string; git_commit: string };
}
+export interface ClusterProviderOption {
+ id: string;
+ active: boolean;
+ available: boolean;
+ reason?: string;
+}
+
+export interface ClusterProviders {
+ orchestrators: ClusterProviderOption[];
+ routing: ClusterProviderOption[];
+ k3s: { kubeconfig: string; namespace: string };
+}
+
export interface ClusterPeer {
- id: number;
name: string;
url: string;
- status: string;
- created_at: string;
- last_seen_at?: string;
+ online: boolean;
+ last_seen: string;
+ error?: string;
+}
+
+export type ClusterCapability =
+ | "fleet.read"
+ | "deployments.read"
+ | "deployments.run"
+ | "capacity.read"
+ | "capacity.offer"
+ | "events.publish"
+ | "routing.manage";
+
+export interface ClusterGrant {
+ capability: ClusterCapability;
+ deployments?: string[];
+ max_cpu?: number;
+ max_memory?: number;
+ max_replicas?: number;
+}
+
+export interface ClusterPeerPolicy {
+ peer: string;
+ grants: ClusterGrant[];
}
export interface ClusterInvite {
@@ -1990,17 +2070,107 @@ export interface ClusterAcceptResult {
status: string;
}
+export interface ClusterServerDeployments {
+ name: string;
+ online: boolean;
+ data?: { deployments: Deployment[] };
+ error?: string;
+}
+
+export interface ClusterDeployments {
+ servers: Record;
+}
+
+export interface AutoscalePolicy {
+ enabled: boolean;
+ min_replicas: number;
+ max_replicas: number;
+ scale_up_percent: number;
+ scale_down_percent: number;
+ scale_up_windows: number;
+ scale_down_windows: number;
+ cooldown_seconds: number;
+ allow_fleet_capacity: boolean;
+ state: {
+ high_windows: number;
+ low_windows: number;
+ last_action?: string;
+ active?: boolean;
+ provider?: "swarm" | "k3s";
+ service?: string;
+ replicas?: number;
+ };
+}
+
+export interface AutoscaleActivation {
+ workload: { workload: string; desired: number; available: number };
+ route: { id: string; service: string; domain: string; path?: string; protocol: string };
+}
+
+export interface AutoscaleCompatibility {
+ compatible: boolean;
+ service?: string;
+ image?: string;
+ services: string[];
+ blockers: string[];
+ warnings: string[];
+ workload?: AutoscaleWorkload;
+}
+
+export interface AutoscaleWorkload {
+ service: string;
+ stateless: boolean;
+ storage: { mode: "none" | "shared"; class: string };
+}
+
export const clusterApi = {
getStatus: () => apiClient.get("/cluster/status"),
+ getProviders: () => apiClient.get("/cluster/providers"),
+ updateProviders: (orchestrator: string, routing: string, k3s: ClusterProviders["k3s"]) =>
+ apiClient.put<{ orchestrator: string; routing: string; k3s: ClusterProviders["k3s"] }>("/cluster/providers", {
+ orchestrator,
+ routing,
+ k3s,
+ }),
+ setup: (serverName: string, advertiseUrl: string) =>
+ apiClient.post("/cluster/setup", { server_name: serverName, advertise_url: advertiseUrl }),
listPeers: () => apiClient.get<{ peers: ClusterPeer[] }>("/cluster/peers"),
+ getPeerPolicy: (name: string) =>
+ apiClient.get(`/cluster/peers/${encodeURIComponent(name)}/policy`),
+ updatePeerPolicy: (name: string, grants: ClusterGrant[]) =>
+ apiClient.put(`/cluster/peers/${encodeURIComponent(name)}/policy`, { grants }),
createInvite: () => apiClient.post("/cluster/invite"),
acceptInvite: (inviteToken: string, peerUrl: string) =>
apiClient.post("/cluster/accept", { invite_token: inviteToken, peer_url: peerUrl }),
removePeer: (name: string) => apiClient.delete<{ status: string; peer: string }>(`/cluster/peers/${name}`),
- getAggregatedDeployments: () => apiClient.get("/cluster/deployments"),
+ getAggregatedDeployments: () => apiClient.get("/cluster/deployments"),
+ deploymentAction: (server: string, name: string, action: "start" | "stop" | "restart") =>
+ apiClient.post(
+ `/cluster/peers/${encodeURIComponent(server)}/proxy/deployments/${encodeURIComponent(name)}/${action}`,
+ ),
+ deploymentLogs: (server: string, name: string) =>
+ apiClient.get<{ logs: string }>(
+ `/cluster/peers/${encodeURIComponent(server)}/proxy/deployments/${encodeURIComponent(name)}/logs`,
+ ),
getAggregatedStats: () => apiClient.get("/cluster/stats"),
};
+export const autoscaleApi = {
+ getPolicy: (deployment: string) =>
+ apiClient.get(`/deployments/${encodeURIComponent(deployment)}/autoscale`),
+ updatePolicy: (deployment: string, policy: Omit) =>
+ apiClient.put(`/deployments/${encodeURIComponent(deployment)}/autoscale`, policy),
+ getCompatibility: (deployment: string) =>
+ apiClient.get(`/deployments/${encodeURIComponent(deployment)}/autoscale/compatibility`),
+ updateWorkload: (deployment: string, workload: AutoscaleWorkload) =>
+ apiClient.put(
+ `/deployments/${encodeURIComponent(deployment)}/autoscale/workload`,
+ workload,
+ ),
+ activate: (deployment: string) =>
+ apiClient.post(`/deployments/${encodeURIComponent(deployment)}/autoscale/activate`),
+};
+
import type { User, APIKey, UserRole, UserDeploymentAccess, DeploymentAccessMap } from "@/types";
export const usersApi = {
diff --git a/src/utils/compose.test.ts b/src/utils/compose.test.ts
index 925d4c0..c916d0c 100644
--- a/src/utils/compose.test.ts
+++ b/src/utils/compose.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { extractComposeMounts, extractComposeServiceNames, toComposeRelativePath } from "./compose";
+import {
+ extractComposeMounts,
+ extractComposeServiceNames,
+ toComposeRelativePath,
+ updateComposeServiceImage,
+} from "./compose";
describe("toComposeRelativePath", () => {
it("converts deployment-root file paths into relative compose paths", () => {
@@ -92,3 +97,41 @@ describe("extractComposeMounts", () => {
expect(extractComposeMounts("not yaml")).toEqual([]);
});
});
+
+describe("updateComposeServiceImage", () => {
+ it("changes only the selected service image and preserves surrounding content", () => {
+ const compose = `services:
+ web:
+ # Keep this note.
+ image: nginx:1.25
+ worker:
+ image: app:old
+`;
+
+ const result = updateComposeServiceImage(compose, "web", "nginx:1.27");
+
+ expect(result.previousImage).toBe("nginx:1.25");
+ expect(result.content).toContain("# Keep this note.\n image: nginx:1.27");
+ expect(result.content).toContain("image: app:old");
+ });
+
+ it("adds an image to a build service", () => {
+ const compose = `services:
+ app:
+ build: .
+`;
+
+ expect(updateComposeServiceImage(compose, "app", "registry.example.com/app:2").content).toContain(
+ " app:\n image: registry.example.com/app:2\n build: .",
+ );
+ });
+
+ it("rejects unknown services and multiline image references", () => {
+ expect(() => updateComposeServiceImage("services:\n app:\n image: app:1\n", "worker", "app:2")).toThrow(
+ "Service worker was not found",
+ );
+ expect(() =>
+ updateComposeServiceImage("services:\n app:\n image: app:1\n", "app", "app:2\ncommand: bad"),
+ ).toThrow("Enter a valid image reference");
+ });
+});
diff --git a/src/utils/compose.ts b/src/utils/compose.ts
index e3fccbf..c9bc4fb 100644
--- a/src/utils/compose.ts
+++ b/src/utils/compose.ts
@@ -49,6 +49,67 @@ export interface ComposeMount {
selinux?: "z" | "Z";
}
+export interface ComposeServiceImageUpdate {
+ content: string;
+ previousImage: string;
+}
+
+export function updateComposeServiceImage(
+ content: string,
+ serviceName: string,
+ image: string,
+): ComposeServiceImageUpdate {
+ const nextImage = image.trim();
+ if (!nextImage || /[\r\n]/.test(nextImage)) throw new Error("Enter a valid image reference");
+
+ const lines = content.split(/\r?\n/);
+ let inServices = false;
+ let serviceIndent = -1;
+ let targetIndex = -1;
+ let targetIndent = -1;
+ let imageIndex = -1;
+ let previousImage = "";
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const raw = lines[index];
+ if (/^\s*#/.test(raw) || raw.trim() === "") continue;
+ const indent = raw.match(/^\s*/)?.[0].length ?? 0;
+
+ if (!inServices) {
+ if (/^services\s*:\s*$/.test(raw)) inServices = true;
+ continue;
+ }
+ if (indent === 0) break;
+ if (serviceIndent === -1) serviceIndent = indent;
+
+ if (indent === serviceIndent) {
+ const match = raw.match(/^\s*([A-Za-z0-9_.-]+)\s*:\s*$/);
+ if (match?.[1] === serviceName) {
+ targetIndex = index;
+ targetIndent = indent;
+ } else if (targetIndex !== -1) {
+ break;
+ }
+ continue;
+ }
+
+ if (targetIndex !== -1 && indent > targetIndent) {
+ const match = raw.match(/^(\s*)image\s*:\s*(.+?)\s*$/);
+ if (match) {
+ imageIndex = index;
+ previousImage = stripQuotes(match[2].trim());
+ lines[index] = `${match[1]}image: ${nextImage}`;
+ break;
+ }
+ }
+ }
+
+ if (targetIndex === -1) throw new Error(`Service ${serviceName} was not found in the Compose file`);
+ if (imageIndex === -1) lines.splice(targetIndex + 1, 0, `${" ".repeat(targetIndent + 2)}image: ${nextImage}`);
+
+ return { content: lines.join("\n"), previousImage };
+}
+
export function extractComposeMounts(content: string): ComposeMount[] {
if (!content) return [];
const lines = content.split(/\r?\n/);
diff --git a/src/views/AgentsView.vue b/src/views/AgentsView.vue
index 83e0a0c..6aa8950 100644
--- a/src/views/AgentsView.vue
+++ b/src/views/AgentsView.vue
@@ -1,22 +1,17 @@
-
+
+ Scheduled agents use only the permissions selected for unattended runs. Interactive changes still require
+ approval.
+
+
+ New agent
+
+
+ Refresh
+
+
+
@@ -190,6 +185,8 @@ import { useAssistStore } from "@/stores/assist";
import { useAuthStore } from "@/stores/auth";
import { useNotificationsStore } from "@/stores/notifications";
import Icon from "@/components/base/Icon.vue";
+import ContextBanner from "@/components/base/ContextBanner.vue";
+import BaseButton from "@/components/base/BaseButton.vue";
import ConfirmModal from "@/components/ConfirmModal.vue";
const agents = ref
([]);
@@ -412,19 +409,9 @@ onMounted(fetchAgents);
diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts
index 80196f7..69a6a9a 100644
--- a/src/views/DeploymentDetailView.test.ts
+++ b/src/views/DeploymentDetailView.test.ts
@@ -159,6 +159,7 @@ describe("DeploymentDetailView", () => {
teleport: true,
ContainerTerminal: true,
LogViewer: true,
+ DeploymentAutoscaleCard: { template: '
' },
},
},
});
@@ -204,6 +205,29 @@ describe("DeploymentDetailView", () => {
expect(wrapper.text()).toContain("Files");
});
+ it("keeps autoscaling under Configuration after Settings", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+
+ expect(wrapper.find(".autoscale-test-panel").exists()).toBe(false);
+ await wrapper
+ .findAll(".tab-btn")
+ .find((tab) => tab.text().includes("Configuration"))!
+ .trigger("click");
+
+ const subTabs = wrapper.findAll(".sub-tab");
+ expect(subTabs.map((tab) => tab.text().trim())).toEqual([
+ "Settings",
+ "Autoscaling",
+ "docker-compose.yml",
+ "service.yml",
+ ]);
+ expect(subTabs[0].classes()).toContain("active");
+ await subTabs[1].trigger("click");
+
+ expect(wrapper.find(".autoscale-test-panel").exists()).toBe(true);
+ });
+
it("has Logs tab", async () => {
const wrapper = mountView();
await flushPromises();
@@ -680,5 +704,14 @@ describe("DeploymentDetailView", () => {
expect(deploymentsApi.logs).toHaveBeenCalledWith("test-app", expect.objectContaining({ service: "web" }));
});
+
+ it("opens the image editor from the service actions in Overview", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+
+ await wrapper.find('button[title="Edit image"]').trigger("click");
+
+ expect(wrapper.find("#overview-service-image").exists()).toBe(true);
+ });
});
});
diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue
index 0b93b89..eaabb88 100755
--- a/src/views/DeploymentDetailView.vue
+++ b/src/views/DeploymentDetailView.vue
@@ -101,9 +101,8 @@
:status="deployment?.status"
@open="showDiagnostics = true"
/>
-
-
+
-
+
-
+
-
+
+
+
@@ -1935,11 +1980,12 @@ import { usePluginsStore } from "@/stores/plugins";
import DomainsManager from "@/components/DomainsManager.vue";
import DomainFormModal from "@/components/DomainFormModal.vue";
import ContainerResourcesModal from "@/components/ContainerResourcesModal.vue";
+import DeploymentAutoscaleCard from "@/components/DeploymentAutoscaleCard.vue";
import DeploymentDiagnosticsModal from "@/components/DeploymentDiagnosticsModal.vue";
import DeploymentHealthCheckModal from "@/components/DeploymentHealthCheckModal.vue";
import LogFilePicker from "@/components/LogFilePicker.vue";
import BaseModal from "@/components/base/BaseModal.vue";
-import { extractComposeMounts, extractComposeServiceNames } from "@/utils/compose";
+import { extractComposeMounts, extractComposeServiceNames, updateComposeServiceImage } from "@/utils/compose";
import { matchTypeHints, describeBlockedRule } from "@/utils/protectedMode";
import { usePlanFlow } from "@/composables/usePlanFlow";
import SplitActionButton from "@/components/base/SplitActionButton.vue";
@@ -2081,6 +2127,10 @@ const tabBarItems = computed(() => {
});
const services = ref
([]);
+const editingServiceImage = ref(null);
+const serviceImageReference = ref("");
+const savingServiceImage = ref(false);
+const serviceImageError = ref("");
const hasMultipleDomains = computed(() => {
return deployment.value?.metadata?.domains && deployment.value.metadata.domains.length > 1;
});
@@ -2182,12 +2232,13 @@ const isEditingConfig = ref(false);
const serviceConfig = ref("");
const isEditingServiceConfig = ref(false);
const configExtensions = [yaml(), oneDark];
-const activeConfigTab = ref<"compose" | "service" | "settings">("compose");
+const activeConfigTab = ref<"settings" | "autoscaling" | "compose" | "service">("settings");
const configSubTabs = computed(() => [
+ { id: "settings", label: "Settings", icon: "pi pi-sliders-h" },
+ { id: "autoscaling", label: "Autoscaling", icon: "pi pi-chart-line" },
{ id: "compose", label: composeFilename.value, icon: "pi pi-file" },
{ id: "service", label: "service.yml", icon: "pi pi-cog" },
- { id: "settings", label: "Settings", icon: "pi pi-sliders-h" },
]);
const showOperationModal = ref(false);
@@ -3434,6 +3485,46 @@ const saveConfig = async () => {
notifications.success("Saved", "Configuration saved successfully");
};
+const openServiceImageEditor = (service: any) => {
+ editingServiceImage.value = service;
+ serviceImageReference.value = service.image || "";
+ serviceImageError.value = "";
+};
+
+const closeServiceImageEditor = () => {
+ if (savingServiceImage.value) return;
+ editingServiceImage.value = null;
+ serviceImageError.value = "";
+};
+
+const saveServiceImage = async () => {
+ if (!editingServiceImage.value) return;
+ serviceImageError.value = "";
+ savingServiceImage.value = true;
+ try {
+ const updated = updateComposeServiceImage(
+ composeConfig.value,
+ editingServiceImage.value.name,
+ serviceImageReference.value,
+ ).content;
+ const result = await runGuarded(
+ () => deploymentsApi.update(route.params.name as string, { compose_content: updated }),
+ () => deploymentsApi.update(route.params.name as string, { compose_content: updated }, { plan: true }),
+ "Image update failed",
+ );
+ if (result === false) return;
+ composeConfig.value = updated;
+ originalConfig = updated;
+ notifications.success("Image updated", `${editingServiceImage.value.name} will use ${serviceImageReference.value}`);
+ editingServiceImage.value = null;
+ await fetchDeployment();
+ } catch (err: any) {
+ serviceImageError.value = err.response?.data?.error || err.message;
+ } finally {
+ savingServiceImage.value = false;
+ }
+};
+
const saveServiceConfig = async () => {
try {
const blob = new Blob([serviceConfig.value], { type: "text/yaml" });
@@ -3707,6 +3798,7 @@ onUnmounted(() => {
.detail-tabs {
display: flex;
gap: var(--space-1);
+ overflow-x: auto;
background: var(--surface-inset);
padding: var(--space-1);
border-radius: var(--radius-sm);
@@ -3724,6 +3816,7 @@ onUnmounted(() => {
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-base);
+ white-space: nowrap;
}
.tab-btn:hover {
@@ -3748,14 +3841,17 @@ onUnmounted(() => {
}
.overview-tab {
- padding: var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-4);
}
.info-cards {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
- margin-bottom: var(--space-4);
+ align-items: start;
}
.info-card {
@@ -4157,6 +4253,18 @@ onUnmounted(() => {
margin: var(--space-2) 0 0;
}
+.service-image-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.service-image-form label {
+ color: var(--text);
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+}
+
.service-status.running {
background: var(--color-success-50);
color: var(--color-success-700);
@@ -4537,6 +4645,11 @@ onUnmounted(() => {
padding: var(--space-4);
}
+.config-autoscaling {
+ border: 0;
+ box-shadow: none;
+}
+
.config-section {
display: flex;
flex-direction: column;
@@ -4711,7 +4824,17 @@ onUnmounted(() => {
}
.overview-summary {
- margin-bottom: var(--space-4);
+ margin: 0;
+}
+
+@media (max-width: 960px) {
+ .info-cards {
+ grid-template-columns: 1fr;
+ }
+
+ .info-card.wide {
+ grid-column: auto;
+ }
}
.files-tab {
diff --git a/src/views/DeploymentsView.test.ts b/src/views/DeploymentsView.test.ts
index 167b381..6b10530 100644
--- a/src/views/DeploymentsView.test.ts
+++ b/src/views/DeploymentsView.test.ts
@@ -5,6 +5,10 @@ import DeploymentsView from "./DeploymentsView.vue";
import { useAuthStore } from "@/stores/auth";
vi.mock("@/services/api", () => ({
+ clusterApi: {
+ getStatus: vi.fn().mockResolvedValue({ data: { enabled: false } }),
+ getAggregatedDeployments: vi.fn(),
+ },
deploymentsApi: {
list: vi.fn().mockResolvedValue({
data: {
@@ -81,15 +85,18 @@ vi.mock("@/services/api", () => ({
}));
const mockPush = vi.fn();
+const mockRoute = { query: {} as Record, name: "deployments" };
vi.mock("vue-router", () => ({
useRouter: () => ({
push: mockPush,
}),
+ useRoute: () => mockRoute,
}));
describe("DeploymentsView", () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockRoute.query = {};
});
const mountView = () => {
@@ -159,6 +166,68 @@ describe("DeploymentsView", () => {
await flushPromises();
expect(deploymentsApi.list).toHaveBeenCalled();
});
+
+ it("filters the aggregate inventory by the selected server", async () => {
+ const { clusterApi } = await import("@/services/api");
+ mockRoute.query = { server: "prod-2" };
+ vi.mocked(clusterApi.getStatus).mockResolvedValueOnce({
+ data: { enabled: true, server_name: "prod-1" },
+ } as any);
+ vi.mocked(clusterApi.getAggregatedDeployments).mockResolvedValueOnce({
+ data: {
+ servers: {
+ "prod-1": {
+ name: "prod-1",
+ online: true,
+ data: { deployments: [{ name: "local-app", status: "running", services: [] }] },
+ },
+ "prod-2": {
+ name: "prod-2",
+ online: true,
+ data: { deployments: [{ name: "remote-app", status: "running", services: [] }] },
+ },
+ },
+ },
+ } as any);
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("remote-app");
+ expect(wrapper.text()).not.toContain("local-app");
+ expect(wrapper.find(".server-context").exists()).toBe(false);
+ expect(wrapper.text()).not.toContain("New Deployment");
+ });
+
+ it("shows only local deployments when no peer is selected", async () => {
+ const { clusterApi } = await import("@/services/api");
+ vi.mocked(clusterApi.getStatus).mockResolvedValueOnce({
+ data: { enabled: true, server_name: "prod-1" },
+ } as any);
+ vi.mocked(clusterApi.getAggregatedDeployments).mockResolvedValueOnce({
+ data: {
+ servers: {
+ "prod-1": {
+ name: "prod-1",
+ online: true,
+ data: { deployments: [{ name: "local-app", status: "running", services: [] }] },
+ },
+ "prod-2": {
+ name: "prod-2",
+ online: true,
+ data: { deployments: [{ name: "remote-app", status: "running", services: [] }] },
+ },
+ },
+ },
+ } as any);
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("local-app");
+ expect(wrapper.text()).not.toContain("remote-app");
+ expect(wrapper.find(".server-context").exists()).toBe(false);
+ });
});
describe("Modal toggles", () => {
diff --git a/src/views/DeploymentsView.vue b/src/views/DeploymentsView.vue
index e3e33b0..3344cdb 100644
--- a/src/views/DeploymentsView.vue
+++ b/src/views/DeploymentsView.vue
@@ -1,23 +1,27 @@
-
+
New Deployment
@@ -32,8 +36,9 @@
-
+
{{ item.name }}
+
{{ item.server }}
{{ item.metadata.networking.domain }}
@@ -87,7 +92,7 @@
class="action-btn start"
title="Start"
:disabled="item.status === 'running'"
- @click.stop="handleOperation('start', item.name)"
+ @click.stop="handleOperation('start', item)"
>
@@ -96,7 +101,7 @@
class="action-btn stop"
title="Stop"
:disabled="item.status === 'stopped'"
- @click.stop="handleOperation('stop', item.name)"
+ @click.stop="handleOperation('stop', item)"
>
@@ -105,11 +110,11 @@
class="action-btn restart"
title="Restart"
:disabled="item.status === 'stopped'"
- @click.stop="handleOperation('restart', item.name)"
+ @click.stop="handleOperation('restart', item)"
>
-
+
@@ -119,14 +124,14 @@
@@ -246,7 +251,7 @@
class="icon-btn stop"
title="Stop"
:disabled="deployment.status === 'stopped'"
- @click="handleOperation('stop', deployment.name)"
+ @click="handleOperation('stop', deployment)"
>
@@ -255,14 +260,19 @@
class="icon-btn restart"
title="Restart"
:disabled="deployment.status === 'stopped'"
- @click="handleOperation('restart', deployment.name)"
+ @click="handleOperation('restart', deployment)"
>
-
+
-
+
@@ -297,16 +307,16 @@
diff --git a/src/views/ServerInfoView.vue b/src/views/ServerInfoView.vue
index afebb59..79300f0 100644
--- a/src/views/ServerInfoView.vue
+++ b/src/views/ServerInfoView.vue
@@ -29,6 +29,10 @@
Hostname
{{ serverInfo?.hostname || "—" }}
+
+ Agent URL
+ {{ serverInfo?.agent_url || "—" }}
+
Public IPv4
{{ serverInfo?.public_ipv4 || "—" }}
diff --git a/src/views/SettingsView.test.ts b/src/views/SettingsView.test.ts
index c809a4a..745ac71 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 ten tabs", () => {
+ it("displays all nine tabs", () => {
const wrapper = mountView();
const tabs = wrapper.findAll(".tab");
- expect(tabs.length).toBe(10);
+ expect(tabs.length).toBe(9);
});
it("has General tab", () => {
@@ -380,7 +380,6 @@ 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: "" },
{ id: "mcp", label: "MCP Server", icon: "" },
diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue
index 0ffeaf6..9fae014 100644
--- a/src/views/SettingsView.vue
+++ b/src/views/SettingsView.vue
@@ -771,10 +771,6 @@
-
-
-
-