From 4c8c379a3d60f5b1041de854ee8a4e31a26b92f5 Mon Sep 17 00:00:00 2001 From: Dan Webb Date: Wed, 7 Jan 2026 11:45:27 +0000 Subject: [PATCH 1/2] feat: Bucket Stats - Adds buckets size stats to each bucket - Move the breadcrumbs to their own section to prevent squashing Signed-off-by: Dan Webb --- docker-compose.yml | 11 +- e2e/bucket_stats.spec.js | 157 ++++++++++++++++++ internal/handlers/buckets_handler.go | 28 +++- internal/handlers/users_handler.go | 16 +- views/pages/browser.html | 37 +++-- views/pages/buckets.html | 4 + views/pages/service_accounts.html | 10 +- views/pages/users.html | 2 +- .../service_account_create_modal.html | 20 ++- views/partials/service_account_created.html | 10 +- views/partials/user_create_modal.html | 103 ++++++++---- 11 files changed, 329 insertions(+), 69 deletions(-) create mode 100644 e2e/bucket_stats.spec.js diff --git a/docker-compose.yml b/docker-compose.yml index 1eae9f4..ed851e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,16 @@ services: networks: - ironbuckets healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "-O", "/dev/null", "http://localhost:8080/health"] + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "-O", + "/dev/null", + "http://localhost:8080/health", + ] interval: 5s timeout: 3s retries: 5 diff --git a/e2e/bucket_stats.spec.js b/e2e/bucket_stats.spec.js new file mode 100644 index 0000000..357bcac --- /dev/null +++ b/e2e/bucket_stats.spec.js @@ -0,0 +1,157 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const APP_URL = process.env.APP_URL || 'http://localhost:8080'; +const ADMIN_USER = process.env.ADMIN_USER || 'minioadmin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'minioadmin'; + +test.describe('Bucket Stats', () => { + let testBucket; + const testFileName = 'test-upload.txt'; + const testFileContent = 'Hello IronBuckets! This is a test file for verifying size updates.'; + const testFilePath = path.join(__dirname, testFileName); + + // Helper to open Create Bucket Modal + async function openCreateBucketModal(page) { + await page.waitForFunction(() => typeof window.htmx !== 'undefined'); + await page.evaluate(() => { + const btn = document.querySelector('button[hx-get="/buckets/create"]'); + if (btn) window.htmx.trigger(btn, 'click'); + }); + await page.waitForSelector('#bucket-modal', { state: 'visible', timeout: 5000 }); + } + + test.beforeAll(async () => { + // Create a dummy file for upload + fs.writeFileSync(testFilePath, testFileContent); + }); + + test.afterAll(async () => { + // Cleanup dummy file + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath); + } + }); + + test.beforeEach(async ({ page }) => { + console.log('beforeEach: Starting setup'); + testBucket = `stats-test-${Date.now()}`; + console.log(`beforeEach: Test bucket name: ${testBucket}`); + + // Login + await page.goto(`${APP_URL}/login`); + console.log('beforeEach: Navigated to login'); + await page.fill('input[name="accessKey"]', ADMIN_USER); + await page.fill('input[name="secretKey"]', ADMIN_PASSWORD); + await page.click('button[type="submit"]'); + await page.waitForURL(`${APP_URL}/`); + console.log('beforeEach: Login successful'); + }); + + test.afterEach(async ({ page }) => { + console.log('afterEach: Starting cleanup'); + if (!testBucket) return; + try { + // Navigate to buckets list + await page.goto(`${APP_URL}/buckets`); + console.log('afterEach: Navigated to buckets'); + + // Find bucket card + const bucketLink = page.locator(`a[href="/buckets/${testBucket}"]`).first(); + if (await bucketLink.count() > 0) { + // Find dropdown trigger within the card (parent of parent of link usually, or sibling logic) + // The structure is: + //
+ //
... dropdown ...
+ // ... + //
+ // We can find the card by text, then find the dropdown button. + const card = page.locator('.group').filter({ hasText: testBucket }).first(); + const dropdownBtn = card.locator('button').first(); + + await dropdownBtn.click(); + await page.getByRole('button', { name: 'Delete Bucket' }).click(); + await page.waitForSelector('#confirm-dialog[open]'); + await page.click('#confirm-dialog-confirm'); + await page.waitForTimeout(500); + } + } catch (e) { + console.log('Cleanup failed:', e); + } + }); + + test('should update bucket size after file upload', async ({ page }) => { + test.setTimeout(120000); + console.log('Starting test...'); + // 1. Create Bucket + await page.goto(`${APP_URL}/buckets`); + console.log('Navigated to buckets list'); + + await openCreateBucketModal(page); + console.log('Opened create modal'); + + await page.fill('#bucket-modal input[name="bucketName"]', testBucket); + await page.click('#bucket-modal button[type="submit"]'); + await page.waitForURL(`${APP_URL}/buckets`, { timeout: 5000 }); + console.log('Bucket created'); + + // Verify initial state + const bucketCard = page.locator('.group').filter({ hasText: testBucket }); + await expect(bucketCard).toBeVisible(); + await expect(bucketCard.getByText('Size')).toBeVisible(); + await expect(bucketCard.getByText('0 B')).toBeVisible(); + console.log('Initial state verified'); + + // 2. Navigate to bucket and upload file + await page.locator(`a[href="/buckets/${testBucket}"]`).filter({ visible: true }).click(); + await page.waitForURL(`${APP_URL}/buckets/${testBucket}`); + console.log('Navigated to bucket'); + + // Trigger the upload modal/input + // Use the specific ID from the HTML + const fileInput = page.locator('#upload-input'); + + // Set files on the hidden input + await fileInput.setInputFiles(testFilePath); + console.log('File set on input'); + + // The onchange handler triggers HTMX submit, which reloads the page. + // We wait for the file name to appear in the list. + await expect(page.getByText(testFileName)).toBeVisible({ timeout: 10000 }); + console.log('File uploaded and visible'); + + // 3. Navigate back to buckets list + await page.goto(`${APP_URL}/buckets`); + console.log('Navigated back to buckets list'); + + // 4. Verify Size updated + // The size might take a moment to update if it's async (MinIO scanner). + // We retry reloading the page until the size is not "0 B". + await expect(async () => { + await page.reload(); + const card = page.locator('.group').filter({ hasText: testBucket }); + // Debug: print current size text + try { + // Try to find the element that contains the size (usually "0 B" or "X B") + // The structure is
...Size
...SIZE...
...
+ // We can look for the sibling of the "Size" label or similar. + // Based on previous code: + // Size + //
{{ .FormattedSize }}
+ const sizeEl = card.locator('div.text-lg.font-semibold.text-white'); + const sizeText = await sizeEl.textContent(); + console.log(`Current size text: ${sizeText}`); + } catch (e) { + console.log('Could not read size text'); + } + await expect(card.getByText('0 B')).not.toBeVisible({ timeout: 2000 }); + }).toPass({ + timeout: 120000, // Wait up to 120 seconds for MinIO to update stats + intervals: [2000, 5000, 10000] // Retry intervals + }); + + console.log('Size updated (not 0 B)'); + }); +}); diff --git a/internal/handlers/buckets_handler.go b/internal/handlers/buckets_handler.go index 0a44543..4870858 100644 --- a/internal/handlers/buckets_handler.go +++ b/internal/handlers/buckets_handler.go @@ -49,9 +49,35 @@ func (h *BucketsHandler) ListBuckets(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list buckets") } + // Fetch Data Usage for sizes + mdm, err := h.minioFactory.NewAdminClient(*creds) + var usage madmin.DataUsageInfo + if err == nil { + usage, _ = mdm.DataUsageInfo(c.Request().Context()) + } + + type BucketWithStats struct { + minio.BucketInfo + Size uint64 + FormattedSize string + } + + var bucketsWithStats []BucketWithStats + for _, b := range buckets { + size := uint64(0) + if usage.BucketSizes != nil { + size = usage.BucketSizes[b.Name] + } + bucketsWithStats = append(bucketsWithStats, BucketWithStats{ + BucketInfo: b, + Size: size, + FormattedSize: utils.FormatBytes(size), + }) + } + return c.Render(http.StatusOK, "buckets", map[string]interface{}{ "ActiveNav": "buckets", - "Buckets": buckets, + "Buckets": bucketsWithStats, }) } diff --git a/internal/handlers/users_handler.go b/internal/handlers/users_handler.go index 6ea35db..8904f2a 100644 --- a/internal/handlers/users_handler.go +++ b/internal/handlers/users_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/damacus/iron-buckets/internal/services" "github.com/labstack/echo/v4" @@ -218,17 +219,28 @@ func (h *UsersHandler) CreateServiceAccount(c echo.Context) error { accessKey := c.Param("accessKey") name := c.FormValue("name") description := c.FormValue("description") + expiry := c.FormValue("expiry") mdm, err := h.minioFactory.NewAdminClient(*creds) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to connect to MinIO") } - newCreds, err := mdm.AddServiceAccount(c.Request().Context(), madmin.AddServiceAccountReq{ + req := madmin.AddServiceAccountReq{ TargetUser: accessKey, Name: name, Description: description, - }) + } + + if expiry != "" { + dur, err := time.ParseDuration(expiry) + if err == nil { + exp := time.Now().Add(dur) + req.Expiration = &exp + } + } + + newCreds, err := mdm.AddServiceAccount(c.Request().Context(), req) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create service account: "+err.Error()) } diff --git a/views/pages/browser.html b/views/pages/browser.html index ddf0232..f16e55e 100644 --- a/views/pages/browser.html +++ b/views/pages/browser.html @@ -77,24 +77,15 @@
-
-
- - - - - -
-
+

{{ .BucketName }}

+
+
+
+ +
diff --git a/views/pages/buckets.html b/views/pages/buckets.html index 08e1786..7f4cb64 100644 --- a/views/pages/buckets.html +++ b/views/pages/buckets.html @@ -59,6 +59,10 @@

Buckets

Created {{ .CreationDate.Format "Jan 2, 2006" }}
+
+ Size +
{{ .FormattedSize }}
+
{{ end }} diff --git a/views/pages/service_accounts.html b/views/pages/service_accounts.html index f504bdf..bda1dd9 100644 --- a/views/pages/service_accounts.html +++ b/views/pages/service_accounts.html @@ -9,13 +9,13 @@ {{ .ParentUser }}

Service Accounts

-

Access keys for programmatic access

+

Service accounts for programmatic access

@@ -24,7 +24,7 @@

Service Accounts

- + @@ -56,7 +56,7 @@

Service Accounts

diff --git a/views/pages/users.html b/views/pages/users.html index c52e7bd..6e85733 100644 --- a/views/pages/users.html +++ b/views/pages/users.html @@ -70,7 +70,7 @@

Identity Management

- Access Keys + Service Accounts
-

Create a new access key for {{ .ParentUser }}

+

Create a new service account for {{ .ParentUser }}

- - Username (optional) +
@@ -24,6 +24,16 @@

Create Access Key

+
+ + +
@@ -33,7 +43,7 @@

Create Access Key

diff --git a/views/partials/service_account_created.html b/views/partials/service_account_created.html index bb9011e..055f420 100644 --- a/views/partials/service_account_created.html +++ b/views/partials/service_account_created.html @@ -7,14 +7,14 @@
-

Access Key Created

-

Save these credentials now. The secret key will not be shown again.

+

Service Account Created

+

Save these credentials now. The password will not be shown again.

- +
@@ -25,7 +25,7 @@

Access Key Created

- +
@@ -40,7 +40,7 @@

Access Key Created

-

Make sure to copy these credentials. The secret key cannot be retrieved later.

+

Make sure to copy these credentials. The password cannot be retrieved later.

diff --git a/views/partials/user_create_modal.html b/views/partials/user_create_modal.html index e032008..da93a82 100644 --- a/views/partials/user_create_modal.html +++ b/views/partials/user_create_modal.html @@ -1,38 +1,77 @@ {{ define "user_create_modal" }} -
-
- +
+
+ -

Add New User

+

Add New User

-
-
-
- - -
-
- - -
-
- - -
-
+ +
+
+ + +
+
+ + +
+
+ + +
+
-
- - -
- -
- +
+ + +
+ +
+
{{ end }} From dffe445789e3b7aa5c843d9b48c6fc00e0d8b25f Mon Sep 17 00:00:00 2001 From: Dan Webb Date: Fri, 9 Jan 2026 23:30:06 +0000 Subject: [PATCH 2/2] fix: update test mocks and selectors for bucket stats feature - Add NewAdminClient and DataUsageInfo mock expectations to TestBucketJourney - Update E2E test selectors to use specific bucket card link selector - Fixes CI failures caused by breadcrumb UI changes adding duplicate links --- cmd/server/bucket_journey_test.go | 5 +++++ e2e/bucket_stats.spec.js | 2 +- e2e/object_browser.spec.js | 16 ++++++++-------- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cmd/server/bucket_journey_test.go b/cmd/server/bucket_journey_test.go index 1ccc56a..dc020ca 100644 --- a/cmd/server/bucket_journey_test.go +++ b/cmd/server/bucket_journey_test.go @@ -16,6 +16,7 @@ import ( "github.com/damacus/iron-buckets/internal/middleware" "github.com/damacus/iron-buckets/internal/services" "github.com/labstack/echo/v4" + "github.com/minio/madmin-go/v3" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/lifecycle" "github.com/minio/minio-go/v7/pkg/notification" @@ -35,12 +36,16 @@ func TestBucketJourney(t *testing.T) { // Pre-login the mock factory creds := services.Credentials{Endpoint: "play.minio.io:9000", AccessKey: "admin", SecretKey: "password"} mockFactory.On("NewClient", creds).Return(mockClient, nil) + mockFactory.On("NewAdminClient", creds).Return(mockClient, nil) // Mock Bucket Operations mockClient.On("ListBuckets", mock.Anything).Return([]minio.BucketInfo{ {Name: "bucket-1", CreationDate: time.Now()}, {Name: "bucket-2", CreationDate: time.Now()}, }, nil) + mockClient.On("DataUsageInfo", mock.Anything).Return(madmin.DataUsageInfo{ + BucketSizes: map[string]uint64{"bucket-1": 1024, "bucket-2": 2048}, + }, nil) mockClient.On("MakeBucket", mock.Anything, "newbucket", mock.Anything).Return(nil) mockClient.On("RemoveBucket", mock.Anything, "newbucket").Return(nil) diff --git a/e2e/bucket_stats.spec.js b/e2e/bucket_stats.spec.js index 357bcac..eeabf1f 100644 --- a/e2e/bucket_stats.spec.js +++ b/e2e/bucket_stats.spec.js @@ -105,7 +105,7 @@ test.describe('Bucket Stats', () => { console.log('Initial state verified'); // 2. Navigate to bucket and upload file - await page.locator(`a[href="/buckets/${testBucket}"]`).filter({ visible: true }).click(); + await page.locator(`.group`).filter({ hasText: testBucket }).locator('a.block').filter({ hasText: testBucket }).click(); await page.waitForURL(`${APP_URL}/buckets/${testBucket}`); console.log('Navigated to bucket'); diff --git a/e2e/object_browser.spec.js b/e2e/object_browser.spec.js index 81b8810..87f8736 100644 --- a/e2e/object_browser.spec.js +++ b/e2e/object_browser.spec.js @@ -87,8 +87,8 @@ test.describe('Object Browser', () => { await page.waitForURL(`${APP_URL}/buckets`, { timeout: 5000 }); await expect(page.getByText(testBucket)).toBeVisible(); - // Click on the bucket to open object browser (use visible link, not dropdown) - await page.locator(`a[href="/buckets/${testBucket}"]`).filter({ visible: true }).click(); + // Click on the bucket to open object browser (use the card link with bucket name) + await page.locator(`.group`).filter({ hasText: testBucket }).locator('a.block').filter({ hasText: testBucket }).click(); await page.waitForURL(`${APP_URL}/buckets/${testBucket}`); // Verify object browser elements @@ -107,8 +107,8 @@ test.describe('Object Browser', () => { await page.click('#bucket-modal button[type="submit"]'); await page.waitForURL(`${APP_URL}/buckets`, { timeout: 5000 }); - // Open the bucket (use visible link, not dropdown) - await page.locator(`a[href="/buckets/${testBucket}"]`).filter({ visible: true }).click(); + // Open the bucket (use the card link with bucket name) + await page.locator(`.group`).filter({ hasText: testBucket }).locator('a.block').filter({ hasText: testBucket }).click(); await page.waitForURL(`${APP_URL}/buckets/${testBucket}`); // Create a folder using the New Folder button @@ -139,8 +139,8 @@ test.describe('Object Browser', () => { await page.click('#bucket-modal button[type="submit"]'); await page.waitForURL(`${APP_URL}/buckets`, { timeout: 5000 }); - // Open the bucket (use visible link, not dropdown) - await page.locator(`a[href="/buckets/${testBucket}"]`).filter({ visible: true }).click(); + // Open the bucket (use the card link with bucket name) + await page.locator(`.group`).filter({ hasText: testBucket }).locator('a.block').filter({ hasText: testBucket }).click(); await page.waitForURL(`${APP_URL}/buckets/${testBucket}`); // Create level1 folder @@ -183,8 +183,8 @@ test.describe('Object Browser', () => { await page.click('#bucket-modal button[type="submit"]'); await page.waitForURL(`${APP_URL}/buckets`, { timeout: 5000 }); - // Open the bucket (use visible link, not dropdown) - await page.locator(`a[href="/buckets/${testBucket}"]`).filter({ visible: true }).click(); + // Open the bucket (use the card link with bucket name) + await page.locator(`.group`).filter({ hasText: testBucket }).locator('a.block').filter({ hasText: testBucket }).click(); await page.waitForURL(`${APP_URL}/buckets/${testBucket}`); // Create a folder
Access KeyUsername Name Description Status