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 @@ + + + + + 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 @@