From f524a767acabd19db70af1c43b67866adf0e38cf Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 2 Aug 2026 02:35:26 +0100 Subject: [PATCH 1/9] feat(object-stores): Deploy and connect object stores from the UI The Object Stores page can now deploy a self-hosted store or connect an existing deployment as one, and shows each connected store. Deploying an object store picks from the available store templates and optionally auto-registers the result. Marketplace installs now open the full deployment flow so an app gets the same name, domain, SSL, and database options as deploying from anywhere else. --- src/components/DeployObjectStoreModal.vue | 406 ++++++++++++++++++++++ src/components/NewDeploymentModal.vue | 26 +- src/services/api.ts | 27 ++ src/views/MarketplaceView.vue | 172 ++------- src/views/ObjectStoresView.vue | 36 +- src/views/TemplatesView.vue | 3 +- 6 files changed, 524 insertions(+), 146 deletions(-) create mode 100644 src/components/DeployObjectStoreModal.vue 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 @@ + + + + + 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/services/api.ts b/src/services/api.ts index 90e7c44..693607c 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1121,6 +1121,33 @@ 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 const objectStoresApi = { + 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 - - - - - + @@ -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/ObjectStoresView.vue b/src/views/ObjectStoresView.vue index 62f70d5..fed8275 100644 --- a/src/views/ObjectStoresView.vue +++ b/src/views/ObjectStoresView.vue @@ -19,7 +19,7 @@

Connected stores

- + Deploy a local store @@ -34,13 +34,16 @@

No object stores connected yet.

- + Deploy a local store - + Connect external
+
@@ -69,6 +72,8 @@
+ +
@@ -79,6 +84,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 +92,21 @@ const router = useRouter(); const auth = useAuthStore(); const canManage = auth.hasPermission("backups:write") || auth.hasPermission("config:write"); +const showDeployModal = ref(false); + 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" }, @@ -242,6 +255,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 @@ + + diff --git a/src/services/api.ts b/src/services/api.ts index 693607c..cd8bfa7 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1129,7 +1129,33 @@ export interface ObjectStoreContract { use_path_style?: boolean; } +export interface StoreObject { + Key: string; + Size: number; + ModTime: string; +} + export const objectStoresApi = { + listObjects: (name: string, prefix?: string) => + apiClient.get<{ objects: StoreObject[] }>(`/object-stores/${encodeURIComponent(name)}/objects`, { + params: prefix ? { prefix } : undefined, + }), + uploadObject: (name: string, file: File, 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, + ); + }, + downloadObject: (name: string, key: string) => + apiClient.get(`/object-stores/${encodeURIComponent(name)}/objects/download`, { + params: { key }, + responseType: "blob", + }), + deleteObject: (name: string, key: string) => + apiClient.delete(`/object-stores/${encodeURIComponent(name)}/objects`, { params: { key } }), provisionManaged: ( data: { deployment: string; diff --git a/src/views/ObjectStoresView.vue b/src/views/ObjectStoresView.vue index fed8275..8b0c2ba 100644 --- a/src/views/ObjectStoresView.vue +++ b/src/views/ObjectStoresView.vue @@ -47,7 +47,7 @@
- +
{{ d.name }} @@ -64,6 +64,7 @@
Endpoint
{{ d.endpoint || "AWS default" }}
+
Browse objects
@@ -74,6 +75,7 @@
+ @@ -85,6 +87,7 @@ 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 ObjectBrowserModal from "@/components/ObjectBrowserModal.vue"; import { backupDestinationsApi, type BackupDestination } from "@/services/api"; import { useAuthStore } from "@/stores/auth"; @@ -93,6 +96,13 @@ const auth = useAuthStore(); const canManage = auth.hasPermission("backups:write") || auth.hasPermission("config:write"); const showDeployModal = ref(false); +const showBrowser = ref(false); +const selectedStore = ref(null); + +function openBrowser(d: BackupDestination) { + selectedStore.value = d; + showBrowser.value = true; +} function storeKind(d: BackupDestination): string { return d.kind || "external"; @@ -222,6 +232,24 @@ onMounted(load); gap: var(--space-3); } +.store-card { + cursor: pointer; + transition: border-color 0.12s; +} + +.store-card:hover { + border-color: var(--accent); +} + +.store-open { + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: var(--space-3); + font-size: var(--text-xs); + color: var(--accent); +} + .store-top { display: flex; align-items: center; From 40a4cfbd8ec6cb4171af5391e8b4eac06ca626bd Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 2 Aug 2026 02:49:58 +0100 Subject: [PATCH 3/9] ui(object-stores): Add "use in app" to wire a store into a deployment Each connected store gains a "use in app" action that picks a deployment and a variable prefix, then injects the store's connection details into it and reports the variables written. --- src/components/AttachStoreModal.vue | 139 ++++++++++++++++++++++++++++ src/services/api.ts | 5 + src/views/ObjectStoresView.vue | 41 +++++++- 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 src/components/AttachStoreModal.vue 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 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index cd8bfa7..5883da9 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1156,6 +1156,11 @@ export const objectStoresApi = { }), deleteObject: (name: string, key: string) => apiClient.delete(`/object-stores/${encodeURIComponent(name)}/objects`, { params: { key } }), + attach: (name: string, data: { deployment: string; prefix?: string }) => + apiClient.post<{ message: string; keys: string[]; endpoint: string; network: string }>( + `/object-stores/${encodeURIComponent(name)}/attach`, + data, + ), provisionManaged: ( data: { deployment: string; diff --git a/src/views/ObjectStoresView.vue b/src/views/ObjectStoresView.vue index 8b0c2ba..aed5a9f 100644 --- a/src/views/ObjectStoresView.vue +++ b/src/views/ObjectStoresView.vue @@ -64,7 +64,12 @@
Endpoint
{{ d.endpoint || "AWS default" }}
-
Browse objects
+
+ Browse objects + +
@@ -76,6 +81,7 @@ + @@ -88,6 +94,7 @@ import BaseButton from "@/components/base/BaseButton.vue"; import StorageBackupsSettings from "@/components/StorageBackupsSettings.vue"; import DeployObjectStoreModal from "@/components/DeployObjectStoreModal.vue"; import ObjectBrowserModal from "@/components/ObjectBrowserModal.vue"; +import AttachStoreModal from "@/components/AttachStoreModal.vue"; import { backupDestinationsApi, type BackupDestination } from "@/services/api"; import { useAuthStore } from "@/stores/auth"; @@ -97,6 +104,7 @@ const canManage = auth.hasPermission("backups:write") || auth.hasPermission("con const showDeployModal = ref(false); const showBrowser = ref(false); +const showAttach = ref(false); const selectedStore = ref(null); function openBrowser(d: BackupDestination) { @@ -104,6 +112,11 @@ function openBrowser(d: BackupDestination) { showBrowser.value = true; } +function openAttach(d: BackupDestination) { + selectedStore.value = d; + showAttach.value = true; +} + function storeKind(d: BackupDestination): string { return d.kind || "external"; } @@ -241,15 +254,39 @@ onMounted(load); 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; - margin-top: var(--space-3); font-size: var(--text-xs); color: var(--accent); } +.store-use { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: none; + border: none; + padding: 0.1rem 0.2rem; + font-size: var(--text-xs); + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-sm); +} + +.store-use:hover { + color: var(--text); + background: var(--surface-inset); +} + .store-top { display: flex; align-items: center; From 48e7be317c5a3c97ad4683b58d883515c83dd109 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 2 Aug 2026 02:57:06 +0100 Subject: [PATCH 4/9] ui(object-stores): Add store-to-store replication Each connected store gains a replicate action that picks a target store, runs the copy, and reports how many objects were copied, skipped, and failed. --- src/components/ReplicateStoreModal.vue | 142 +++++++++++++++++++++++++ src/services/api.ts | 10 ++ src/views/ObjectStoresView.vue | 24 ++++- 3 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/components/ReplicateStoreModal.vue 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 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index 5883da9..095863e 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1161,6 +1161,16 @@ export const objectStoresApi = { `/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; diff --git a/src/views/ObjectStoresView.vue b/src/views/ObjectStoresView.vue index aed5a9f..f0cdc70 100644 --- a/src/views/ObjectStoresView.vue +++ b/src/views/ObjectStoresView.vue @@ -66,9 +66,14 @@
Browse objects - + + + +
@@ -82,6 +87,7 @@ + @@ -95,6 +101,7 @@ import StorageBackupsSettings from "@/components/StorageBackupsSettings.vue"; import DeployObjectStoreModal from "@/components/DeployObjectStoreModal.vue"; import ObjectBrowserModal from "@/components/ObjectBrowserModal.vue"; import AttachStoreModal from "@/components/AttachStoreModal.vue"; +import ReplicateStoreModal from "@/components/ReplicateStoreModal.vue"; import { backupDestinationsApi, type BackupDestination } from "@/services/api"; import { useAuthStore } from "@/stores/auth"; @@ -105,6 +112,7 @@ const canManage = auth.hasPermission("backups:write") || auth.hasPermission("con const showDeployModal = ref(false); const showBrowser = ref(false); const showAttach = ref(false); +const showReplicate = ref(false); const selectedStore = ref(null); function openBrowser(d: BackupDestination) { @@ -117,6 +125,11 @@ function openAttach(d: BackupDestination) { showAttach.value = true; } +function openReplicate(d: BackupDestination) { + selectedStore.value = d; + showReplicate.value = true; +} + function storeKind(d: BackupDestination): string { return d.kind || "external"; } @@ -269,6 +282,11 @@ onMounted(load); color: var(--accent); } +.store-action-group { + display: inline-flex; + gap: 0.15rem; +} + .store-use { display: inline-flex; align-items: center; From 0fa995f5a28607098be308b2c1b97d53b68a3b6e Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 2 Aug 2026 12:19:36 +0100 Subject: [PATCH 5/9] ui(object-stores): Make the object browser a dedicated store page Opening a store now navigates to its own page showing the store's details, its objects, and the use-in-app and replicate actions, instead of a cramped modal. --- src/router/index.ts | 6 + .../ObjectStoreDetailView.vue} | 210 ++++++++++++++---- src/views/ObjectStoresView.vue | 58 +---- 3 files changed, 180 insertions(+), 94 deletions(-) rename src/{components/ObjectBrowserModal.vue => views/ObjectStoreDetailView.vue} (55%) diff --git a/src/router/index.ts b/src/router/index.ts index 5e910d4..a4daede 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -100,6 +100,12 @@ 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: "certificates", name: "certificates", diff --git a/src/components/ObjectBrowserModal.vue b/src/views/ObjectStoreDetailView.vue similarity index 55% rename from src/components/ObjectBrowserModal.vue rename to src/views/ObjectStoreDetailView.vue index ffec192..2ceab4d 100644 --- a/src/components/ObjectBrowserModal.vue +++ b/src/views/ObjectStoreDetailView.vue @@ -1,26 +1,49 @@ diff --git a/src/views/ObjectStoreDetailView.vue b/src/views/ObjectStoreDetailView.vue index 8db93c7..1b885ca 100644 --- a/src/views/ObjectStoreDetailView.vue +++ b/src/views/ObjectStoreDetailView.vue @@ -27,77 +27,60 @@ -
- Buckets - +
+

Buckets

+ - -
- -
-

{{ selectedBucket }}

- Backups (delete disabled) - - {{ objects.length }} object{{ objects.length === 1 ? "" : "s" }} - Refresh - - - Upload + + New bucket
Loading…
-
+
-

This bucket is empty.

+

No buckets yet.

- +
- + + - - - + - - + +
KeyBucketObjects SizeModified
- - {{ o.Key }} +
+ + {{ b.name }} + backups {{ formatBytes(o.Size) }}{{ formatTime(o.ModTime) }}{{ b.truncated ? `${b.objects.toLocaleString()}+` : b.objects.toLocaleString() }}{{ formatBytes(b.size) }}{{ b.truncated ? "+" : "" }} - @@ -116,21 +99,14 @@ - - -
- -
{{ preview.text }}
-
-
@@ -141,11 +117,10 @@ import Icon from "@/components/base/Icon.vue"; import BaseCard from "@/components/base/BaseCard.vue"; import BaseButton from "@/components/base/BaseButton.vue"; import BaseInput from "@/components/base/BaseInput.vue"; -import BaseModal from "@/components/base/BaseModal.vue"; import AttachStoreModal from "@/components/AttachStoreModal.vue"; import ReplicateStoreModal from "@/components/ReplicateStoreModal.vue"; import ConfirmModal from "@/components/ConfirmModal.vue"; -import { backupDestinationsApi, objectStoresApi, type BackupDestination, type StoreObject } from "@/services/api"; +import { backupDestinationsApi, objectStoresApi, type BackupDestination, type StoreBucket } from "@/services/api"; import { useNotificationsStore } from "@/stores/notifications"; import { useAuthStore } from "@/stores/auth"; @@ -154,34 +129,23 @@ const router = useRouter(); const notifications = useNotificationsStore(); const auth = useAuthStore(); const canWrite = auth.hasPermission("backups:write"); +const canDelete = auth.hasPermission("backups:delete"); const canManage = auth.hasPermission("backups:write") || auth.hasPermission("config:write"); const name = route.params.name as string; const store = ref(null); const resolving = ref(true); -const objects = ref([]); +const buckets = ref([]); const loading = ref(false); -const uploading = ref(false); -const busyKey = ref(null); -const fileInput = ref(null); const showAttach = ref(false); const showReplicate = ref(false); -const pendingDelete = ref(null); - -const buckets = ref([]); -const backupBucket = ref(""); -const selectedBucket = ref(""); const showNewBucket = ref(false); const newBucketName = ref(""); -const creatingBucket = ref(false); - -const preview = ref<{ key: string; kind: "image" | "text"; url?: string; text?: string } | null>(null); +const creating = ref(false); +const pendingDelete = ref(null); +const deleting = ref(false); const kind = computed(() => store.value?.kind || "external"); -const isProtected = computed(() => selectedBucket.value === backupBucket.value); - -const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"]; -const TEXT_EXT = ["txt", "json", "yaml", "yml", "md", "log", "csv", "xml", "html", "css", "js", "ts", "env", "sh", "conf"]; onMounted(async () => { try { @@ -192,163 +156,67 @@ onMounted(async () => { } finally { resolving.value = false; } - if (store.value) await loadBuckets(); + if (store.value) load(); }); -async function loadBuckets() { +async function load() { + loading.value = true; try { const res = await objectStoresApi.listBuckets(name); - buckets.value = res.data.buckets || []; - backupBucket.value = res.data.backup_bucket || ""; - selectedBucket.value = buckets.value.includes(backupBucket.value) - ? backupBucket.value - : buckets.value[0] || backupBucket.value; + buckets.value = (res.data.buckets || []).sort((a, b) => a.name.localeCompare(b.name)); } catch (e: any) { notifications.error("Could not list buckets", e.response?.data?.error || e.message); - selectedBucket.value = store.value?.bucket || ""; + buckets.value = []; + } finally { + loading.value = false; } - await load(); } -function selectBucket(b: string) { - selectedBucket.value = b; - load(); +function open(b: StoreBucket) { + router.push(`/storage/object-stores/${encodeURIComponent(name)}/buckets/${encodeURIComponent(b.name)}`); } async function createBucket() { const bucket = newBucketName.value.trim(); if (!bucket) return; - creatingBucket.value = true; + creating.value = true; try { await objectStoresApi.createBucket(name, bucket); showNewBucket.value = false; newBucketName.value = ""; - await loadBuckets(); - selectBucket(bucket); - } catch (e: any) { - notifications.error("Could not create bucket", e.response?.data?.error || e.message); - } finally { - creatingBucket.value = false; - } -} - -async function load() { - loading.value = true; - try { - const res = await objectStoresApi.listObjects(name, selectedBucket.value); - objects.value = (res.data.objects || []).sort((a, b) => b.ModTime.localeCompare(a.ModTime)); - } catch (e: any) { - notifications.error("Could not list objects", e.response?.data?.error || e.message); - objects.value = []; - } finally { - loading.value = false; - } -} - -function pickFile() { - fileInput.value?.click(); -} - -async function onFilePicked(e: Event) { - const input = e.target as HTMLInputElement; - const file = input.files?.[0]; - input.value = ""; - if (!file) return; - uploading.value = true; - try { - await objectStoresApi.uploadObject(name, file, selectedBucket.value); - notifications.success("Uploaded", `${file.name} uploaded to ${selectedBucket.value}.`); await load(); } catch (e: any) { - notifications.error("Upload failed", e.response?.data?.error || e.message); - } finally { - uploading.value = false; - } -} - -async function download(o: StoreObject) { - busyKey.value = o.Key; - try { - const res = await objectStoresApi.downloadObject(name, o.Key, selectedBucket.value); - const url = URL.createObjectURL(res.data as Blob); - const a = document.createElement("a"); - a.href = url; - a.download = o.Key.split("/").pop() || o.Key; - a.click(); - URL.revokeObjectURL(url); - } catch (e: any) { - notifications.error("Download failed", e.response?.data?.error || e.message); + notifications.error("Could not create bucket", e.response?.data?.error || e.message); } finally { - busyKey.value = null; + creating.value = false; } } -function remove(o: StoreObject) { - pendingDelete.value = o; +function askDelete(b: StoreBucket) { + pendingDelete.value = b; } -async function confirmDelete() { - const o = pendingDelete.value; - if (!o) return; - busyKey.value = o.Key; +async function confirmDeleteBucket() { + const b = pendingDelete.value; + if (!b) return; + deleting.value = true; try { - await objectStoresApi.deleteObject(name, o.Key, selectedBucket.value); - objects.value = objects.value.filter((x) => x.Key !== o.Key); + await objectStoresApi.deleteBucket(name, b.name); + buckets.value = buckets.value.filter((x) => x.name !== b.name); pendingDelete.value = null; } catch (e: any) { - notifications.error("Delete failed", e.response?.data?.error || e.message); - } finally { - busyKey.value = null; - } -} - -function ext(key: string): string { - return key.split(".").pop()?.toLowerCase() || ""; -} - -function previewable(key: string): boolean { - const e = ext(key); - return IMAGE_EXT.includes(e) || TEXT_EXT.includes(e); -} - -async function openPreview(o: StoreObject) { - const e = ext(o.Key); - busyKey.value = o.Key; - try { - const res = await objectStoresApi.downloadObject(name, o.Key, selectedBucket.value, true); - const blob = res.data as Blob; - if (IMAGE_EXT.includes(e)) { - preview.value = { key: o.Key, kind: "image", url: URL.createObjectURL(blob) }; - } else { - preview.value = { key: o.Key, kind: "text", text: await blob.text() }; - } - } catch (err: any) { - notifications.error("Preview failed", err.response?.data?.error || err.message); + notifications.error("Could not delete bucket", e.response?.data?.error || e.message); } finally { - busyKey.value = null; + deleting.value = false; } } -function closePreview() { - if (preview.value?.url) URL.revokeObjectURL(preview.value.url); - preview.value = null; -} - function formatBytes(n: number): string { if (!n) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(n) / Math.log(1024)); return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${units[i]}`; } - -function formatTime(iso: string): string { - const d = new Date(iso); - const diff = (Date.now() - d.getTime()) / 1000; - if (diff < 60) return "just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return d.toLocaleDateString(); -}