Skip to content

Commit c7122f0

Browse files
authored
feat: portal collections with thumbnails, context-aware navigation, and asset browser (#282)
* feat: add grid/table view toggle for My Assets and Shared With Me pages Add a persistent toggle between thumbnail grid and dense table view for asset listing pages. The preference is stored in localStorage and shared across both pages. * feat: collections system with thumbnails, context-aware navigation, and asset browser - Add portal collections: create, edit, view, delete, share, and public viewer - Auto-generate collection thumbnails as mosaic of asset thumbnails - Context-aware back navigation via URL paths (/collections/:id/assets/:assetId, /shared/assets/:id) so the back arrow always goes to the correct parent - Replace inline asset search with full browse modal (search, sort, thumbnails) - Add asset preview modal for quick-viewing content without navigating away - Show collection associations on asset list pages (grid and table views) - Show aggregated asset tags on collection list instead of section count - Skip create dialog for new collections, go straight to editor - Add delete collection with confirmation in editor toolbar - Add confirmation on asset removal from collection sections - Preserve thumbnails across dev restarts (skip S3 re-upload if content exists) - Add fresh audit events on each dev restart (50 unique events per start) - Fix S3 key prefix for collection thumbnails (portal/ prefix) - Add 3rd seed collection with sections and items * fix: address PR review issues — N+1 query, targeted permission check, test coverage - Replace per-asset Get() loop in fetchAssetMap with batch GetByIDs query - Add GetUserCollectionPermission to ShareStore for targeted access checks instead of loading all shares and filtering in Go - Fix struct alignment in main.go Deps literal - Fix pre-existing TestPublicViewContentViewerBundle failure - Extract helpers to reduce cyclomatic complexity in List and handlers - Add test coverage for collection_handler.go, collection_store.go, and public.go collection additions (91.5% package coverage) - Propagate new interface methods to all mock implementations
1 parent 25420e9 commit c7122f0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+7902
-156
lines changed

cmd/mcp-data-platform/main.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -514,12 +514,13 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform) {
514514
}
515515

516516
deps := portal.Deps{
517-
AssetStore: p.PortalAssetStore(),
518-
ShareStore: p.PortalShareStore(),
519-
VersionStore: p.PortalVersionStore(),
520-
S3Client: p.PortalS3Client(),
521-
S3Bucket: p.Config().Portal.S3Bucket,
522-
PublicBaseURL: p.Config().Portal.PublicBaseURL,
517+
AssetStore: p.PortalAssetStore(),
518+
ShareStore: p.PortalShareStore(),
519+
VersionStore: p.PortalVersionStore(),
520+
CollectionStore: p.PortalCollectionStore(),
521+
S3Client: p.PortalS3Client(),
522+
S3Bucket: p.Config().Portal.S3Bucket,
523+
PublicBaseURL: p.Config().Portal.PublicBaseURL,
523524
RateLimit: portal.RateLimitConfig{
524525
RequestsPerMinute: p.Config().Portal.RateLimit.RequestsPerMinute,
525526
BurstSize: p.Config().Portal.RateLimit.BurstSize,

dev/.air.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ tmp_dir = "build/air"
88
delay = 500
99
exclude_dir = ["ui", "build", "dist", "node_modules", "dev", "test", "apps", "docs", ".git", "internal/apidocs", "internal/ui/dist", "internal/contentviewer/dist"]
1010
exclude_regex = ["_test\\.go$"]
11-
include_ext = ["go", "yaml"]
11+
include_ext = ["go", "yaml", "html", "sql"]
1212
include_file = ["dev/platform.yaml"]
1313
kill_delay = 500
1414
send_interrupt = true

dev/seed-s3.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env bash
22
# Upload seed asset content to SeaweedFS and the portal API.
33
# Called by start.sh after SQL seed completes.
4+
#
5+
# Only uploads content if the asset has no content yet (HTTP 404 on GET).
6+
# This prevents clearing generated thumbnails on every dev restart.
47
set -euo pipefail
58

69
API="http://localhost:8080"
@@ -9,8 +12,16 @@ CONTENT_DIR="dev/seed-content"
912

1013
# Upload content via the portal API (handles S3 + version tracking)
1114
# Bucket must already exist (created by start.sh during Docker startup).
15+
# Skips upload if the asset already has content (preserves thumbnails).
1216
upload() {
1317
local id="$1" file="$2"
18+
local status
19+
status=$(curl -s -o /dev/null -w "%{http_code}" \
20+
"$API/api/v1/portal/assets/$id/content" \
21+
-H "X-API-Key: $API_KEY")
22+
if [ "$status" = "200" ]; then
23+
return 0
24+
fi
1425
curl -sf -X PUT "$API/api/v1/portal/assets/$id/content" \
1526
-H "X-API-Key: $API_KEY" \
1627
--data-binary "@$file" > /dev/null

dev/seed.sql

Lines changed: 251 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,24 @@
33
-- Run AFTER the Go server has started once (to create the schema via migrations).
44
-- Usage: docker exec acme-dev-postgres psql -U platform -d mcp_platform -f /tmp/seed.sql
55
--
6+
-- All inserts use ON CONFLICT upserts so this file can be re-run safely.
7+
-- Demo data gets reset to latest; user-created data is preserved.
8+
--
69
-- Seeds:
710
-- ~5,000 audit events over the past 7 days
11+
-- 50 fresh audit events within the last hour (new each restart)
812
-- 8 knowledge insights in various states
913
-- 2 knowledge changesets (1 applied, 1 rolled back)
1014
-- 6 portal assets with versions and shares
15+
-- 3 portal collections with sections, items, and a public share
1116

1217
-- ============================================================================
1318
-- Audit Events (~5,000 over 7 days)
19+
-- Delete seed events and re-insert so timestamps are always relative to NOW().
1420
-- ============================================================================
1521

22+
DELETE FROM audit_logs WHERE id LIKE 'evt-%';
23+
1624
INSERT INTO audit_logs (
1725
id, request_id, session_id, user_id, user_email, persona,
1826
tool_name, toolkit_kind, toolkit_name, connection,
@@ -139,6 +147,83 @@ FROM
139147
LIMIT 1
140148
) AS t;
141149

150+
-- ============================================================================
151+
-- Fresh Activity (50 new events each restart, simulating recent usage)
152+
-- Uses gen_random_uuid() so each restart adds unique events.
153+
-- ============================================================================
154+
155+
INSERT INTO audit_logs (
156+
id, request_id, session_id, user_id, user_email, persona,
157+
tool_name, toolkit_kind, toolkit_name, connection,
158+
parameters, success, error_message,
159+
duration_ms, response_chars, request_chars, content_blocks,
160+
transport, source, enrichment_applied, authorized,
161+
"timestamp", created_date
162+
)
163+
SELECT
164+
'fresh-' || gen_random_uuid()::text,
165+
'req-' || gen_random_uuid()::text,
166+
'sess-' || ((n % 20) + 200),
167+
u.email,
168+
u.email,
169+
u.persona,
170+
t.tool_name,
171+
t.toolkit_kind,
172+
t.toolkit_name,
173+
t.connection,
174+
CASE t.toolkit_kind
175+
WHEN 'trino' THEN jsonb_build_object(
176+
'catalog', (ARRAY['iceberg','hive','memory'])[1 + (n % 3)],
177+
'schema', (ARRAY['retail','inventory','finance','analytics','staging'])[1 + (n % 5)],
178+
'table', (ARRAY['daily_sales','store_transactions','inventory_levels','product_catalog','regional_performance'])[1 + (n % 5)]
179+
)
180+
WHEN 'datahub' THEN jsonb_build_object(
181+
'query', (ARRAY['daily_sales','inventory','customer','revenue','store performance','supply chain'])[1 + (n % 6)]
182+
)
183+
WHEN 's3' THEN jsonb_build_object(
184+
'bucket', (ARRAY['acme-raw-transactions','acme-analytics-output','acme-ml-features','acme-report-archive'])[1 + (n % 4)],
185+
'prefix', (ARRAY['raw/2024/','processed/daily/','exports/regional/','ml/features/'])[1 + (n % 4)]
186+
)
187+
END,
188+
true,
189+
'',
190+
30 + (n * 7 % 500),
191+
100 + (n * 13 % 5000),
192+
50 + (n * 7 % 400),
193+
1 + (n % 4),
194+
'http',
195+
'mcp',
196+
(n % 3) != 0,
197+
true,
198+
NOW() - ((n * 3 % 60) || ' minutes')::interval - ((n * 17 % 60) || ' seconds')::interval,
199+
CURRENT_DATE
200+
FROM
201+
generate_series(1, 50) AS n,
202+
LATERAL (
203+
SELECT email, persona FROM (VALUES
204+
('sarah.chen@example.com', 'admin'),
205+
('marcus.johnson@example.com', 'data-engineer'),
206+
('rachel.thompson@example.com', 'inventory-analyst'),
207+
('david.park@example.com', 'regional-director'),
208+
('amanda.lee@example.com', 'data-engineer'),
209+
('lisa.chang@example.com', 'data-engineer')
210+
) AS users(email, persona)
211+
OFFSET (n % 6)
212+
LIMIT 1
213+
) AS u,
214+
LATERAL (
215+
SELECT tool_name, toolkit_kind, toolkit_name, connection FROM (VALUES
216+
('trino_query', 'trino', 'acme-warehouse', 'acme-warehouse'),
217+
('trino_describe_table', 'trino', 'acme-warehouse', 'acme-warehouse'),
218+
('datahub_search', 'datahub', 'acme-catalog', 'acme-catalog'),
219+
('datahub_get_entity', 'datahub', 'acme-catalog', 'acme-catalog'),
220+
('s3_list_objects', 's3', 'acme-data-lake', 'acme-data-lake'),
221+
('s3_get_object', 's3', 'acme-data-lake', 'acme-data-lake')
222+
) AS tools(tool_name, toolkit_kind, toolkit_name, connection)
223+
OFFSET (n % 6)
224+
LIMIT 1
225+
) AS t;
226+
142227
-- ============================================================================
143228
-- Knowledge Insights (8 in various states)
144229
-- ============================================================================
@@ -236,7 +321,14 @@ INSERT INTO knowledge_insights (
236321
'[]'::jsonb,
237322
'pending', '', '', '', '',
238323
NOW() - interval '12 hours'
239-
);
324+
)
325+
ON CONFLICT (id) DO UPDATE SET
326+
category = EXCLUDED.category,
327+
insight_text = EXCLUDED.insight_text,
328+
confidence = EXCLUDED.confidence,
329+
status = EXCLUDED.status,
330+
reviewed_by = EXCLUDED.reviewed_by,
331+
review_notes = EXCLUDED.review_notes;
240332

241333
-- ============================================================================
242334
-- Knowledge Changesets (2: 1 applied, 1 rolled back)
@@ -269,7 +361,12 @@ INSERT INTO knowledge_changesets (
269361
'sarah.chen@example.com', 'sarah.chen@example.com',
270362
true, 'sarah.chen@example.com',
271363
NOW() - interval '6 days'
272-
);
364+
)
365+
ON CONFLICT (id) DO UPDATE SET
366+
change_type = EXCLUDED.change_type,
367+
previous_value = EXCLUDED.previous_value,
368+
new_value = EXCLUDED.new_value,
369+
rolled_back = EXCLUDED.rolled_back;
273370

274371
-- ============================================================================
275372
-- Portal Assets (6 assets across different users and content types)
@@ -345,7 +442,12 @@ INSERT INTO portal_assets (
345442
'{"tool": "save_artifact", "session_id": "sess-206"}'::jsonb,
346443
'sess-206', 1,
347444
NOW() - interval '1 day', NOW() - interval '1 day'
348-
);
445+
)
446+
ON CONFLICT (id) DO UPDATE SET
447+
name = EXCLUDED.name,
448+
description = EXCLUDED.description,
449+
content_type = EXCLUDED.content_type,
450+
tags = EXCLUDED.tags;
349451

350452
-- Asset versions (one per asset, matching the current_version=1)
351453
INSERT INTO portal_asset_versions (
@@ -357,7 +459,8 @@ INSERT INTO portal_asset_versions (
357459
('ver-003', 'asset-003', 1, 'portal/apikey:admin/asset-003/v1/content.jsx', 'portal-assets', 'text/jsx', 6340, 'apikey:admin', 'Initial version', NOW() - interval '4 days'),
358460
('ver-004', 'asset-004', 1, 'portal/apikey:admin/asset-004/v1/content.md', 'portal-assets', 'text/markdown', 2890, 'apikey:admin', 'Initial version', NOW() - interval '3 days'),
359461
('ver-005', 'asset-005', 1, 'portal/apikey:admin/asset-005/v1/content.svg', 'portal-assets', 'image/svg+xml', 8150, 'apikey:admin', 'Initial version', NOW() - interval '2 days'),
360-
('ver-006', 'asset-006', 1, 'portal/apikey:admin/asset-006/v1/content.html', 'portal-assets', 'text/html', 5420, 'apikey:admin', 'Initial version', NOW() - interval '1 day');
462+
('ver-006', 'asset-006', 1, 'portal/apikey:admin/asset-006/v1/content.html', 'portal-assets', 'text/html', 5420, 'apikey:admin', 'Initial version', NOW() - interval '1 day')
463+
ON CONFLICT (id) DO NOTHING;
361464

362465
-- Shares (2 assets shared: one user share, one public link)
363466
INSERT INTO portal_shares (
@@ -373,4 +476,147 @@ INSERT INTO portal_shares (
373476
'share-002', 'asset-002', 'tok-inventory-marcus',
374477
'apikey:admin', NULL, NOW() - interval '4 days',
375478
'apikey:admin', 'marcus.johnson@example.com', 'viewer'
376-
);
479+
)
480+
ON CONFLICT (id) DO UPDATE SET
481+
expires_at = EXCLUDED.expires_at,
482+
permission = EXCLUDED.permission;
483+
484+
-- ============================================================================
485+
-- Portal Collections (2 curated collections)
486+
-- ============================================================================
487+
488+
INSERT INTO portal_collections (
489+
id, owner_id, owner_email, name, description, config,
490+
created_at, updated_at
491+
) VALUES
492+
(
493+
'coll-001', 'apikey:admin', 'admin@apikey.local',
494+
'Q3 2025 Executive Review',
495+
E'Comprehensive **quarterly business review** package prepared for the executive leadership team.\n\nThis collection brings together financial results, operational metrics, and strategic insights from Q3 2025. Each section focuses on a different aspect of business performance.\n\n> *"Data is the new oil, but only if you refine it."*\n\n### How to use this collection\n\n- Start with the **Financial Overview** for top-line results\n- Review **Operations & Inventory** for supply chain health\n- Finish with **Technical Architecture** for platform investment context',
496+
'{"thumbnail_size": "medium"}'::jsonb,
497+
NOW() - interval '2 days', NOW() - interval '2 days'
498+
),
499+
(
500+
'coll-002', 'apikey:admin', 'admin@apikey.local',
501+
'Regional Sales Deep Dive',
502+
E'A focused analysis of **regional sales performance** across all product categories and store locations.\n\nThis collection was assembled to support the upcoming regional managers meeting. It includes:\n\n1. Revenue dashboards with week-over-week trends\n2. Store-level comparisons and rankings\n3. Geographic heatmaps for visual pattern recognition\n\n---\n\n*Prepared by the Analytics team using the ACME Data Platform.*',
503+
'{"thumbnail_size": "large"}'::jsonb,
504+
NOW() - interval '1 day', NOW() - interval '1 day'
505+
)
506+
,
507+
(
508+
'coll-003', 'apikey:admin', 'admin@apikey.local',
509+
'2025 Sales Insights',
510+
E'This is a demo collection. Bacon ipsum dolor amet ground round porchetta filet mignon turducken chicken hamburger tenderloin jowl jerky strip steak alcatra shoulder.\n\nA curated set of dashboards, reports, and analyses covering 2025 sales performance across all regions.',
511+
'{"thumbnail_size": "large"}'::jsonb,
512+
NOW() - interval '1 day', NOW() - interval '1 day'
513+
)
514+
ON CONFLICT (id) DO UPDATE SET
515+
name = EXCLUDED.name,
516+
description = EXCLUDED.description,
517+
config = EXCLUDED.config;
518+
519+
-- Collection sections with descriptions
520+
INSERT INTO portal_collection_sections (
521+
id, collection_id, title, description, position, created_at
522+
) VALUES
523+
-- Collection 1: Q3 Executive Review
524+
(
525+
'sec-001', 'coll-001',
526+
'Financial Overview',
527+
E'Top-line **financial results** for Q3 2025.\n\nKey metrics to watch:\n- Revenue vs. plan\n- Margin trends\n- Year-over-year growth rates',
528+
0, NOW() - interval '2 days'
529+
),
530+
(
531+
'sec-002', 'coll-001',
532+
'Operations & Inventory',
533+
E'Supply chain health and inventory management metrics.\n\nThis section highlights stock levels, reorder alerts, and warehouse utilization across all categories.',
534+
1, NOW() - interval '2 days'
535+
),
536+
(
537+
'sec-003', 'coll-001',
538+
'Technical Architecture',
539+
E'Overview of the **data platform architecture** powering these analytics.\n\n```\nSources → Ingestion → Warehouse → Serving → Dashboards\n```\n\nIncluded for context on platform investment and capabilities.',
540+
2, NOW() - interval '2 days'
541+
),
542+
-- Collection 2: Regional Sales Deep Dive
543+
(
544+
'sec-004', 'coll-002',
545+
'Revenue Dashboards',
546+
E'Interactive revenue views showing **weekly trends** and category breakdowns.\n\nUse these to identify:\n- Which categories are driving growth\n- Week-over-week momentum shifts\n- Seasonal patterns emerging in Q3',
547+
0, NOW() - interval '1 day'
548+
),
549+
(
550+
'sec-005', 'coll-002',
551+
'Store Rankings & Comparisons',
552+
E'Head-to-head store performance analysis covering:\n\n| Metric | Description |\n|--------|-------------|\n| Revenue | Total sales volume |\n| Traffic | Foot traffic counts |\n| Conversion | Visit-to-purchase rate |\n\nStores are ranked by composite score.',
553+
1, NOW() - interval '1 day'
554+
),
555+
(
556+
'sec-006', 'coll-002',
557+
'Geographic Analysis',
558+
E'Visual **geographic breakdown** of sales by region and product category.\n\nThe heatmap reveals concentration patterns that are not obvious from tabular data alone.',
559+
2, NOW() - interval '1 day'
560+
)
561+
,
562+
-- Collection 3: 2025 Sales Insights
563+
(
564+
'sec-007', 'coll-003',
565+
'Sales Overview',
566+
E'Top-level revenue and performance dashboards for 2025.',
567+
0, NOW() - interval '1 day'
568+
),
569+
(
570+
'sec-008', 'coll-003',
571+
'Regional Breakdown',
572+
E'Regional performance analysis with geographic visualizations.',
573+
1, NOW() - interval '1 day'
574+
)
575+
ON CONFLICT (id) DO UPDATE SET
576+
title = EXCLUDED.title,
577+
description = EXCLUDED.description,
578+
position = EXCLUDED.position;
579+
580+
-- Collection items (assets assigned to sections)
581+
INSERT INTO portal_collection_items (
582+
id, section_id, asset_id, position, created_at
583+
) VALUES
584+
-- Collection 1, Section 1: Financial Overview
585+
('item-001', 'sec-001', 'asset-006', 0, NOW() - interval '2 days'), -- Q3 Financial Summary
586+
('item-002', 'sec-001', 'asset-001', 1, NOW() - interval '2 days'), -- Weekly Revenue Dashboard
587+
-- Collection 1, Section 2: Operations & Inventory
588+
('item-003', 'sec-002', 'asset-002', 0, NOW() - interval '2 days'), -- Inventory Health Report
589+
-- Collection 1, Section 3: Technical Architecture
590+
('item-004', 'sec-003', 'asset-004', 0, NOW() - interval '2 days'), -- Data Pipeline Architecture
591+
-- Collection 2, Section 1: Revenue Dashboards
592+
('item-005', 'sec-004', 'asset-001', 0, NOW() - interval '1 day'), -- Weekly Revenue Dashboard
593+
('item-006', 'sec-004', 'asset-006', 1, NOW() - interval '1 day'), -- Q3 Financial Summary
594+
-- Collection 2, Section 2: Store Rankings
595+
('item-007', 'sec-005', 'asset-003', 0, NOW() - interval '1 day'), -- Store Performance Comparison
596+
-- Collection 2, Section 3: Geographic Analysis
597+
('item-008', 'sec-006', 'asset-005', 0, NOW() - interval '1 day'), -- Regional Sales Heatmap
598+
('item-009', 'sec-006', 'asset-002', 1, NOW() - interval '1 day'), -- Inventory Health Report
599+
-- Collection 3, Section 1: Sales Overview
600+
('item-010', 'sec-007', 'asset-001', 0, NOW() - interval '1 day'), -- Weekly Revenue Dashboard
601+
('item-011', 'sec-007', 'asset-006', 1, NOW() - interval '1 day'), -- Q3 Financial Summary
602+
-- Collection 3, Section 2: Regional Breakdown
603+
('item-012', 'sec-008', 'asset-005', 0, NOW() - interval '1 day'), -- Regional Sales Heatmap
604+
('item-013', 'sec-008', 'asset-003', 1, NOW() - interval '1 day') -- Store Performance Comparison
605+
ON CONFLICT (id) DO UPDATE SET
606+
section_id = EXCLUDED.section_id,
607+
asset_id = EXCLUDED.asset_id,
608+
position = EXCLUDED.position;
609+
610+
-- Share collection 1 with a public link
611+
INSERT INTO portal_shares (
612+
id, collection_id, token, created_by, expires_at, created_at,
613+
shared_with_user_id, shared_with_email, permission
614+
) VALUES
615+
(
616+
'share-003', 'coll-001', 'tok-q3-exec-review-public',
617+
'apikey:admin', NOW() + interval '30 days', NOW() - interval '2 days',
618+
NULL, NULL, 'viewer'
619+
)
620+
ON CONFLICT (id) DO UPDATE SET
621+
expires_at = EXCLUDED.expires_at,
622+
permission = EXCLUDED.permission;

0 commit comments

Comments
 (0)