diff --git a/depot/api/api.go b/depot/api/api.go index 5659b10..26f2c7a 100644 --- a/depot/api/api.go +++ b/depot/api/api.go @@ -57,14 +57,12 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/stats", GetStats) router.GET("/stats/activity", GetActivityStats) + router.GET("/search", OmniSearch) router.GET("/stats/uploaders/:entityID", GetUploaderStats) router.GET("/stats/applications/:clientID", GetApplicationStats) router.GET("/files", ListAttributionFiles) router.GET("/files/search", SearchFiles) - - // Files are addressed by id alone here: the id determines the bucket, so a - // consumer stores one reference, and a public file gets a URL it can be - // embedded with. + router.GET("/download/:fileID", DownloadFile) router.GET("/storage-backends", ListStorageBackends) @@ -85,8 +83,6 @@ func InitializeRoutes(router *gin.Engine) { router.PATCH("/buckets/:bucketName/grants/:clientID", UpdateBucketGrant) router.DELETE("/buckets/:bucketName/grants/:clientID", DeleteBucketGrant) - // File routes an application reaches with its own token, resolved by the - // bucket's grants. router.GET("/buckets/:bucketName/files", ListFiles) router.POST("/buckets/:bucketName/files", UploadFile) router.GET("/buckets/:bucketName/files/:id", GetFile) diff --git a/depot/api/search.go b/depot/api/search.go new file mode 100644 index 0000000..6fb4d64 --- /dev/null +++ b/depot/api/search.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gaucho-racing/depot/depot/service" + "github.com/gin-gonic/gin" +) + +func OmniSearch(c *gin.Context) { + Require(c, RequestTokenExists(c)) + + query := strings.TrimSpace(c.Query("q")) + if len([]rune(query)) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "q must contain at least 2 characters"}) + return + } + limit, err := strconv.Atoi(c.DefaultQuery("limit", "30")) + if err != nil || limit < 1 || limit > 50 { + c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be an integer between 1 and 50"}) + return + } + bucketIDs, err := accessibleBucketIDs(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + results, err := service.OmniSearch(c.Request.Context(), service.OmniSearchOptions{ + Query: query, + BucketIDs: bucketIDs, + IncludeAdminResources: RequestTokenIsAdmin(c), + Limit: limit, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"query": query, "results": results}) +} diff --git a/depot/database/db.go b/depot/database/db.go index 0bfc7d8..ce1a236 100644 --- a/depot/database/db.go +++ b/depot/database/db.go @@ -32,6 +32,10 @@ func Init() { } logger.SugarLogger.Infoln("Connected to database") + if err := db.Exec("CREATE EXTENSION IF NOT EXISTS pg_trgm").Error; err != nil { + logger.SugarLogger.Fatalf("failed to enable pg_trgm: %v", err) + return + } // Renames from the terminal → storage backend nomenclature change; // AutoMigrate can't rename, so handle existing dev databases explicitly. @@ -62,6 +66,20 @@ func Init() { logger.SugarLogger.Fatalf("failed to run database migrations: %v", err) return } + searchIndexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_depot_bucket_search ON depot_bucket USING GIN ((lower(coalesce(id, '') || ' ' || coalesce(name, '') || ' ' || coalesce(description, ''))) gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_depot_file_search ON depot_file USING GIN ((lower(coalesce(id, '') || ' ' || coalesce(bucket_name, '') || ' ' || coalesce(original_name, '') || ' ' || coalesce(path, '') || ' ' || coalesce(content_type, '') || ' ' || coalesce(storage_backend, '') || ' ' || coalesce(created_by_entity_id, '') || ' ' || coalesce(created_by_client_id, ''))) gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_depot_storage_backend_search ON depot_storage_backend USING GIN ((lower(coalesce(id, '') || ' ' || coalesce(name, '') || ' ' || coalesce(region, '') || ' ' || coalesce(bucket, '') || ' ' || coalesce(endpoint, ''))) gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_depot_bucket_grant_search ON depot_bucket_grant USING GIN ((lower(coalesce(id, '') || ' ' || coalesce(bucket_name, '') || ' ' || coalesce(client_id, '') || ' ' || coalesce(description, ''))) gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_depot_file_replica_search ON depot_file_replica USING GIN ((lower(coalesce(id, '') || ' ' || coalesce(file_id, '') || ' ' || coalesce(storage_backend, '') || ' ' || coalesce(error, ''))) gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_depot_access_log_search ON depot_access_log USING GIN ((lower(coalesce(id, '') || ' ' || coalesce(file_id, '') || ' ' || coalesce(file_name, '') || ' ' || coalesce(bucket_name, '') || ' ' || coalesce(entity_id, '') || ' ' || coalesce(client_id, ''))) gin_trgm_ops)`, + } + for _, statement := range searchIndexes { + if err := db.Exec(statement).Error; err != nil { + logger.SugarLogger.Fatalf("failed to create search index: %v", err) + return + } + } logger.SugarLogger.Infoln("AutoMigration complete") DB = db } diff --git a/depot/service/identity.go b/depot/service/identity.go index 842d2d7..49b1ea0 100644 --- a/depot/service/identity.go +++ b/depot/service/identity.go @@ -11,6 +11,7 @@ import ( ) const identityCacheTTL = 5 * time.Minute +const identityResolveBatchSize = 100 type cachedIdentity struct { summary sentinel.IdentitySummary @@ -41,16 +42,19 @@ func ResolveIdentities(ctx context.Context, entityIDs []string) ([]sentinel.Iden } if len(missing) > 0 { - resolved, err := sentinel.ResolveIdentities(ctx, missing) - if err != nil { - if summaries, complete := cachedIdentityResults(entityIDs); complete { - logger.SugarLogger.Warnf("serving stale identity summaries, refresh failed: %v", err) - return summaries, nil + for start := 0; start < len(missing); start += identityResolveBatchSize { + end := min(start+identityResolveBatchSize, len(missing)) + resolved, err := sentinel.ResolveIdentities(ctx, missing[start:end]) + if err != nil { + if summaries, complete := cachedIdentityResults(entityIDs); complete { + logger.SugarLogger.Warnf("serving stale identity summaries, refresh failed: %v", err) + return summaries, nil + } + return nil, err + } + for _, summary := range resolved { + cachedIdentities[summary.ID] = cachedIdentity{summary: summary, fetchedAt: now} } - return nil, err - } - for _, summary := range resolved { - cachedIdentities[summary.ID] = cachedIdentity{summary: summary, fetchedAt: now} } } diff --git a/depot/service/search.go b/depot/service/search.go new file mode 100644 index 0000000..9f11bdd --- /dev/null +++ b/depot/service/search.go @@ -0,0 +1,357 @@ +package service + +import ( + "context" + "fmt" + "math" + "net/url" + "sort" + "strings" + "unicode" + + "github.com/gaucho-racing/depot/depot/database" + "github.com/gaucho-racing/depot/depot/model" + "github.com/gaucho-racing/depot/depot/pkg/logger" + "github.com/gaucho-racing/depot/depot/pkg/sentinel" +) + +type SearchResult struct { + Type string `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Href string `json:"href"` + IconURL string `json:"icon_url,omitempty"` + Score float64 `json:"-"` +} + +type OmniSearchOptions struct { + Query string + BucketIDs []string + IncludeAdminResources bool + Limit int +} + +type searchRow struct { + Type string + ID string + Title string + Subtitle string + BucketName string + FileID string + ClientID string + EntityID string + IconURL string + Score float64 +} + +func OmniSearch(ctx context.Context, options OmniSearchOptions) ([]SearchResult, error) { + query := strings.ToLower(strings.TrimSpace(options.Query)) + pattern := "%" + escapeLike(query) + "%" + rows := []searchRow{} + + if len(options.BucketIDs) > 0 { + bucketRows, err := searchBucketResources(query, pattern, options.BucketIDs, options.Limit) + if err != nil { + return nil, err + } + rows = append(rows, bucketRows...) + } + if options.IncludeAdminResources { + adminRows, err := searchAdminResources(query, pattern, options.Limit) + if err != nil { + return nil, err + } + rows = append(rows, adminRows...) + } + + applications := resolveSearchApplications(options.BucketIDs, options.IncludeAdminResources) + identities := resolveSearchIdentities(ctx, options.BucketIDs, options.IncludeAdminResources) + rows = enrichSearchRows(rows, applications, identities) + rows = append(rows, searchAttribution(query, applications, identities)...) + + results := make([]SearchResult, 0, len(rows)) + for _, row := range rows { + results = append(results, resultFromRow(row)) + } + sort.SliceStable(results, func(i, j int) bool { + if results[i].Score == results[j].Score { + return results[i].Title < results[j].Title + } + return results[i].Score > results[j].Score + }) + if len(results) > options.Limit { + results = results[:options.Limit] + } + if results == nil { + results = []SearchResult{} + } + return results, nil +} + +func searchBucketResources(query string, pattern string, bucketIDs []string, limit int) ([]searchRow, error) { + rows := []searchRow{} + sql := ` +WITH results AS ( + SELECT 'bucket' AS type, id, name AS title, description AS subtitle, name AS bucket_name, + '' AS file_id, '' AS client_id, '' AS entity_id, + lower(coalesce(id, '') || ' ' || coalesce(name, '') || ' ' || coalesce(description, '')) AS search_text, + '' AS extra_text + FROM depot_bucket WHERE id IN ? + UNION ALL + SELECT 'file', id, coalesce(nullif(original_name, ''), id), + concat_ws(' · ', bucket_name, nullif(path, ''), nullif(content_type, '')), bucket_name, + id, created_by_client_id, created_by_entity_id, + lower(coalesce(id, '') || ' ' || coalesce(bucket_name, '') || ' ' || coalesce(original_name, '') || ' ' || coalesce(path, '') || ' ' || coalesce(content_type, '') || ' ' || coalesce(storage_backend, '') || ' ' || coalesce(created_by_entity_id, '') || ' ' || coalesce(created_by_client_id, '')), + lower(coalesce(tags::text, '')) + FROM depot_file WHERE bucket_id IN ? +) +SELECT type, id, title, subtitle, bucket_name, file_id, client_id, entity_id, + CASE WHEN lower(id) = ? OR lower(title) = ? THEN 2 ELSE 0 END + + CASE WHEN lower(title) LIKE ? ESCAPE '\' THEN 0.5 ELSE 0 END + + greatest(similarity(search_text, ?), word_similarity(?, search_text), similarity(extra_text, ?), word_similarity(?, extra_text)) AS score +FROM results +WHERE search_text ILIKE ? ESCAPE '\' OR search_text % ? OR ? <% search_text + OR extra_text ILIKE ? ESCAPE '\' OR extra_text % ? OR ? <% extra_text +ORDER BY score DESC +LIMIT ?` + if err := database.DB.Raw(sql, bucketIDs, bucketIDs, query, query, pattern, query, query, query, query, pattern, query, query, pattern, query, query, limit).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("search bucket resources: %w", err) + } + return rows, nil +} + +func searchAdminResources(query string, pattern string, limit int) ([]searchRow, error) { + rows := []searchRow{} + sql := ` +WITH results AS ( + SELECT 'storage_backend' AS type, id, name AS title, + concat_ws(' · ', provider::text, nullif(region, ''), nullif(bucket, '')) AS subtitle, + '' AS bucket_name, '' AS file_id, '' AS client_id, '' AS entity_id, + lower(coalesce(id, '') || ' ' || coalesce(name, '') || ' ' || coalesce(region, '') || ' ' || coalesce(bucket, '') || ' ' || coalesce(endpoint, '')) AS search_text, + lower(coalesce(provider::text, '')) AS extra_text + FROM depot_storage_backend + UNION ALL + SELECT 'bucket_grant', id, client_id, + concat_ws(' · ', access::text || ' access', bucket_name, nullif(description, '')), bucket_name, + '', client_id, '', + lower(coalesce(id, '') || ' ' || coalesce(bucket_name, '') || ' ' || coalesce(client_id, '') || ' ' || coalesce(description, '')), + lower(coalesce(access::text, '')) + FROM depot_bucket_grant + UNION ALL + SELECT 'file_replica', replica.id, replica.id, + concat_ws(' · ', replica.status::text, replica.storage_backend, file.original_name), file.bucket_name, + replica.file_id, '', '', + lower(coalesce(replica.id, '') || ' ' || coalesce(replica.file_id, '') || ' ' || coalesce(replica.storage_backend, '') || ' ' || coalesce(replica.error, '')), + lower(coalesce(replica.status::text, '') || ' ' || coalesce(file.original_name, '') || ' ' || coalesce(file.bucket_name, '')) + FROM depot_file_replica replica JOIN depot_file file ON file.id = replica.file_id + UNION ALL + SELECT 'access_log', log.id, coalesce(nullif(log.file_name, ''), log.file_id), + concat_ws(' · ', log.action::text, log.bucket_name, nullif(log.entity_id, ''), nullif(log.client_id, '')), log.bucket_name, + log.file_id, log.client_id, log.entity_id, + lower(coalesce(log.id, '') || ' ' || coalesce(log.file_id, '') || ' ' || coalesce(log.file_name, '') || ' ' || coalesce(log.bucket_name, '') || ' ' || coalesce(log.entity_id, '') || ' ' || coalesce(log.client_id, '')), + lower(coalesce(log.action::text, '') || ' ' || coalesce(log.actor_type::text, '')) + FROM depot_access_log log +) +SELECT type, id, title, subtitle, bucket_name, file_id, client_id, entity_id, + CASE WHEN lower(id) = ? OR lower(title) = ? THEN 2 ELSE 0 END + + CASE WHEN lower(title) LIKE ? ESCAPE '\' THEN 0.5 ELSE 0 END + + greatest(similarity(search_text, ?), word_similarity(?, search_text), similarity(extra_text, ?), word_similarity(?, extra_text)) AS score +FROM results +WHERE search_text ILIKE ? ESCAPE '\' OR search_text % ? OR ? <% search_text + OR extra_text ILIKE ? ESCAPE '\' OR extra_text % ? OR ? <% extra_text +ORDER BY score DESC +LIMIT ?` + if err := database.DB.Raw(sql, query, query, pattern, query, query, query, query, pattern, query, query, pattern, query, query, limit).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("search admin resources: %w", err) + } + return rows, nil +} + +func resolveSearchApplications(bucketIDs []string, includeAdminResources bool) map[string]sentinel.Application { + clientIDs := []string{} + if len(bucketIDs) > 0 { + if err := database.DB.Model(&model.File{}).Where("bucket_id IN ? AND created_by_client_id <> ''", bucketIDs).Distinct().Pluck("created_by_client_id", &clientIDs).Error; err != nil { + logger.SugarLogger.Warnf("search application IDs unavailable: %v", err) + return map[string]sentinel.Application{} + } + } + if includeAdminResources { + grantClientIDs := []string{} + if err := database.DB.Model(&model.BucketGrant{}).Where("client_id <> ''").Distinct().Pluck("client_id", &grantClientIDs).Error; err != nil { + logger.SugarLogger.Warnf("search grant application IDs unavailable: %v", err) + } else { + clientIDs = append(clientIDs, grantClientIDs...) + } + logClientIDs := []string{} + if err := database.DB.Model(&model.AccessLog{}).Where("client_id <> ''").Distinct().Pluck("client_id", &logClientIDs).Error; err != nil { + logger.SugarLogger.Warnf("search access log application IDs unavailable: %v", err) + } else { + clientIDs = append(clientIDs, logClientIDs...) + } + } + applications, err := ResolveApplications(clientIDs) + if err != nil { + logger.SugarLogger.Warnf("search application summaries unavailable: %v", err) + return map[string]sentinel.Application{} + } + byClientID := make(map[string]sentinel.Application, len(applications)) + for _, application := range applications { + byClientID[application.ClientID] = application + } + return byClientID +} + +func resolveSearchIdentities(ctx context.Context, bucketIDs []string, includeAdminResources bool) map[string]sentinel.IdentitySummary { + entityIDs := []string{} + if len(bucketIDs) > 0 { + if err := database.DB.Model(&model.File{}).Where("bucket_id IN ? AND created_by_entity_id <> ''", bucketIDs).Distinct().Pluck("created_by_entity_id", &entityIDs).Error; err != nil { + logger.SugarLogger.Warnf("search identity IDs unavailable: %v", err) + return map[string]sentinel.IdentitySummary{} + } + } + if includeAdminResources { + logEntityIDs := []string{} + if err := database.DB.Model(&model.AccessLog{}).Where("entity_id <> ''").Distinct().Pluck("entity_id", &logEntityIDs).Error; err != nil { + logger.SugarLogger.Warnf("search access log identity IDs unavailable: %v", err) + } else { + entityIDs = append(entityIDs, logEntityIDs...) + } + } + identities, err := ResolveIdentities(ctx, entityIDs) + if err != nil { + logger.SugarLogger.Warnf("search identity summaries unavailable: %v", err) + return map[string]sentinel.IdentitySummary{} + } + byID := make(map[string]sentinel.IdentitySummary, len(identities)) + for _, identity := range identities { + byID[identity.ID] = identity + } + return byID +} + +func enrichSearchRows(rows []searchRow, applications map[string]sentinel.Application, identities map[string]sentinel.IdentitySummary) []searchRow { + for i := range rows { + if rows[i].Type == "bucket_grant" { + if application, exists := applications[rows[i].ClientID]; exists { + rows[i].Title = application.Name + rows[i].Subtitle = application.ClientID + " · " + rows[i].Subtitle + rows[i].IconURL = application.IconURL + } + } + if rows[i].Type == "access_log" { + actorName := "" + if identity, exists := identities[rows[i].EntityID]; exists { + actorName = identity.Name + rows[i].IconURL = identity.AvatarURL + } else if application, exists := applications[rows[i].ClientID]; exists { + actorName = application.Name + rows[i].IconURL = application.IconURL + } + if actorName != "" { + rows[i].Subtitle += " · " + actorName + } + } + } + return rows +} + +func searchAttribution(query string, applications map[string]sentinel.Application, identities map[string]sentinel.IdentitySummary) []searchRow { + rows := make([]searchRow, 0, len(applications)+len(identities)) + for _, application := range applications { + text := strings.Join([]string{application.Name, application.ClientID, application.Description}, " ") + if score := fuzzyScore(query, text); score > 0 { + rows = append(rows, searchRow{Type: "application", ID: application.ClientID, Title: application.Name, Subtitle: application.ClientID, ClientID: application.ClientID, IconURL: application.IconURL, Score: score}) + } + } + for _, identity := range identities { + text := strings.Join([]string{identity.Name, identity.Username, identity.ID}, " ") + if score := fuzzyScore(query, text); score > 0 { + iconURL := identity.AvatarURL + if iconURL == "" && identity.Application != nil { + iconURL = identity.Application.IconURL + } + rows = append(rows, searchRow{Type: "uploader", ID: identity.ID, Title: identity.Name, Subtitle: identitySubtitle(identity), EntityID: identity.ID, IconURL: iconURL, Score: score}) + } + } + return rows +} + +func resultFromRow(row searchRow) SearchResult { + result := SearchResult{Type: row.Type, ID: row.ID, Title: row.Title, Subtitle: row.Subtitle, IconURL: row.IconURL, Score: row.Score} + switch row.Type { + case "bucket": + result.Href = "/buckets/" + url.PathEscape(row.BucketName) + case "file", "file_replica", "access_log": + result.Href = "/buckets/" + url.PathEscape(row.BucketName) + "?file=" + url.QueryEscape(row.FileID) + case "storage_backend": + result.Href = "/storage-backends" + case "bucket_grant": + result.Href = "/buckets/" + url.PathEscape(row.BucketName) + "/edit" + case "application": + result.Href = "/applications/" + url.PathEscape(row.ClientID) + case "uploader": + result.Href = "/uploaders/" + url.PathEscape(row.EntityID) + } + return result +} + +func identitySubtitle(identity sentinel.IdentitySummary) string { + if identity.Username != "" { + return "@" + identity.Username + } + if identity.Type == "SERVICE_ACCOUNT" && identity.Application != nil { + return "Service account · " + identity.Application.Name + } + return identity.Type +} + +func fuzzyScore(query string, value string) float64 { + query = normalizeSearchText(query) + value = normalizeSearchText(value) + if query == "" || value == "" { + return 0 + } + if query == value { + return 2.5 + } + bonus := 0.0 + if strings.Contains(value, query) { + bonus = 0.5 + } + queryTrigrams := trigrams(query) + valueTrigrams := trigrams(value) + intersection := 0 + for trigram := range queryTrigrams { + if _, exists := valueTrigrams[trigram]; exists { + intersection++ + } + } + if intersection == 0 { + return bonus + } + return bonus + (2 * float64(intersection) / float64(len(queryTrigrams)+len(valueTrigrams))) +} + +func normalizeSearchText(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return unicode.ToLower(r) + } + return ' ' + }, value) +} + +func trigrams(value string) map[string]struct{} { + runes := []rune(" " + value + " ") + grams := make(map[string]struct{}, int(math.Max(1, float64(len(runes)-2)))) + for i := 0; i+2 < len(runes); i++ { + grams[string(runes[i:i+3])] = struct{}{} + } + return grams +} + +func escapeLike(value string) string { + replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return replacer.Replace(value) +} diff --git a/web/package-lock.json b/web/package-lock.json index a20db80..a81101e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.15.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^1.11.0", "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", @@ -657,20 +658,20 @@ "license": "BSD-3-Clause" }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -678,9 +679,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -2859,6 +2860,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", @@ -4093,6 +4125,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", diff --git a/web/package.json b/web/package.json index a84e0f8..f6dfdf5 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "axios": "^1.15.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^1.11.0", "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx index 64f4f6b..265e043 100644 --- a/web/src/components/AppHeader.tsx +++ b/web/src/components/AppHeader.tsx @@ -1,8 +1,9 @@ -import { BookOpen, Container, Database, LayoutDashboard, LogOut, Menu, Package, Search, Settings } from "lucide-react" +import { BookOpen, Container, Database, LayoutDashboard, LogOut, Menu, Package, Settings } from "lucide-react" import { Link, useLocation, useNavigate } from "react-router-dom" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" +import { HeaderSearch } from "@/components/HeaderSearch" import { DropdownMenu, DropdownMenuContent, @@ -18,7 +19,6 @@ import { cn } from "@/lib/utils" const mobileItems = [ { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/buckets", label: "Buckets", icon: Package }, - { to: "/search", label: "Search", icon: Search }, { to: "/storage-backends", label: "Storage Backends", icon: Database }, { to: "/api-docs", label: "API Docs", icon: BookOpen }, { to: "/settings", label: "Settings", icon: Settings }, @@ -28,7 +28,6 @@ function sectionTitle(pathname: string) { if (pathname.startsWith("/api-docs")) return "API Documentation" if (pathname.startsWith("/settings")) return "Settings" if (pathname.startsWith("/storage-backends")) return "Storage Backends" - if (pathname.startsWith("/search")) return "Search" if (pathname.startsWith("/buckets")) return "Buckets" if (pathname.startsWith("/dashboard")) return "Dashboard" return "Depot" @@ -128,6 +127,7 @@ export function AppHeader() {
+- Type a query and press enter. -
- ) : resultsQuery.isLoading ? ( -- No files match "{submitted}". -
- ) : ( -