diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26dc2ed..c9e64b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
- Deployment autoscaling configuration with workload compatibility and activation guidance
- Grouped notification incidents with editable targets and delivery rules
- HTTP, TCP, and container command health checks for web services and databases
+- Full deployment details and management controls for permitted Fleet peers
+- Service-specific HTTP, TCP, and command health check editing
### Changed
- Deployments show the selected server in the navigation and remain local by default
@@ -16,9 +18,13 @@
- Assigned local deployments load without Fleet access or Fleet availability
- Peer deployment lists reload when switching from an empty local server
- Peer deployment details open for users with read access
+- Peer deployment details use the same overview, configuration, files, and actions as local deployments
- Object storage and notifications follow their dedicated permissions
- Operators no longer see Updates unless access is explicitly granted
- Read-only Settings access is clearly identified and its controls remain legible in dark mode
+- User deployment grants can target a deployment on a specific Fleet server
+- Host terminal and process controls are hidden unless system write access is explicitly granted
+- Global certificate actions remain administrator-only while deployment certificates stay manageable
## [0.4.0-beta.4] - 2026-08-21
diff --git a/src/components/BackupsTab.test.ts b/src/components/BackupsTab.test.ts
index 9dd2f09..c7fb178 100644
--- a/src/components/BackupsTab.test.ts
+++ b/src/components/BackupsTab.test.ts
@@ -10,7 +10,7 @@ vi.mock("@/services/api", () => ({
createDeploymentBackup: vi.fn().mockResolvedValue({ data: { job_id: "job-123" } }),
delete: vi.fn().mockResolvedValue({ data: { success: true } }),
restore: vi.fn().mockResolvedValue({ data: { job_id: "restore-job-123" } }),
- download: vi.fn().mockReturnValue("/api/backups/test-backup/download"),
+ download: vi.fn().mockResolvedValue({ data: new Blob(["backup"]) }),
getJob: vi.fn().mockResolvedValue({
data: { job: { id: "job-123", status: "completed", type: "backup" } },
}),
@@ -69,6 +69,11 @@ describe("BackupsTab", () => {
vi.clearAllMocks();
mockGetDeploymentBackups.mockResolvedValue({ data: { backups: [] } });
mockListTasks.mockResolvedValue({ data: { tasks: [] } });
+ vi.stubGlobal("URL", {
+ ...URL,
+ createObjectURL: vi.fn().mockReturnValue("blob:backup"),
+ revokeObjectURL: vi.fn(),
+ });
});
const mountBackupsTab = (options: { backups?: typeof mockBackups; tasks?: typeof mockScheduledTasks } = {}) => {
@@ -225,14 +230,16 @@ describe("BackupsTab", () => {
expect(restoreButtons.length).toBe(2);
});
- it("has Download link for each backup", async () => {
+ it("has a Download button for each backup", async () => {
const wrapper = mountBackupsTab({ backups: mockBackups });
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 10));
await wrapper.vm.$nextTick();
- const downloadLinks = wrapper.findAll(".backup-actions a[download]");
- expect(downloadLinks.length).toBe(2);
+ const downloadButtons = wrapper
+ .findAll(".backup-actions button")
+ .filter((button) => button.text().includes("Download"));
+ expect(downloadButtons.length).toBe(2);
});
it("has Delete button for each backup", async () => {
@@ -295,7 +302,7 @@ describe("BackupsTab", () => {
await vm.deleteBackup();
- expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000");
+ expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000", "my-app");
});
});
@@ -328,11 +335,15 @@ describe("BackupsTab", () => {
await vm.restoreBackup();
- expect(mockRestoreBackup).toHaveBeenCalledWith("my-app_20250101_120000", {
- restore_data: true,
- restore_db: true,
- stop_first: true,
- });
+ expect(mockRestoreBackup).toHaveBeenCalledWith(
+ "my-app_20250101_120000",
+ {
+ restore_data: true,
+ restore_db: true,
+ stop_first: true,
+ },
+ "my-app",
+ );
});
});
@@ -456,12 +467,16 @@ describe("BackupsTab", () => {
expect(result).toBeTruthy();
});
- it("getDownloadUrl returns correct URL", () => {
- const wrapper = mountBackupsTab();
- const vm = wrapper.vm as any;
+ it("downloads through the authenticated API client", async () => {
+ const wrapper = mountBackupsTab({ backups: mockBackups });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+
+ const download = wrapper.findAll("button").find((button) => button.text().includes("Download"));
+ await download!.trigger("click");
- vm.getDownloadUrl("test-backup-id");
- expect(backupsApi.download).toHaveBeenCalledWith("test-backup-id");
+ expect(click).toHaveBeenCalled();
+ expect(backupsApi.download).toHaveBeenCalledWith("my-app_20250101_120000", "my-app");
});
});
diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue
index a37bfd3..2b5e25e 100644
--- a/src/components/BackupsTab.vue
+++ b/src/components/BackupsTab.vue
@@ -2,12 +2,14 @@
-
@@ -953,18 +988,40 @@
@@ -1952,7 +2009,6 @@ import { yaml } from "@codemirror/lang-yaml";
import { oneDark } from "@codemirror/theme-one-dark";
import {
deploymentsApi,
- clusterApi,
proxyApi,
certificatesApi,
filesApi,
@@ -2004,7 +2060,6 @@ import InlineAssist from "@/components/ai/InlineAssist.vue";
import { useAssistStore } from "@/stores/assist";
import Icon from "@/components/base/Icon.vue";
import OperationModal from "@/components/OperationModal.vue";
-import RemoteDeploymentOverview from "@/components/RemoteDeploymentOverview.vue";
import { useDeploymentJob, type DeploymentOperation } from "@/composables/useDeploymentJob";
import { useServiceJobs } from "@/composables/useServiceJobs";
@@ -2029,10 +2084,34 @@ const closeConfigAssist = () => {
configAssistOpen.value = false;
assistStore.close();
};
-const canWrite = authStore.hasPermission("deployments:write");
-const canDelete = authStore.hasPermission("deployments:delete");
const remoteServer = computed(() => String(route.query.server || ""));
const isRemote = computed(() => remoteServer.value !== "");
+const deploymentAccessKey = computed(() => {
+ const name = String(route.params.name || "");
+ return isRemote.value ? `${remoteServer.value}/${name}` : name;
+});
+const canRead = computed(
+ () =>
+ Boolean(authStore.hasPermission("deployments:read")) &&
+ Boolean(authStore.canAccessDeployment(deploymentAccessKey.value, "read")),
+);
+const canWrite = computed(
+ () =>
+ Boolean(authStore.hasPermission("deployments:write")) &&
+ Boolean(authStore.canAccessDeployment(deploymentAccessKey.value, "write")),
+);
+const canDelete = computed(
+ () =>
+ Boolean(authStore.hasPermission("deployments:delete")) &&
+ Boolean(authStore.canAccessDeployment(deploymentAccessKey.value, "admin")),
+);
+const canReadBackups = computed(() => canRead.value && authStore.hasPermission("backups:read"));
+const canManageBackups = computed(() => canWrite.value && authStore.hasPermission("backups:write"));
+const canDeleteBackups = computed(() => canDelete.value && authStore.hasPermission("backups:delete"));
+const canManageSchedules = computed(() => canWrite.value && authStore.hasPermission("scheduler:write"));
+const canDeleteSchedules = computed(() => canDelete.value && authStore.hasPermission("scheduler:delete"));
+const canReadSecurity = computed(() => canRead.value && authStore.hasPermission("security:read"));
+const canManageSecurity = computed(() => canWrite.value && authStore.hasPermission("security:write"));
const backPath = computed(() => {
if (isRemote.value) return { path: "/deployments", query: { server: remoteServer.value } };
@@ -2120,7 +2199,7 @@ const tabs = [
const pluginsStore = usePluginsStore();
const pluginTabs = computed(() =>
- (pluginsStore.getPluginsForSlot("deployment.detail") || []).map((e) => ({
+ (isRemote.value ? [] : pluginsStore.getPluginsForSlot("deployment.detail") || []).map((e) => ({
id: `plugin:${e.plugin.name}`,
label: e.extension.title || e.plugin.display_name,
icon: e.extension.icon,
@@ -2131,6 +2210,9 @@ const pluginTabs = computed(() =>
const tabBarItems = computed(() => {
const items: Array<{ id: string; label: string; icon?: string; kind: "native" | "plugin" }> = [];
for (const tab of tabs) {
+ if (isRemote.value && tab.id === "terminal") continue;
+ if (tab.id === "backups" && !canReadBackups.value) continue;
+ if (tab.id === "security" && !canReadSecurity.value) continue;
items.push({ ...tab, kind: "native" });
if (tab.id === "actions") {
for (const pt of pluginTabs.value) items.push({ ...pt, kind: "plugin" });
@@ -2522,9 +2604,7 @@ const fetchDeployment = async () => {
loading.value = true;
error.value = "";
try {
- const response = isRemote.value
- ? await clusterApi.getDeployment(remoteServer.value, route.params.name as string)
- : await deploymentsApi.get(route.params.name as string);
+ const response = await deploymentsApi.get(route.params.name as string);
const data = response.data as any;
deployment.value = data.deployment || data;
syncProtectedModeFromDeployment();
@@ -2542,9 +2622,9 @@ const fetchDeployment = async () => {
services.value = deployment.value?.services || [];
- if (isRemote.value) return;
-
- if (deployment.value?.metadata?.credential_id) {
+ if (isRemote.value) {
+ registryCredential.value = null;
+ } else if (deployment.value?.metadata?.credential_id) {
try {
const credResponse = await credentialsApi.get(deployment.value.metadata.credential_id);
registryCredential.value = credResponse.data.credential;
@@ -2662,7 +2742,11 @@ const handleRequestCertificate = async () => {
requestingCert.value = true;
try {
- await certificatesApi.request(proxyStatus.value.domain);
+ if (isRemote.value) {
+ await certificatesApi.renewDeployment(route.params.name as string);
+ } else {
+ await certificatesApi.request(proxyStatus.value.domain);
+ }
notifications.success(
"Certificate Requested",
`SSL certificate for ${proxyStatus.value.domain} has been requested`,
@@ -3583,24 +3667,25 @@ watch(activeTab, (newTab) => {
onMounted(() => {
fetchDeployment();
- if (isRemote.value) return;
if (activeTab.value === "logs") fetchLogSources();
- 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()
- .then((response) => {
- allCredentials.value = response.data.credentials || [];
- })
- .catch(() => {
- allCredentials.value = [];
+ if (!isRemote.value) {
+ Promise.resolve(pluginsStore.fetchPlugins()).then(() => {
+ if (activeTab.value.startsWith("plugin:") && !pluginTabs.value.some((t) => t.id === activeTab.value)) {
+ activeTab.value = "overview";
+ }
});
+ }
+ deploymentJob.resume(route.params.name as string);
+ if (!isRemote.value) {
+ credentialsApi
+ .list()
+ .then((response) => {
+ allCredentials.value = response.data.credentials || [];
+ })
+ .catch(() => {
+ allCredentials.value = [];
+ });
+ }
refreshInterval = window.setInterval(() => {
if (logsFollow.value && activeTab.value === "logs") {
fetchLogs();
diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue
index 6443d92..1ffd3bd 100644
--- a/src/views/UsersView.vue
+++ b/src/views/UsersView.vue
@@ -175,7 +175,7 @@ import { ref, onMounted, computed, watch } from "vue";
import type { User, UserRole, UserDeploymentAccess, Permission, DeploymentAccessMap } from "@/types";
import { useUsersStore } from "@/stores/users";
import { useAuthStore } from "@/stores/auth";
-import { deploymentsApi } from "@/services/api";
+import { clusterApi, deploymentsApi } from "@/services/api";
import PermissionPicker from "@/components/PermissionPicker.vue";
import TabbedFormModal, { type TabbedFormModalTab } from "@/components/TabbedFormModal.vue";
import DeploymentAccessField from "@/components/DeploymentAccessField.vue";
@@ -246,8 +246,21 @@ const loadUsers = async () => {
const loadAllDeployments = async () => {
try {
- const response = await deploymentsApi.list();
- allDeployments.value = response.data.deployments.map((d) => d.name);
+ const [local, fleet, status] = await Promise.all([
+ deploymentsApi.list(),
+ clusterApi.getAggregatedDeployments().catch(() => null),
+ clusterApi.getStatus().catch(() => null),
+ ]);
+ const available = new Set(local.data.deployments.map((deployment) => deployment.name));
+ if (fleet) {
+ for (const [server, result] of Object.entries(fleet.data.servers)) {
+ if (server === status?.data.server_name) continue;
+ for (const deployment of result.data?.deployments || []) {
+ available.add(`${server}/${deployment.name}`);
+ }
+ }
+ }
+ allDeployments.value = [...available].sort((a, b) => a.localeCompare(b));
} catch {
// ignore
}