diff --git a/src/components/AttachStoreModal.vue b/src/components/AttachStoreModal.vue new file mode 100644 index 0000000..9829a15 --- /dev/null +++ b/src/components/AttachStoreModal.vue @@ -0,0 +1,139 @@ + + + + Attached to {{ result.deployment }}. + These were written to its environment. Restart the deployment to apply. + + + {{ k }} + + + + + + + + Select a deployment + {{ d }} + + + + + + + + + Done + + Cancel + + Attach + + + + + + + + + diff --git a/src/components/DeployObjectStoreModal.vue b/src/components/DeployObjectStoreModal.vue new file mode 100644 index 0000000..deb9ae7 --- /dev/null +++ b/src/components/DeployObjectStoreModal.vue @@ -0,0 +1,406 @@ + + + + + Loading stores… + + + + + + + {{ t.name }} + {{ t.description }} + + + + + + + + Use an existing deployment + Connect any S3-compatible container you already run. + + + + + + + + + + + + + + + + Auto-register as a connected store + + Create the credential and backup destination so this store is ready for backups immediately. Turn off to + wire it up yourself later in Settings. + + + + + + + + + + Select a deployment + {{ d }} + + + + + + + + + + + + + + + + Use path-style addressing (required by MinIO and most self-hosted stores) + + + + + Back + Cancel + + Deploy store + + + Connect store + + + + + + + + diff --git a/src/components/NewDeploymentModal.vue b/src/components/NewDeploymentModal.vue index 7cd7a2c..05d0b5d 100644 --- a/src/components/NewDeploymentModal.vue +++ b/src/components/NewDeploymentModal.vue @@ -1647,6 +1647,9 @@ interface QuickApp { const props = defineProps<{ visible: boolean; + initialMode?: "easy" | "compose" | "image" | "git"; + initialCompose?: string; + initialName?: string; }>(); const emit = defineEmits(["close", "created"]); @@ -2639,10 +2642,31 @@ watch( loadExistingDeployments(); loadCredentials(); loadSourceCredentials(); + + applyInitialSelection(); } }, ); +// applyInitialSelection preseeds the wizard when a caller opens it aimed at a +// specific deploy mode (e.g. the Marketplace hands off a downloaded compose), +// skipping the mode picker and landing on the configuration step. +const applyInitialSelection = () => { + const mode = props.initialMode; + if (!mode) return; + + deploymentMode.value = mode; + if (props.initialName) { + form.name = props.initialName.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); + onNameChange(); + } + if (mode === "compose") { + selectedQuickApp.value = "custom"; + form.composeContent = props.initialCompose || getDefaultComposeContent(); + } + currentStep.value = 1; +}; + watch(deploymentMode, (newMode, oldMode) => { if (oldMode && newMode !== oldMode) { selectedQuickApp.value = ""; @@ -2897,7 +2921,7 @@ const handleCreate = async () => { }; await deploymentsApi.create(payload); - emit("created"); + emit("created", form.name); } catch (e: any) { const msg = e.response?.data?.error || e.message; notifications.error("Failed to create deployment", msg); diff --git a/src/components/ReplicateStoreModal.vue b/src/components/ReplicateStoreModal.vue new file mode 100644 index 0000000..d85d2ee --- /dev/null +++ b/src/components/ReplicateStoreModal.vue @@ -0,0 +1,142 @@ + + + + {{ result.message }} + + Copied + {{ result.copied }} + Skipped (unchanged) + {{ result.skipped }} + Failed + {{ result.failed }} + + + + + + + Select a target store + + {{ t.name }} ({{ t.kind === "managed" ? "managed" : "external" }}) + + + + No other store to replicate to. Connect one first. + + + + Done + + Cancel + + Replicate now + + + + + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index 5e910d4..fdee118 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -100,6 +100,18 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/ObjectStoresView.vue"), meta: { permission: "backups:read" }, }, + { + path: "storage/object-stores/:name", + name: "object-store-detail", + component: () => import("@/views/ObjectStoreDetailView.vue"), + meta: { permission: "backups:read" }, + }, + { + path: "storage/object-stores/:name/buckets/:bucket", + name: "object-store-bucket", + component: () => import("@/views/ObjectBucketView.vue"), + meta: { permission: "backups:read" }, + }, { path: "certificates", name: "certificates", diff --git a/src/services/api.ts b/src/services/api.ts index 90e7c44..8b65b90 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1121,6 +1121,96 @@ export const backupDestinationsApi = { apiClient.post<{ success: boolean; message?: string; error?: string }>("/backup-destinations/test", data), }; +export interface ObjectStoreContract { + access_key_env?: string; + secret_key_env?: string; + api_port: number; + region?: string; + use_path_style?: boolean; +} + +export interface StoreObject { + Key: string; + Size: number; + ModTime: string; +} + +export interface StoreBucket { + name: string; + objects: number; + size: number; + truncated: boolean; + is_backup: boolean; +} + +export const objectStoresApi = { + listBuckets: (name: string) => + apiClient.get<{ buckets: StoreBucket[]; backup_bucket: string }>( + `/object-stores/${encodeURIComponent(name)}/buckets`, + ), + createBucket: (name: string, bucket: string) => + apiClient.post<{ message: string; bucket: string }>(`/object-stores/${encodeURIComponent(name)}/buckets`, { + bucket, + }), + deleteBucket: (name: string, bucket: string) => + apiClient.delete(`/object-stores/${encodeURIComponent(name)}/buckets/${encodeURIComponent(bucket)}`), + listObjects: (name: string, bucket?: string, token?: string, prefix?: string) => + apiClient.get<{ objects: StoreObject[]; next_token: string }>( + `/object-stores/${encodeURIComponent(name)}/objects`, + { params: { ...(bucket ? { bucket } : {}), ...(token ? { token } : {}), ...(prefix ? { prefix } : {}) } }, + ), + uploadObject: (name: string, file: File, bucket?: string, key?: string) => { + const form = new FormData(); + form.append("file", file); + if (key) form.append("key", key); + return apiClient.post<{ message: string; key: string }>( + `/object-stores/${encodeURIComponent(name)}/objects`, + form, + { params: bucket ? { bucket } : undefined, headers: { "Content-Type": "multipart/form-data" } }, + ); + }, + downloadObject: (name: string, key: string, bucket?: string, inline?: boolean) => + apiClient.get(`/object-stores/${encodeURIComponent(name)}/objects/download`, { + params: { key, ...(bucket ? { bucket } : {}), ...(inline ? { inline: "true" } : {}) }, + responseType: "blob", + }), + deleteObject: (name: string, key: string, bucket?: string) => + apiClient.delete(`/object-stores/${encodeURIComponent(name)}/objects`, { + params: { key, ...(bucket ? { bucket } : {}) }, + }), + attach: (name: string, data: { deployment: string; prefix?: string }) => + apiClient.post<{ message: string; keys: string[]; endpoint: string; network: string }>( + `/object-stores/${encodeURIComponent(name)}/attach`, + data, + ), + replicate: (name: string, target: string) => + apiClient.post<{ + message: string; + target: string; + copied: number; + skipped: number; + failed: number; + bytes_copied: number; + total_objects: number; + }>(`/object-stores/${encodeURIComponent(name)}/replicate`, { target }), + provisionManaged: ( + data: { + deployment: string; + store_name?: string; + bucket?: string; + access_key?: string; + secret_key?: string; + } & ObjectStoreContract, + ) => + apiClient.post<{ + message: string; + destination: BackupDestination; + credential: StorageCredential; + applied: boolean; + apply_error?: string; + }>("/object-stores/provision-managed", data), +}; + export interface SecurityEventFilter { event_type?: string; severity?: string; diff --git a/src/views/MarketplaceView.vue b/src/views/MarketplaceView.vue index 784dbce..3436318 100644 --- a/src/views/MarketplaceView.vue +++ b/src/views/MarketplaceView.vue @@ -98,40 +98,23 @@ Installed - - - Install + + + + Deploy - - - - - - Deploy {{ installTarget.name }} - - - - - This downloads the template and creates a new deployment. Pick a name (it must be unique). - - Deployment name - - - - - - + @@ -139,8 +122,8 @@ import { ref, computed, onMounted } from "vue"; import { useRouter } from "vue-router"; import Icon from "@/components/base/Icon.vue"; +import NewDeploymentModal from "@/components/NewDeploymentModal.vue"; import { marketplaceApi, type MarketplaceTemplate, type MarketplaceCategory } from "@/services/marketplace"; -import { deploymentsApi } from "@/services/api"; import { useDeploymentsStore } from "@/stores/deployments"; import { useNotificationsStore } from "@/stores/notifications"; @@ -156,9 +139,10 @@ const searchQuery = ref(""); const selectedCategory = ref("all"); const brokenLogos = ref>(new Set()); -const installTarget = ref(null); +const showDeployModal = ref(false); +const installCompose = ref(""); const installName = ref(""); -const installing = ref(false); +const preparing = ref(null); async function load() { loading.value = true; @@ -213,35 +197,28 @@ function openDeployment(app: MarketplaceTemplate) { router.push(`/deployments/${app.slug}`); } -function openInstall(app: MarketplaceTemplate) { - installTarget.value = app; - installName.value = app.slug; -} - -function closeInstall() { - if (installing.value) return; - installTarget.value = null; - installName.value = ""; -} - -async function confirmInstall() { - if (!installTarget.value) return; - const app = installTarget.value; - const name = installName.value.trim(); - installing.value = true; +// The store is a discovery surface: deploying downloads the template's compose +// and hands it to the standard deployment flow so it gets the same name, domain, +// SSL, and database options as deploying from anywhere else. +async function openInstall(app: MarketplaceTemplate) { + preparing.value = app.slug; try { const payload = await marketplaceApi.download(app.slug); - await deploymentsApi.create({ name, compose_content: payload.data.content }); - await deploymentsStore.fetchDeployments(); - notifications.success("Deployed", `${app.name} was deployed as "${name}".`); - installTarget.value = null; - router.push(`/deployments/${name}`); + installCompose.value = payload.data.content; + installName.value = app.slug; + showDeployModal.value = true; } catch (e: any) { - notifications.error("Install failed", e?.response?.data?.error || e?.message || "Could not deploy the template."); + notifications.error("Could not load template", e?.response?.data?.error || e?.message || "Download failed."); } finally { - installing.value = false; + preparing.value = null; } } + +async function onDeployed(name: string) { + showDeployModal.value = false; + await deploymentsStore.fetchDeployments(); + router.push(`/deployments/${name}`); +} diff --git a/src/views/ObjectBucketView.vue b/src/views/ObjectBucketView.vue new file mode 100644 index 0000000..b51adb9 --- /dev/null +++ b/src/views/ObjectBucketView.vue @@ -0,0 +1,486 @@ + + + + Object Stores + + + {{ name }} + + + {{ bucket }} + + + + + {{ bucket }} + Backups (delete disabled) + + {{ objects.length }}{{ nextToken ? "+" : "" }} object{{ objects.length === 1 ? "" : "s" }} + Refresh + + + Upload + + + + + Loading… + + + + + This bucket is empty. + + + + + + + Key + Size + Modified + + + + + + + {{ o.Key }} + {{ o.Key }} + + {{ formatBytes(o.Size) }} + {{ formatTime(o.ModTime) }} + + + + + + + + + + + + + Load more + + + + + + + + + + {{ preview.text }} + + + + + + + + diff --git a/src/views/ObjectStoreDetailView.vue b/src/views/ObjectStoreDetailView.vue new file mode 100644 index 0000000..1b885ca --- /dev/null +++ b/src/views/ObjectStoreDetailView.vue @@ -0,0 +1,424 @@ + + + + + Object Stores + + + + + {{ store.name }} + {{ kind === "managed" ? "Managed" : "External" }} + + {{ store.enabled === false ? "Disabled" : "Active" }} + + + + Endpoint + {{ store.endpoint || "AWS default" }} + Region + {{ store.region || "—" }} + + + Use in app + Replicate + + + + + + + Buckets + + + + + Create + + Cancel + + + New bucket + + + + Loading… + + + + No buckets yet. + + + + + + Bucket + Objects + Size + + + + + + + + {{ b.name }} + backups + + {{ b.truncated ? `${b.objects.toLocaleString()}+` : b.objects.toLocaleString() }} + {{ formatBytes(b.size) }}{{ b.truncated ? "+" : "" }} + + + + + + + + + + + + + + + + Store not found. + + + + + + + + + + + diff --git a/src/views/ObjectStoresView.vue b/src/views/ObjectStoresView.vue index 62f70d5..2ae0b42 100644 --- a/src/views/ObjectStoresView.vue +++ b/src/views/ObjectStoresView.vue @@ -19,7 +19,7 @@ Connected stores - + Deploy a local store @@ -34,17 +34,20 @@ No object stores connected yet. - + Deploy a local store - + Connect external + + Or browse all templates + - + {{ d.name }} @@ -61,6 +64,7 @@ Endpoint {{ d.endpoint || "AWS default" }} + Open store @@ -69,6 +73,8 @@ + + @@ -79,6 +85,7 @@ import Icon from "@/components/base/Icon.vue"; import BaseCard from "@/components/base/BaseCard.vue"; import BaseButton from "@/components/base/BaseButton.vue"; import StorageBackupsSettings from "@/components/StorageBackupsSettings.vue"; +import DeployObjectStoreModal from "@/components/DeployObjectStoreModal.vue"; import { backupDestinationsApi, type BackupDestination } from "@/services/api"; import { useAuthStore } from "@/stores/auth"; @@ -86,14 +93,25 @@ const router = useRouter(); const auth = useAuthStore(); const canManage = auth.hasPermission("backups:write") || auth.hasPermission("config:write"); +const showDeployModal = ref(false); + +function openBrowser(d: BackupDestination) { + router.push(`/storage/object-stores/${encodeURIComponent(d.name)}`); +} + function storeKind(d: BackupDestination): string { return d.kind || "external"; } -function deployStore() { +function browseTemplates() { router.push("/templates"); } +function onStoreDeployed() { + tab.value = "overview"; + load(); +} + const tabs = [ { id: "overview", label: "Overview", icon: "layout-grid" }, { id: "settings", label: "Settings", icon: "settings" }, @@ -209,6 +227,30 @@ onMounted(load); gap: var(--space-3); } +.store-card { + cursor: pointer; + transition: border-color 0.12s; +} + +.store-card:hover { + border-color: var(--accent); +} + +.store-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--space-3); +} + +.store-open { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: var(--text-xs); + color: var(--accent); +} + .store-top { display: flex; align-items: center; @@ -242,6 +284,21 @@ onMounted(load); gap: var(--space-2); } +.browse-link { + margin-top: var(--space-2); + background: none; + border: none; + padding: 0; + color: var(--text-muted); + font-size: var(--text-sm); + text-decoration: underline; + cursor: pointer; +} + +.browse-link:hover { + color: var(--text); +} + .store-state { margin-left: auto; font-size: var(--text-xs); diff --git a/src/views/TemplatesView.vue b/src/views/TemplatesView.vue index 28fa2e1..0742558 100644 --- a/src/views/TemplatesView.vue +++ b/src/views/TemplatesView.vue @@ -135,7 +135,7 @@
Attached to {{ result.deployment }}.
These were written to its environment. Restart the deployment to apply.
{{ k }}
{{ result.message }}
No other store to replicate to. Connect one first.
- This downloads the template and creates a new deployment. Pick a name (it must be unique). -
This bucket is empty.
{{ preview.text }}
No buckets yet.
Store not found.
No object stores connected yet.