From a180a429cc268b9c0930341143e7ce78048549ef Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 21 Apr 2026 01:37:09 +0545 Subject: [PATCH 1/3] feat: scrape UI --- .gitignore | 1 - Makefile | 11 +- cmd/root.go | 2 +- cmd/run.go | 125 +- cmd/scrapeui/assets.go | 6 + cmd/scrapeui/convert.go | 313 +++ cmd/scrapeui/frontend/.gitignore | 2 + cmd/scrapeui/frontend/package-lock.json | 2073 +++++++++++++++++ cmd/scrapeui/frontend/package.json | 17 + cmd/scrapeui/frontend/src/App.tsx | 508 ++++ .../src/components/AccessLogTable.tsx | 104 + .../frontend/src/components/AccessTable.tsx | 116 + .../frontend/src/components/AliasList.tsx | 26 + .../frontend/src/components/AnsiHtml.tsx | 60 + .../frontend/src/components/ConfigNode.tsx | 64 + .../frontend/src/components/ConfigTree.tsx | 74 + .../frontend/src/components/DetailPanel.tsx | 429 ++++ .../frontend/src/components/EntityTable.tsx | 293 +++ .../frontend/src/components/FilterBar.tsx | 74 + .../frontend/src/components/HARPanel.tsx | 139 ++ .../frontend/src/components/JsonView.tsx | 64 + .../src/components/ScrapeConfigPanel.tsx | 160 ++ .../frontend/src/components/ScraperList.tsx | 44 + .../frontend/src/components/SnapshotPanel.tsx | 204 ++ .../frontend/src/components/SplitPane.tsx | 56 + .../frontend/src/components/Summary.tsx | 51 + cmd/scrapeui/frontend/src/globals.d.ts | 3 + cmd/scrapeui/frontend/src/hooks/useRoute.ts | 74 + cmd/scrapeui/frontend/src/hooks/useSort.tsx | 51 + cmd/scrapeui/frontend/src/index.tsx | 4 + cmd/scrapeui/frontend/src/types.ts | 304 +++ cmd/scrapeui/frontend/src/utils.ts | 239 ++ cmd/scrapeui/frontend/tsconfig.json | 14 + cmd/scrapeui/frontend/vite.config.ts | 164 ++ cmd/scrapeui/server.go | 453 ++++ cmd/scrapeui/types.go | 115 + cmd/ui.go | 101 + 37 files changed, 6531 insertions(+), 7 deletions(-) create mode 100644 cmd/scrapeui/assets.go create mode 100644 cmd/scrapeui/convert.go create mode 100644 cmd/scrapeui/frontend/.gitignore create mode 100644 cmd/scrapeui/frontend/package-lock.json create mode 100644 cmd/scrapeui/frontend/package.json create mode 100644 cmd/scrapeui/frontend/src/App.tsx create mode 100644 cmd/scrapeui/frontend/src/components/AccessLogTable.tsx create mode 100644 cmd/scrapeui/frontend/src/components/AccessTable.tsx create mode 100644 cmd/scrapeui/frontend/src/components/AliasList.tsx create mode 100644 cmd/scrapeui/frontend/src/components/AnsiHtml.tsx create mode 100644 cmd/scrapeui/frontend/src/components/ConfigNode.tsx create mode 100644 cmd/scrapeui/frontend/src/components/ConfigTree.tsx create mode 100644 cmd/scrapeui/frontend/src/components/DetailPanel.tsx create mode 100644 cmd/scrapeui/frontend/src/components/EntityTable.tsx create mode 100644 cmd/scrapeui/frontend/src/components/FilterBar.tsx create mode 100644 cmd/scrapeui/frontend/src/components/HARPanel.tsx create mode 100644 cmd/scrapeui/frontend/src/components/JsonView.tsx create mode 100644 cmd/scrapeui/frontend/src/components/ScrapeConfigPanel.tsx create mode 100644 cmd/scrapeui/frontend/src/components/ScraperList.tsx create mode 100644 cmd/scrapeui/frontend/src/components/SnapshotPanel.tsx create mode 100644 cmd/scrapeui/frontend/src/components/SplitPane.tsx create mode 100644 cmd/scrapeui/frontend/src/components/Summary.tsx create mode 100644 cmd/scrapeui/frontend/src/globals.d.ts create mode 100644 cmd/scrapeui/frontend/src/hooks/useRoute.ts create mode 100644 cmd/scrapeui/frontend/src/hooks/useSort.tsx create mode 100644 cmd/scrapeui/frontend/src/index.tsx create mode 100644 cmd/scrapeui/frontend/src/types.ts create mode 100644 cmd/scrapeui/frontend/src/utils.ts create mode 100644 cmd/scrapeui/frontend/tsconfig.json create mode 100644 cmd/scrapeui/frontend/vite.config.ts create mode 100644 cmd/scrapeui/server.go create mode 100644 cmd/scrapeui/types.go create mode 100644 cmd/ui.go diff --git a/.gitignore b/.gitignore index b59d0b735..43732f0d6 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,3 @@ screenshots/ *.har *.png .playwright-mcp/ -*.json diff --git a/Makefile b/Makefile index 2b6add9dc..9b3d073b1 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,10 @@ tidy: go mod tidy git add go.mod go.sum +.PHONY: scrapeui-build +scrapeui-build: + cd cmd/scrapeui/frontend && npm ci && npm run build + # Generate OpenAPI schema .PHONY: gen-schemas gen-schemas: @@ -116,7 +120,6 @@ test-fast: ginkgo ginkgo --tags slim --nodes=4 --label-filter "!slow" -r -v --skip-package=tests/e2e ./... - .PHONY: gotest-prod gotest-prod: $(validate-envtest-assets) \ @@ -215,7 +218,7 @@ uninstall-crd: manifests # produce a build that's debuggable .PHONY: dev -dev: +dev: scrapeui-build go build -o ./.bin/$(NAME) -v -gcflags="all=-N -l" main.go .PHONY: watch @@ -302,10 +305,10 @@ rust-generate-header: .PHONY: bench bench: - go test ./scrapers/kubernetes/ -bench='^Benchmark(EventProcessing|CacheMemory|Deserialization)' \ + go test ./... -bench='^Benchmark(EventProcessing|CacheMemory|Deserialization)' \ -benchmem -run='^$$' \ -count=3 \ - -benchtime=2s -v + -benchtime=2s -v $(BENCH_ARGS) .PHONY: modernize modernize: diff --git a/cmd/root.go b/cmd/root.go index 19aec65a9..dec6ff646 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -151,5 +151,5 @@ func init() { }, }) - Root.AddCommand(Run, Analyze, Serve, GoOffline, Operator) + Root.AddCommand(Run, Analyze, Serve, GoOffline, Operator, UI) } diff --git a/cmd/run.go b/cmd/run.go index dc1d2d225..3c2f6a4e7 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -8,10 +8,15 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" + "os/exec" + "os/signal" "path" + "runtime" "strings" + "syscall" "time" "github.com/flanksource/clicky" @@ -22,6 +27,7 @@ import ( "github.com/flanksource/commons/timer" "github.com/flanksource/config-db/api" v1 "github.com/flanksource/config-db/api/v1" + "github.com/flanksource/config-db/cmd/scrapeui" "github.com/flanksource/config-db/db" "github.com/flanksource/config-db/scrapers" "github.com/flanksource/duty" @@ -44,6 +50,8 @@ var outputDir string var debugPort int var export bool var save bool +var uiEnabled bool +var uiPort int // Run ... var Run = &cobra.Command{ @@ -130,11 +138,30 @@ var Run = &cobra.Command{ } } + var uiServer *scrapeui.Server + if uiEnabled { + names := make([]string, len(scraperConfigs)) + specs := make([]any, len(scraperConfigs)) + for i, sc := range scraperConfigs { + names[i] = sc.Name + specs[i] = sc.Spec + } + var scrapeSpec any = specs + if len(specs) == 1 { + scrapeSpec = specs[0] + } + uiServer = startScrapeUI(names, scrapeSpec, &logBuf) + } + var hasErrors bool var allResults v1.ScrapeResults var lastSummary *v1.ScrapeSummary var lastSnapshotPair *v1.ScrapeSnapshotPair for i := range scraperConfigs { + if uiServer != nil { + uiServer.UpdateScraper(scraperConfigs[i].Name, scrapeui.ScraperRunning, nil, nil, nil) + } + scrapeCtx, cancel, cancelTimeout := api.NewScrapeContext(dutyCtx).WithScrapeConfig(&scraperConfigs[i]). WithTimeout(dutyCtx.Properties().Duration("scraper.timeout", 4*time.Hour)) defer cancelTimeout() @@ -153,6 +180,14 @@ var Run = &cobra.Command{ if err != nil { hasErrors = true logger.Errorf("error scraping config: (name=%s) %+v", scraperConfigs[i].Name, err) + if uiServer != nil { + uiServer.UpdateScraper(scraperConfigs[i].Name, scrapeui.ScraperError, results, summary, err) + } + } else if uiServer != nil { + uiServer.UpdateScraper(scraperConfigs[i].Name, scrapeui.ScraperComplete, results, summary, nil) + } + if uiServer != nil && snapshotPair != nil { + uiServer.SetSnapshots(scraperConfigs[i].Name, snapshotPair) } scraperUID := string(scraperConfigs[i].GetUID()) @@ -180,6 +215,10 @@ var Run = &cobra.Command{ } } + if uiServer != nil && summary != nil { + uiServer.SetLastScrapeSummary(*summary) + } + allResults = append(allResults, results...) if summary != nil { lastSummary = summary @@ -193,6 +232,48 @@ var Run = &cobra.Command{ logger.Use(os.Stderr) printOutput(allResults, lastSummary, lastSnapshotPair, harCollector, logBuf.String()) + if uiServer != nil { + if harCollector != nil { + uiServer.SetHAR(harCollector.Entries()) + } + + props := make(map[string]scrapeui.PropertyInfo) + for k, v := range dutyCtx.Properties().SupportedProperties() { + props[k] = scrapeui.PropertyInfo{ + Value: v.Value, + Default: v.Default, + Type: v.Type, + } + } + scraperLogLevel := "" + for _, sc := range scraperConfigs { + if sc.Spec.LogLevel != "" { + scraperLogLevel = sc.Spec.LogLevel + break + } + } + globalLevel := "info" + if logger.IsTraceEnabled() { + globalLevel = "trace" + } + uiServer.SetProperties(props, scrapeui.LogLevelInfo{ + Scraper: scraperLogLevel, + Global: globalLevel, + }) + + uiServer.SetDone() + } + + if uiServer == nil { + printOutput(allResults, lastSummary, lastSnapshotPair, harCollector, logBuf.String()) + } + + if uiServer != nil { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + return + } if hasErrors { os.Exit(1) @@ -554,11 +635,53 @@ func ensureScraper(ctx context.Context, sc *v1.ScrapeConfig) error { return nil } +func startScrapeUI(scraperNames []string, scrapeSpec any, logBuf *bytes.Buffer) *scrapeui.Server { + srv := scrapeui.NewServer(scraperNames, scrapeSpec, logBuf) + bi := GetBuildInfo() + srv.SetBuildInfo(scrapeui.BuildInfo{Version: bi.Version, Commit: bi.Commit, Date: bi.Date}) + addr := fmt.Sprintf("localhost:%d", uiPort) + listener, err := net.Listen("tcp", addr) + if err != nil && uiPort != 0 { + logger.Warnf("Port %d in use, picking a free port", uiPort) + listener, err = net.Listen("tcp", "localhost:0") + } + if err != nil { + logger.Errorf("Failed to start scrape UI server: %v", err) + return nil + } + port := listener.Addr().(*net.TCPAddr).Port + url := fmt.Sprintf("http://localhost:%d", port) + + go http.Serve(listener, srv.Handler()) //nolint:errcheck + + time.Sleep(100 * time.Millisecond) + logger.Infof("Scrape UI at %s", url) + openBrowser(url) + return srv +} + +func openBrowser(url string) { + var cmd string + var args []string + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: + cmd = "xdg-open" + } + args = append(args, url) + _ = exec.Command(cmd, args...).Start() +} + func init() { Run.Flags().BoolVar(&save, "save", false, "Save scraped configurations to the database") Run.Flags().BoolVar(&export, "export", true, "Export scraped configurations to files in the output directory and/or pretty print them") Run.Flags().StringVarP(&outputDir, "output-dir", "o", "", "The output folder for configurations") Run.Flags().IntVar(&debugPort, "debug-port", -1, "Start an HTTP server to use the /debug routes, Use -1 to disable and 0 to pick a free port") + Run.Flags().BoolVar(&uiEnabled, "ui", false, "Open a browser dashboard showing real-time scrape progress") + Run.Flags().IntVar(&uiPort, "ui-port", 9001, "Port for the UI server (0 to pick a free port)") clicky.BindAllFlags(Run.Flags()) - } diff --git a/cmd/scrapeui/assets.go b/cmd/scrapeui/assets.go new file mode 100644 index 000000000..be0444f6b --- /dev/null +++ b/cmd/scrapeui/assets.go @@ -0,0 +1,6 @@ +package scrapeui + +import _ "embed" + +//go:embed frontend/dist/scrapeui.js +var bundleJS string diff --git a/cmd/scrapeui/convert.go b/cmd/scrapeui/convert.go new file mode 100644 index 000000000..ce32842b6 --- /dev/null +++ b/cmd/scrapeui/convert.go @@ -0,0 +1,313 @@ +package scrapeui + +import ( + "strings" + + v1 "github.com/flanksource/config-db/api/v1" + duty "github.com/flanksource/duty" + "github.com/flanksource/duty/models" +) + +// MergeResults flattens per-scraper ScrapeResults into a FullScrapeResults for +// the UI. External users/groups/roles are NOT read from individual results any +// longer — they are published once per save cycle via ScrapeSummary.External* +// (and copied into Server.results by UpdateScraper). This function still +// returns ExternalUserGroups and any other per-result fields. +func MergeResults(results []v1.ScrapeResult) v1.FullScrapeResults { + for i := range results { + if results[i].Resolved != nil && results[i].Action == "" { + results[i].Action = results[i].Resolved.Action + } + } + return v1.MergeScrapeResults(v1.ScrapeResults(results)) +} + +// BuildUIRelationships creates frontend-friendly relationships from scrape results. +// It uses external IDs and resolved names (from Resolved.Relationships) so the +// frontend can match relationships to config items by external ID. +// It also resolves RelationshipSelectors in-memory against the scraped configs. +func BuildUIRelationships(results []v1.ScrapeResult) []UIRelationship { + nameByExternalID := map[string]string{} + for _, r := range results { + if r.Name != "" { + nameByExternalID[r.ID] = r.Name + } + } + + var out []UIRelationship + seen := map[string]bool{} + addRel := func(rel UIRelationship) { + key := rel.ConfigExternalID + "|" + rel.RelatedExternalID + "|" + rel.Relation + if seen[key] { + return + } + seen[key] = true + if rel.ConfigName == "" { + rel.ConfigName = nameByExternalID[rel.ConfigExternalID] + } + if rel.RelatedName == "" { + rel.RelatedName = nameByExternalID[rel.RelatedExternalID] + } + out = append(out, rel) + } + for _, r := range results { + // From Resolved.Relationships (populated by relationshipResultHandler for direct rels) + if r.Resolved != nil { + for _, ref := range r.Resolved.Relationships { + addRel(UIRelationship{ + ConfigExternalID: externalIDOrFallback(ref.Query.ConfigExternalID.ExternalID, ref.Query.ConfigID, r.ID), + RelatedExternalID: externalIDOrFallback(ref.Query.RelatedExternalID.ExternalID, ref.Query.RelatedConfigID, ""), + Relation: ref.Query.Relationship, + ConfigName: ref.ConfigName, + RelatedName: ref.RelatedName, + }) + } + } + + // From RelationshipResults (populated by selector resolution in saveResults) + for _, rr := range r.RelationshipResults { + addRel(UIRelationship{ + ConfigExternalID: externalIDOrFallback(rr.ConfigExternalID.ExternalID, rr.ConfigID, r.ID), + RelatedExternalID: externalIDOrFallback(rr.RelatedExternalID.ExternalID, rr.RelatedConfigID, ""), + Relation: rr.Relationship, + }) + } + + // Resolve RelationshipSelectors in-memory against scraped configs + for _, dr := range r.RelationshipSelectors { + for _, match := range matchSelector(dr.Selector, results) { + rel := UIRelationship{Relation: dr.Selector.Type} + if dr.Parent { + rel.ConfigExternalID = match.ID + rel.ConfigName = match.Name + rel.RelatedExternalID = r.ID + rel.RelatedName = r.Name + } else { + rel.ConfigExternalID = r.ID + rel.ConfigName = r.Name + rel.RelatedExternalID = match.ID + rel.RelatedName = match.Name + } + addRel(rel) + } + } + } + return out +} + +// matchSelector finds configs matching a RelationshipSelector in-memory. +func matchSelector(sel duty.RelationshipSelector, configs []v1.ScrapeResult) []v1.ScrapeResult { + var matches []v1.ScrapeResult + for _, c := range configs { + if sel.Type != "" && c.Type != sel.Type { + continue + } + if sel.Name != "" && c.Name != sel.Name { + continue + } + if sel.ExternalID != "" && c.ID != sel.ExternalID { + continue + } + if sel.Namespace != "" { + ns, _ := c.Tags["namespace"] + if ns != sel.Namespace { + continue + } + } + if len(sel.Labels) > 0 { + if !matchLabels(sel.Labels, c.Labels) { + continue + } + } + matches = append(matches, c) + } + return matches +} + +func matchLabels(required, actual map[string]string) bool { + for k, v := range required { + if actual[k] != v { + return false + } + } + return true +} + +func externalIDOrFallback(externalID, configID, fallback string) string { + if externalID != "" { + return externalID + } + if configID != "" { + return configID + } + return fallback +} + +// BuildUIRelationshipsFromDB converts DB-resolved ConfigRelationships back to +// UI relationships by mapping internal UUIDs to external IDs via the config list. +func BuildUIRelationshipsFromDB(rels []models.ConfigRelationship, configs []v1.ScrapeResult) []UIRelationship { + if len(rels) == 0 { + return nil + } + + // Build UUID → external ID index. + // After DB save, ScrapeResult.ConfigID is set to the internal UUID. + type configRef struct { + externalID string + name string + } + byUUID := map[string]configRef{} + for _, c := range configs { + if c.ConfigID != nil && *c.ConfigID != "" { + byUUID[*c.ConfigID] = configRef{externalID: c.ID, name: c.Name} + } + } + + var out []UIRelationship + for _, r := range rels { + cfgRef := byUUID[r.ConfigID] + relRef := byUUID[r.RelatedID] + if cfgRef.externalID == "" && relRef.externalID == "" { + continue // can't resolve either side + } + out = append(out, UIRelationship{ + ConfigExternalID: cfgRef.externalID, + RelatedExternalID: relRef.externalID, + Relation: r.Relation, + ConfigName: cfgRef.name, + RelatedName: relRef.name, + }) + } + return out +} + +// BuildConfigMeta extracts resolved parent paths and locations from scrape results, +// supplemented by parent relationships from the UIRelationship list. +func BuildConfigMeta(results []v1.ScrapeResult, relationships []UIRelationship) map[string]ConfigMeta { + nameByExtID := map[string]string{} + for _, r := range results { + if r.Name != "" { + nameByExtID[r.ID] = r.Name + } + } + + meta := map[string]ConfigMeta{} + for _, r := range results { + m := ConfigMeta{} + if r.Resolved != nil { + for _, p := range r.Resolved.Parents { + name := p.Name + if name == "" { + name = nameByExtID[p.Query.ExternalID] + } + if name == "" { + name = p.Query.ExternalID + } + if name != "" { + m.Parents = append(m.Parents, name) + } + } + } else { + for _, p := range r.Parents { + if p.ExternalID == "" { + continue + } + name := nameByExtID[p.ExternalID] + if name == "" { + name = p.ExternalID + } + m.Parents = append(m.Parents, name) + } + } + if len(r.Locations) > 0 { + m.Location = strings.Join(r.Locations, ", ") + } + if len(m.Parents) > 0 || m.Location != "" { + meta[r.ID] = m + } + } + + addParentsFromRelationships(meta, relationships) + return meta +} + +// BuildConfigMetaFromRelationships derives parent metadata from UIRelationships alone. +func BuildConfigMetaFromRelationships(rels []UIRelationship) map[string]ConfigMeta { + meta := map[string]ConfigMeta{} + addParentsFromRelationships(meta, rels) + return meta +} + +// addParentsFromRelationships supplements meta with parents from UIRelationships. +// Convention: config_id = parent, related_id = child. +func addParentsFromRelationships(meta map[string]ConfigMeta, rels []UIRelationship) { + for _, rel := range rels { + if rel.RelatedExternalID == "" { + continue + } + parentName := rel.ConfigName + if parentName == "" { + parentName = rel.ConfigExternalID + } + if parentName == "" { + continue + } + m := meta[rel.RelatedExternalID] + for _, p := range m.Parents { + if p == parentName { + parentName = "" + break + } + } + if parentName != "" { + m.Parents = append(m.Parents, parentName) + meta[rel.RelatedExternalID] = m + } + } +} + +func ConvertSaveSummary(s *v1.ScrapeSummary) *SaveSummary { + if s == nil { + return nil + } + ss := &SaveSummary{ + ConfigTypes: make(map[string]TypeSummary, len(s.ConfigTypes)), + } + for k, v := range s.ConfigTypes { + ss.ConfigTypes[k] = TypeSummary{ + Added: v.Added, + Updated: v.Updated, + Unchanged: v.Unchanged, + Changes: v.Changes, + } + } + return ss +} + +func BuildCounts(results v1.FullScrapeResults, uiRels []UIRelationship) Counts { + c := Counts{ + Configs: len(results.Configs), + Changes: len(results.Changes), + Analysis: len(results.Analysis), + Relationships: len(uiRels), + ExternalUsers: len(results.ExternalUsers), + ExternalGroups: len(results.ExternalGroups), + ExternalRoles: len(results.ExternalRoles), + ConfigAccess: len(results.ConfigAccess), + AccessLogs: len(results.ConfigAccessLogs), + } + for _, r := range results.Configs { + if r.Error != nil { + c.Errors++ + } + } + return c +} + +func ScraperName(name string) string { + if name == "" { + return "unnamed" + } + parts := strings.Split(name, "/") + return parts[len(parts)-1] +} diff --git a/cmd/scrapeui/frontend/.gitignore b/cmd/scrapeui/frontend/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/cmd/scrapeui/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/cmd/scrapeui/frontend/package-lock.json b/cmd/scrapeui/frontend/package-lock.json new file mode 100644 index 000000000..01aae2cc2 --- /dev/null +++ b/cmd/scrapeui/frontend/package-lock.json @@ -0,0 +1,2073 @@ +{ + "name": "@flanksource/config-db-scrape-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@flanksource/config-db-scrape-ui", + "dependencies": { + "preact": "^10.25.0" + }, + "devDependencies": { + "@preact/preset-vite": "^2.9.0", + "typescript": "^5.3.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cmd/scrapeui/frontend/package.json b/cmd/scrapeui/frontend/package.json new file mode 100644 index 000000000..0ccc6b7a7 --- /dev/null +++ b/cmd/scrapeui/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "@flanksource/config-db-scrape-ui", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "preact": "^10.25.0" + }, + "devDependencies": { + "@preact/preset-vite": "^2.9.0", + "typescript": "^5.3.0", + "vite": "^6.0.0" + } +} diff --git a/cmd/scrapeui/frontend/src/App.tsx b/cmd/scrapeui/frontend/src/App.tsx new file mode 100644 index 000000000..0540531fb --- /dev/null +++ b/cmd/scrapeui/frontend/src/App.tsx @@ -0,0 +1,508 @@ +import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; +import type { Snapshot, ScrapeResult, Tab } from './types'; +import { groupByType, filterItems, collectTypes, formatDuration, buildLookups, globalSearch } from './utils'; +import { useRoute } from './hooks/useRoute'; +import { SplitPane } from './components/SplitPane'; +import { ScraperList } from './components/ScraperList'; +import { Summary } from './components/Summary'; +import { FilterBar, type Filters } from './components/FilterBar'; +import { ConfigTree } from './components/ConfigTree'; +import { DetailPanel } from './components/DetailPanel'; +import { AnsiHtml } from './components/AnsiHtml'; +import { HARPanel } from './components/HARPanel'; +import { EntityTable } from './components/EntityTable'; +import { AccessTable } from './components/AccessTable'; +import { AccessLogTable } from './components/AccessLogTable'; +import { ScrapeConfigPanel } from './components/ScrapeConfigPanel'; +import { SnapshotPanel } from './components/SnapshotPanel'; +import { JsonView } from './components/JsonView'; + +const TAB_DEFS: { key: Tab; label: string; icon: string; countKey?: string }[] = [ + { key: 'configs', label: 'Configs', icon: 'codicon:server-process', countKey: 'configs' }, + { key: 'logs', label: 'Logs', icon: 'codicon:terminal' }, + { key: 'har', label: 'HTTP', icon: 'codicon:globe' }, + { key: 'users', label: 'Users', icon: 'codicon:person', countKey: 'external_users' }, + { key: 'groups', label: 'Groups', icon: 'codicon:organization', countKey: 'external_groups' }, + { key: 'roles', label: 'Roles', icon: 'codicon:shield', countKey: 'external_roles' }, + { key: 'access', label: 'Access', icon: 'codicon:lock', countKey: 'config_access' }, + { key: 'access_logs', label: 'Access Logs', icon: 'codicon:history', countKey: 'access_logs' }, + { key: 'issues', label: 'Issues', icon: 'codicon:warning' }, + { key: 'snapshot', label: 'Snapshot', icon: 'codicon:database' }, + { key: 'last_summary', label: 'Last Summary', icon: 'codicon:pulse' }, + { key: 'spec', label: 'Spec', icon: 'codicon:file-code' }, +]; + +export function App() { + const [route, navigate] = useRoute(); + const { tab, id: routeId, q: routeQ } = route; + const [snapshot, setSnapshot] = useState(null); + const [done, setDone] = useState(false); + const [status, setStatus] = useState('Loading...'); + const [selected, setSelected] = useState(null); + const [expandAll, setExpandAll] = useState(null); + const [filters, setFilters] = useState({ health: new Set(), type: new Set() }); + const [elapsed, setElapsed] = useState(0); + const search = routeQ || ''; + const setSearch = (value: string) => navigate({ q: value || undefined }); + const doneRef = useRef(false); + const startRef = useRef(0); + const logsRef = useRef(null); + const initialTabRef = useRef(tab); + + useEffect(() => { + fetch('/api/scrape') + .then(r => r.json()) + .then((snap: Snapshot) => applySnap(snap)) + .catch(() => {}); + + const es = new EventSource('/api/scrape/stream'); + es.addEventListener('message', (e: MessageEvent) => { + const snap: Snapshot = JSON.parse(e.data); + applySnap(snap); + if (snap.done) es.close(); + }); + es.addEventListener('done', () => { + doneRef.current = true; + setDone(true); + setStatus('Scrape complete'); + es.close(); + }); + es.onerror = () => { + if (!doneRef.current) setStatus('Connection lost — retrying...'); + }; + + const timer = setInterval(() => { + if (startRef.current && !doneRef.current) setElapsed(Date.now() - startRef.current); + }, 1000); + + return () => { es.close(); clearInterval(timer); }; + }, []); + + const tabRef = useRef(tab); + tabRef.current = tab; + + function applySnap(snap: Snapshot) { + startRef.current = snap.started_at; + setSnapshot(snap); + if (snap.done) { + doneRef.current = true; + setDone(true); + setStatus('Scrape complete'); + setElapsed(Date.now() - snap.started_at); + } else { + setStatus('Scraping...'); + } + if ((snap.results?.configs?.length ?? 0) > 0 && tabRef.current === 'spec' && initialTabRef.current === 'spec' && location.pathname === '/') { + navigate({ tab: 'configs' }); + } + } + + // Auto-scroll logs + useEffect(() => { + if (tab === 'logs' && logsRef.current) { + logsRef.current.scrollTop = logsRef.current.scrollHeight; + } + }, [snapshot?.logs, tab]); + + const configs = snapshot?.results?.configs || []; + + // Sync selected config with URL route id (when on configs tab) + useEffect(() => { + if (tab !== 'configs') return; + if (!routeId) { + setSelected(null); + return; + } + if (selected?.id === routeId) return; + const match = configs.find(c => c.id === routeId); + if (match) setSelected(match); + }, [routeId, configs, tab]); + const orphanedConfigs = useMemo(() => { + return (snapshot?.issues || []) + .filter(issue => issue.type === 'orphaned' && issue.change) + .map((issue, i): ScrapeResult => ({ + id: `orphaned-${i}`, + name: issue.change!.summary || issue.change!.change_type || `Orphaned #${i + 1}`, + config_type: 'Orphaned Changes', + health: 'warning', + config: issue.change, + })); + }, [snapshot?.issues]); + + const allConfigs = useMemo(() => [...configs, ...orphanedConfigs], [configs, orphanedConfigs]); + + const filtered = useMemo(() => { + let items = filterItems(allConfigs, filters.health, filters.type); + if (search) { + const lq = search.toLowerCase(); + items = items.filter(c => + c.name?.toLowerCase().includes(lq) || + c.config_type?.toLowerCase().includes(lq) || + c.aliases?.some(a => a.toLowerCase().includes(lq)) || + Object.entries(c.labels || {}).some(([k, v]) => k.toLowerCase().includes(lq) || v.toLowerCase().includes(lq)) || + Object.entries(c.tags || {}).some(([k, v]) => k.toLowerCase().includes(lq) || v.toLowerCase().includes(lq)) || + JSON.stringify(c.config)?.toLowerCase().includes(lq) + ); + } + return items; + }, [allConfigs, filters, search]); + const groups = useMemo(() => groupByType(filtered), [filtered]); + const types = useMemo(() => collectTypes(allConfigs), [allConfigs]); + const healthValues = useMemo(() => { + const vals = new Set(); + for (const item of allConfigs) vals.add(item.health || 'unknown'); + return Array.from(vals).sort(); + }, [allConfigs]); + + const counts: Record = snapshot?.counts as any || {}; + + const zero = () => ({ changes: 0, access: 0, accessLogs: 0, analysis: 0, relationships: 0 }); + + const configCounts = useMemo(() => { + const m = new Map>(); + const changes = snapshot?.results?.changes || []; + const access = snapshot?.results?.config_access || []; + const logs = snapshot?.results?.config_access_logs || []; + const relationships = snapshot?.relationships || []; + + for (const ch of changes) { + if (!ch.source) continue; + for (const cfg of configs) { + if (ch.source.includes(cfg.id)) { + const c = m.get(cfg.id) || zero(); + c.changes++; + m.set(cfg.id, c); + } + } + } + for (const a of access) { + const extId = (a.external_config_id as any)?.external_id || a.external_config_id; + if (!extId) continue; + for (const cfg of configs) { + if (cfg.id === extId) { + const c = m.get(cfg.id) || zero(); + c.access++; + m.set(cfg.id, c); + } + } + } + for (const l of logs) { + const extId = (l.external_config_id as any)?.external_id || l.external_config_id; + if (!extId) continue; + for (const cfg of configs) { + if (cfg.id === extId) { + const c = m.get(cfg.id) || zero(); + c.accessLogs++; + m.set(cfg.id, c); + } + } + } + for (const rel of relationships) { + if (rel.config_id) { + const c = m.get(rel.config_id) || zero(); + c.relationships++; + m.set(rel.config_id, c); + } + if (rel.related_id && rel.related_id !== rel.config_id) { + const c = m.get(rel.related_id) || zero(); + c.relationships++; + m.set(rel.related_id, c); + } + } + return m; + }, [snapshot?.results, configs]); + + const lookups = useMemo(() => buildLookups(snapshot?.results), [snapshot?.results]); + + const searchCounts = useMemo( + () => globalSearch(search, snapshot?.results, snapshot?.har, snapshot?.logs), + [search, snapshot?.results, snapshot?.har, snapshot?.logs], + ); + + const scraperErrors = useMemo( + () => (snapshot?.scrapers || []).filter(s => s.status === 'error' && s.error), + [snapshot?.scrapers], + ); + + return ( +
+ {/* Header */} +
+
+
+

+ + Scrape Results +

+ {status} + {snapshot?.build_info && ( + + {snapshot.build_info.version} + {snapshot.build_info.commit && snapshot.build_info.commit !== 'none' && ( + <> · {snapshot.build_info.commit.substring(0, 8)} + )} + {snapshot.build_info.date && snapshot.build_info.date !== 'unknown' && ( + <> · {snapshot.build_info.date} + )} + + )} +
+ {snapshot && ( + + )} +
+ {snapshot &&
} +
+ + {/* Scrape error banner — surfaces errors from failed scrapers so they + aren't just a small red chip in the scraper list. */} + {scraperErrors.length > 0 && ( +
+ {scraperErrors.map(s => ( +
+ +
+
{s.name} failed
+
{s.error}
+
+
+ ))} +
+ )} + + {/* Tab bar */} +
+ {TAB_DEFS.map(t => { + const count = t.countKey ? counts[t.countKey] || 0 : ( + t.key === 'har' ? (snapshot?.har?.length || 0) : + t.key === 'logs' ? (snapshot?.logs ? 1 : 0) : + t.key === 'issues' ? (snapshot?.issues?.length || 0) : 0 + ); + const isActive = tab === t.key; + const searchHits = search ? (searchCounts[t.key] || 0) : 0; + + // Hide tabs with no data (except configs, logs, spec, snapshot, last_summary) + if (!count && !isActive && !searchHits && !['configs', 'logs', 'spec', 'snapshot', 'last_summary'].includes(t.key)) return null; + + return ( + + ); + })} +
+
+ + setSearch((e.target as HTMLInputElement).value)} + class="pl-7 pr-7 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 w-64" + /> + {search && ( + + )} +
+ +
+
+ + {/* Content */} +
+ {tab === 'configs' && ( +
+
+ {configs.length > 0 && ( +
+ + + +
+ )} +
+ + {groups.map(g => ( + navigate({ tab: 'configs', id: item.id })} expandAll={expandAll} configCounts={configCounts} /> + ))} + {configs.length === 0 && !done && ( +
+ +

Waiting for scrape results...

+
+ )} + {filtered.length === 0 && configs.length > 0 && ( +
No items match the current filters
+ )} + + } + right={ navigate({ tab: kind, id })} + />} + /> +
+ )} + + {tab === 'logs' && ( +
+ {snapshot?.logs ? ( + + ) : ( +
+ {done ? 'No logs captured' : 'Waiting for logs...'} +
+ )} +
+ )} + + {tab === 'har' && } + + {tab === 'users' && navigate({ tab: 'users', id })} />} + {tab === 'groups' && navigate({ tab: 'groups', id })} />} + {tab === 'roles' && navigate({ tab: 'roles', id })} />} + {tab === 'access' && } + {tab === 'access_logs' && } + + {tab === 'issues' && ( +
+ {(!snapshot?.issues || snapshot.issues.length === 0) ? ( +
No issues found
+ ) : ( +
+ {snapshot.issues.map((issue, i) => ( +
+
+ {issue.type} + {issue.message && {issue.message}} + {issue.warning?.count && issue.warning.count > 1 && ( + ×{issue.warning.count} + )} +
+ {issue.change && ( +
+
change_type: {issue.change.change_type}
+ {issue.change.config_type &&
config_type: {issue.change.config_type}
} + {issue.change.external_id &&
external_id: {issue.change.external_id}
} + {issue.change.summary &&
summary: {issue.change.summary}
} + {issue.change.source &&
source: {issue.change.source}
} + {issue.change.severity &&
severity: {issue.change.severity}
} + {issue.change.created_at &&
created_at: {issue.change.created_at}
} +
+ )} + {issue.warning && ( +
+ {issue.warning.expr &&
expr: {issue.warning.expr}
} + {issue.warning.input && ( +
+ input +
+ {typeof issue.warning.input === 'object' ? :
{String(issue.warning.input)}
} +
+
+ )} + {issue.warning.output && ( +
+ output +
+ {typeof issue.warning.output === 'object' ? :
{String(issue.warning.output)}
} +
+
+ )} + {issue.warning.result && ( +
+ result +
+ {typeof issue.warning.result === 'object' ? :
{String(issue.warning.result)}
} +
+
+ )} +
+ )} +
+ ))} +
+ )} +
+ )} + + {tab === 'snapshot' && } + {tab === 'last_summary' && ( +
+ {snapshot?.last_scrape_summary ? ( + + ) : ( +
No previous scrape summary available (first run or no database connection)
+ )} +
+ )} + {tab === 'spec' && } +
+
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/AccessLogTable.tsx b/cmd/scrapeui/frontend/src/components/AccessLogTable.tsx new file mode 100644 index 000000000..58008524d --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/AccessLogTable.tsx @@ -0,0 +1,104 @@ +import { useState, useMemo } from 'preact/hooks'; +import type { ExternalConfigAccessLog } from '../types'; +import { useSort, SortIcon } from '../hooks/useSort'; +import { type Lookups, resolveConfigId, resolve, matchesSearch } from '../utils'; +import { JsonView } from './JsonView'; + +interface Props { + entries: ExternalConfigAccessLog[]; + lookups: Lookups; + search?: string; +} + +const COLS: { key: string; label: string; cls: string }[] = [ + { key: 'external_config_id', label: 'Config', cls: 'px-3 py-2' }, + { key: 'external_user_aliases', label: 'User', cls: 'px-3 py-2' }, + { key: 'mfa', label: 'MFA', cls: 'px-3 py-2 w-16' }, + { key: 'count', label: 'Count', cls: 'px-3 py-2 w-16 text-right' }, + { key: 'created_at', label: 'Timestamp', cls: 'px-3 py-2' }, +]; + +const HIDDEN_KEYS = new Set([ + 'config_id', 'external_config_id', + 'external_user_id', 'external_user_aliases', + 'mfa', 'count', 'created_at', 'scraper_id', +]); + +function AccessLogRow({ entry, lookups }: { entry: ExternalConfigAccessLog; lookups: Lookups }) { + const [open, setOpen] = useState(false); + + const extraProps = useMemo(() => { + const out: Record = {}; + for (const [k, v] of Object.entries(entry)) { + if (HIDDEN_KEYS.has(k)) continue; + if (v === null || v === undefined || v === '' || v === '00000000-0000-0000-0000-000000000000') continue; + if (Array.isArray(v) && v.length === 0) continue; + if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue; + out[k] = v; + } + return out; + }, [entry]); + + return ( + <> + setOpen(!open)} + > + {resolveConfigId(lookups, entry.external_config_id)} + + {entry.external_user_aliases?.map((a, j) => ( + {resolve(lookups.users, a)} + ))} + + + {entry.mfa !== undefined && ( + + {entry.mfa ? 'Yes' : 'No'} + + )} + + {entry.count ?? ''} + {entry.created_at || ''} + + {open && Object.keys(extraProps).length > 0 && ( + + + + + + )} + + ); +} + +export function AccessLogTable({ entries, lookups, search }: Props) { + const filtered = useMemo(() => { + if (!search) return entries; + return entries.filter(e => matchesSearch(search, ...(e.external_user_aliases || []))); + }, [entries, search]); + const { sorted, sort, toggle } = useSort(filtered); + + if (!entries || entries.length === 0) { + return
No access log entries
; + } + + return ( +
+ + + + {COLS.map(c => ( + + ))} + + + + {sorted.map((e, i) => )} + +
toggle(c.key)}> + {c.label} +
+
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/AccessTable.tsx b/cmd/scrapeui/frontend/src/components/AccessTable.tsx new file mode 100644 index 000000000..c122cc89b --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/AccessTable.tsx @@ -0,0 +1,116 @@ +import { useState, useMemo } from 'preact/hooks'; +import type { ExternalConfigAccess } from '../types'; +import { useSort, SortIcon } from '../hooks/useSort'; +import { type Lookups, resolveConfigId, resolve, matchesSearch } from '../utils'; +import { JsonView } from './JsonView'; + +interface Props { + entries: ExternalConfigAccess[]; + lookups: Lookups; + search?: string; +} + +const COLS: { key: string; label: string; cls: string }[] = [ + { key: 'id', label: 'ID', cls: 'px-3 py-2' }, + { key: 'external_config_id', label: 'Config', cls: 'px-3 py-2' }, + { key: 'external_user_aliases', label: 'User', cls: 'px-3 py-2' }, + { key: 'external_role_aliases', label: 'Role', cls: 'px-3 py-2' }, + { key: 'external_group_aliases', label: 'Group', cls: 'px-3 py-2' }, + { key: 'created_at', label: 'Created', cls: 'px-3 py-2' }, +]; + +const HIDDEN_KEYS = new Set([ + 'id', 'config_id', 'external_config_id', + 'external_user_id', 'external_user_aliases', + 'external_role_id', 'external_role_aliases', + 'external_group_id', 'external_group_aliases', + 'created_at', +]); + +function AccessRow({ entry, lookups }: { entry: ExternalConfigAccess; lookups: Lookups }) { + const [open, setOpen] = useState(false); + + const extraProps = useMemo(() => { + const out: Record = {}; + for (const [k, v] of Object.entries(entry)) { + if (HIDDEN_KEYS.has(k)) continue; + if (v === null || v === undefined || v === '' || v === '00000000-0000-0000-0000-000000000000') continue; + if (Array.isArray(v) && v.length === 0) continue; + out[k] = v; + } + return out; + }, [entry]); + + return ( + <> + setOpen(!open)} + > + {entry.id} + {resolveConfigId(lookups, entry.external_config_id)} + + {(entry.external_user_aliases?.length ? entry.external_user_aliases : entry.external_user_id ? [entry.external_user_id] : []).map((a, j) => ( + {resolve(lookups.users, a)} + ))} + + + {(entry.external_role_aliases?.length ? entry.external_role_aliases : entry.external_role_id ? [entry.external_role_id] : []).map((a, j) => ( + {resolve(lookups.roles, a)} + ))} + + + {(entry.external_group_aliases?.length ? entry.external_group_aliases : entry.external_group_id ? [entry.external_group_id] : []).map((a, j) => ( + {resolve(lookups.groups, a)} + ))} + + {entry.created_at || ''} + + {open && Object.keys(extraProps).length > 0 && ( + + + + + + )} + + ); +} + +export function AccessTable({ entries, lookups, search }: Props) { + const filtered = useMemo(() => { + if (!search) return entries; + return entries.filter(e => + matchesSearch(search, + e.id, + ...(e.external_user_aliases || []), + ...(e.external_role_aliases || []), + ...(e.external_group_aliases || []), + ) + ); + }, [entries, search]); + const { sorted, sort, toggle } = useSort(filtered); + + if (!entries || entries.length === 0) { + return
No config access records
; + } + + return ( +
+ + + + {COLS.map(c => ( + + ))} + + + + {sorted.map((e, i) => )} + +
toggle(c.key)}> + {c.label} +
+
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/AliasList.tsx b/cmd/scrapeui/frontend/src/components/AliasList.tsx new file mode 100644 index 000000000..4101827de --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/AliasList.tsx @@ -0,0 +1,26 @@ +interface Props { + aliases?: string[]; +} + +export function AliasList({ aliases }: Props) { + if (!aliases || aliases.length === 0) return null; + return ( +
    + {aliases.map((alias, i) => ( +
  • + {alias} + +
  • + ))} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/AnsiHtml.tsx b/cmd/scrapeui/frontend/src/components/AnsiHtml.tsx new file mode 100644 index 000000000..69cf2789a --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/AnsiHtml.tsx @@ -0,0 +1,60 @@ +const ANSI_COLORS: Record = { + '30': 'color:#1e1e1e', '31': 'color:#cd3131', '32': 'color:#0dbc79', + '33': 'color:#e5e510', '34': 'color:#2472c8', '35': 'color:#bc3fbc', + '36': 'color:#11a8cd', '37': 'color:#e5e5e5', + '90': 'color:#666', '91': 'color:#f14c4c', '92': 'color:#23d18b', + '93': 'color:#f5f543', '94': 'color:#3b8eea', '95': 'color:#d670d6', + '96': 'color:#29b8db', '97': 'color:#fff', + '1': 'font-weight:bold', '2': 'opacity:0.7', '3': 'font-style:italic', + '4': 'text-decoration:underline', +}; + +interface Span { + text: string; + style: string; +} + +function parseAnsi(raw: string): Span[] { + const spans: Span[] = []; + const re = /\x1b\[([0-9;]*)m/g; + let last = 0; + let styles: string[] = []; + let match; + + while ((match = re.exec(raw)) !== null) { + if (match.index > last) { + spans.push({ text: raw.slice(last, match.index), style: styles.join(';') }); + } + const codes = match[1].split(';').filter(Boolean); + for (const code of codes) { + if (code === '0' || code === '') { + styles = []; + } else if (ANSI_COLORS[code]) { + styles.push(ANSI_COLORS[code]); + } + } + last = match.index + match[0].length; + } + + if (last < raw.length) { + spans.push({ text: raw.slice(last), style: styles.join(';') }); + } + + return spans; +} + +interface Props { + text: string; + class?: string; +} + +export function AnsiHtml({ text, class: className }: Props) { + const spans = parseAnsi(text); + return ( +
+      {spans.map((s, i) =>
+        s.style ? {s.text} : s.text
+      )}
+    
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/ConfigNode.tsx b/cmd/scrapeui/frontend/src/components/ConfigNode.tsx new file mode 100644 index 000000000..aff9b0afc --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/ConfigNode.tsx @@ -0,0 +1,64 @@ +import type { ScrapeResult } from '../types'; +import { healthIcon, healthColor } from '../utils'; + +export interface ConfigItemCounts { + changes: number; + access: number; + accessLogs: number; + analysis: number; + relationships: number; +} + +interface Props { + item: ScrapeResult; + selected: ScrapeResult | null; + onSelect: (item: ScrapeResult) => void; + counts?: ConfigItemCounts; +} + +function Badge({ count, color, label }: { count: number; color: string; label: string }) { + if (count === 0) return null; + return {count}; +} + +function StatusDot({ color, title }: { color: string; title: string }) { + return ; +} + +export function ConfigNode({ item, selected, onSelect, counts }: Props) { + const isSelected = selected?.id === item.id && selected?.config_type === item.config_type; + const isDeleted = !!item.deleted_at; + const isNew = item.Action === 'inserted' || (!item.Action && !!item.created_at); + const isUpdated = item.Action === 'updated'; + + return ( +
onSelect(item)} + > + + {isNew && } + {isUpdated && } + {isDeleted && } + + {item.name || item.id} + + {counts && ( +
+ + + + + +
+ )} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/ConfigTree.tsx b/cmd/scrapeui/frontend/src/components/ConfigTree.tsx new file mode 100644 index 000000000..7e4161269 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/ConfigTree.tsx @@ -0,0 +1,74 @@ +import { useState, useEffect, useRef } from 'preact/hooks'; +import type { ScrapeResult, TypeGroup } from '../types'; +import { typeIcon } from '../utils'; +import { ConfigNode, type ConfigItemCounts } from './ConfigNode'; + +interface Props { + groups: TypeGroup[]; + selected: ScrapeResult | null; + onSelect: (item: ScrapeResult) => void; + expandAll: boolean | null; + configCounts?: Map; +} + +function TypeGroupNode({ group, selected, onSelect, expandAll, configCounts }: { + group: TypeGroup; + selected: ScrapeResult | null; + onSelect: (item: ScrapeResult) => void; + expandAll: boolean | null; + configCounts?: Map; +}) { + const [open, setOpen] = useState(true); + const prevExpandAll = useRef(expandAll); + + useEffect(() => { + if (expandAll !== null && expandAll !== prevExpandAll.current) { + setOpen(expandAll); + } + prevExpandAll.current = expandAll; + }, [expandAll]); + + return ( +
+
setOpen(!open)} + > + {open ? '▼' : '▶'} + + {group.type} + {group.items.length} +
+ {open && ( +
+ {group.items.map(item => ( + + ))} +
+ )} +
+ ); +} + +export function ConfigTree({ groups, selected, onSelect, expandAll, configCounts }: Props) { + return ( +
+ {groups.map(group => ( + + ))} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/DetailPanel.tsx b/cmd/scrapeui/frontend/src/components/DetailPanel.tsx new file mode 100644 index 000000000..e8fa92fa6 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/DetailPanel.tsx @@ -0,0 +1,429 @@ +import { useState, useMemo } from 'preact/hooks'; +import type { ScrapeResult, ConfigChange, UIRelationship, ConfigMeta, ExternalConfigAccess, ExternalConfigAccessLog, ExternalUser, ExternalGroup, ExternalRole } from '../types'; +import { healthIcon, healthColor, type Lookups, resolve } from '../utils'; +import { JsonView } from './JsonView'; +import { AliasList } from './AliasList'; + +type EntityKind = 'users' | 'groups' | 'roles'; + +interface Props { + item: ScrapeResult | null; + changes?: ConfigChange[]; + relationships?: UIRelationship[]; + configMeta?: Record; + access?: ExternalConfigAccess[]; + accessLogs?: ExternalConfigAccessLog[]; + allUsers?: ExternalUser[]; + allGroups?: ExternalGroup[]; + allRoles?: ExternalRole[]; + lookups: Lookups; + // Optional navigate callback. When provided, entity badges become clickable + // links that navigate to /users/{id}, /groups/{id}, /roles/{id} via the + // SPA router. When omitted, badges fall back to plain spans. + onNavigate?: (kind: EntityKind, id: string) => void; +} + +function LabelBadges({ labels, color }: { labels?: Record; color: string }) { + if (!labels) return null; + const entries = Object.entries(labels); + if (entries.length === 0) return null; + return ( +
+ {entries.map(([k, v]) => ( + {k}={v} + ))} +
+ ); +} + +// matchesConfig decides whether a config_access (or access_log) row belongs +// to a given config item. Some scrapers populate the nested +// external_config_id struct (most ADO scrapers), others populate the +// sibling top-level config_id field directly (e.g. AAD enterprise apps). +// Some scrapers normalize IDs into a path form while others use a UUID +// form. We check every plausible identifier shape so the match is +// resilient to any of these patterns. +function matchesConfig( + a: { external_config_id?: any; config_id?: string }, + item: { id: string; aliases?: string[] }, +): boolean { + const itemKeys = new Set(); + itemKeys.add(item.id); + for (const alias of item.aliases || []) itemKeys.add(alias); + + const ext = a.external_config_id; + if (ext) { + if (typeof ext === 'string') { + if (itemKeys.has(ext)) return true; + } else if (typeof ext === 'object') { + if (ext.external_id && itemKeys.has(ext.external_id)) return true; + if (ext.config_id && itemKeys.has(ext.config_id)) return true; + } + } + if (a.config_id && itemKeys.has(a.config_id)) return true; + return false; +} + +function Expandable({ summary, data, color }: { summary: any; data: any; color: string }) { + const [open, setOpen] = useState(false); + return ( +
+
setOpen(!open)}> + {open ? '▼' : '▶'} +
{summary}
+
+ {open && ( +
+ +
+ )} +
+ ); +} + +// resolveEntityID maps an alias-or-id back to the canonical entity .id by +// scanning the entity list. The badges in the Access section receive an +// alias from the access row (which may differ from the entity's primary id), +// so we resolve it before building the navigation URL — otherwise the +// /users/{id} route wouldn't match anything in the entity tab. +function resolveEntityID( + entities: T[] | undefined, + aliasOrId: string, +): string { + if (!entities || !aliasOrId) return aliasOrId; + for (const e of entities) { + if (e.id === aliasOrId) return e.id; + if (e.aliases?.includes(aliasOrId)) return e.id; + } + return aliasOrId; +} + +interface EntityBadgeProps { + kind: EntityKind; + prefix: string; + aliasOrId: string; + display: string; + colorClass: string; + entities?: { id: string; aliases?: string[] }[]; + onNavigate?: (kind: EntityKind, id: string) => void; +} + +function EntityBadge({ kind, prefix, aliasOrId, display, colorClass, entities, onNavigate }: EntityBadgeProps) { + const canonicalId = resolveEntityID(entities, aliasOrId); + const href = `/${kind}/${encodeURIComponent(canonicalId)}`; + if (!onNavigate) { + return {prefix}{display}; + } + return ( + { + e.preventDefault(); + e.stopPropagation(); + onNavigate(kind, canonicalId); + }} + class={`px-1.5 py-0.5 rounded ${colorClass} hover:brightness-95 no-underline cursor-pointer`} + >{prefix}{display} + ); +} + +function Section({ title, count, children, defaultOpen = true }: { title: string; count?: number; children: any; defaultOpen?: boolean }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+

setOpen(!open)} + > + {open ? '▼' : '▶'} + {title}{count !== undefined && ` (${count})`} +

+ {open && children} +
+ ); +} + +export function DetailPanel({ item, changes, relationships, configMeta, access, accessLogs, allUsers, allGroups, allRoles, lookups, onNavigate }: Props) { + const itemChanges = useMemo(() => { + if (!item || !changes) return []; + return changes.filter(ch => ch.source?.includes(item.id)); + }, [item, changes]); + + const itemRelationships = useMemo(() => { + if (!item || !relationships) return []; + return relationships.filter(r => r.config_id === item.id || r.related_id === item.id); + }, [item, relationships]); + + const itemAccess = useMemo(() => { + if (!item || !access) return []; + return access.filter(a => matchesConfig(a, item)); + }, [item, access]); + + const itemAccessLogs = useMemo(() => { + if (!item || !accessLogs) return []; + return accessLogs.filter(a => matchesConfig(a, item)); + }, [item, accessLogs]); + + if (!item) { + return ( +
+ Select a config item to view details +
+ ); + } + + return ( +
+
+ +
+
+

{item.name || item.id}

+ + + + +
+
+ {item.config_type} + {item.config_class && ({item.config_class})} + {item.status && ( + {item.status} + )} + {(item.Action === 'inserted' || (!item.Action && item.created_at)) && ( + New + )} + {item.Action === 'updated' && ( + Updated + )} + {item.deleted_at && ( + + Deleted{item.delete_reason ? `: ${item.delete_reason}` : ''} + + )} +
+
+
+ +
ID: {item.id}
+ + {/* Metadata: parents, location, timestamps */} +
+ {configMeta?.[item.id]?.parents && configMeta[item.id].parents!.length > 0 && ( +
+ + {configMeta[item.id].parents!.join(' → ')} +
+ )} + {(configMeta?.[item.id]?.location || (item.locations && item.locations.length > 0)) && ( +
+ + {configMeta?.[item.id]?.location || item.locations!.join(', ')} +
+ )} + {(item.created_at || item.last_modified) && ( +
+ {item.created_at && Created: {item.created_at}} + {item.last_modified && item.last_modified !== '0001-01-01T00:00:00Z' && Modified: {item.last_modified}} + {item.deleted_at && Deleted: {item.deleted_at}} +
+ )} +
+ + + + + {item.aliases && item.aliases.length > 0 && ( +
+ +
+ )} + + {item.analysis && ( +
+
Analysis
+ +
+ )} + + {/* Relationships */} + {itemRelationships.length > 0 && ( +
+
+ {itemRelationships.map((rel, i) => { + const isOutgoing = rel.config_id === item.id; + const targetId = isOutgoing ? rel.related_id : rel.config_id; + const targetName = isOutgoing + ? (rel.related_name || lookups.configs.get(targetId) || targetId) + : (rel.config_name || lookups.configs.get(targetId) || targetId); + const resolvedLabel = lookups.configs.get(targetId); + const targetType = resolvedLabel?.match(/\(([^)]+)\)$/)?.[1]; + return ( +
+ + {(targetType || rel.relation) && ( + {targetType || rel.relation} + )} + {targetName} + {isOutgoing ? 'outgoing' : 'incoming'} +
+ ); + })} +
+
+ )} + + {/* Changes */} + {itemChanges.length > 0 && ( +
+
+ {itemChanges.map((ch, i) => ( + + {ch.change_type} + {(ch.resolved?.action || ch.action) && ( + {ch.resolved?.action || ch.action} + )} + {ch.severity && {ch.severity}} + {ch.summary && {ch.summary}} + {ch.created_at && {ch.created_at}} +
+ } + /> + ))} +
+ + )} + + {/* Config Access */} + {itemAccess.length > 0 && ( +
+
+ {itemAccess.map((a, i) => ( + + {(a.external_user_aliases?.length ? a.external_user_aliases : a.external_user_id ? [a.external_user_id] : []).map((u, j) => ( + + ))} + {(a.external_role_aliases?.length ? a.external_role_aliases : a.external_role_id ? [a.external_role_id] : []).map((r, j) => ( + + ))} + {(a.external_group_aliases?.length ? a.external_group_aliases : a.external_group_id ? [a.external_group_id] : []).map((g, j) => ( + + ))} + {a.created_at && {a.created_at}} +
+ } + /> + ))} + +
+ )} + + {/* Access Logs */} + {itemAccessLogs.length > 0 && ( +
+
+ {itemAccessLogs.map((a, i) => ( + + {a.external_user_aliases?.map((u, j) => ( + + ))} + {a.mfa !== undefined && ( + MFA: {a.mfa ? 'Yes' : 'No'} + )} + {a.count != null && x{a.count}} + {a.created_at && {a.created_at}} +
+ } + /> + ))} + +
+ )} + + {/* Config JSON */} + {item.config && ( +
+
+ {typeof item.config === 'string' ? ( +
{item.config}
+ ) : ( + + )} +
+
+ )} + + ); +} diff --git a/cmd/scrapeui/frontend/src/components/EntityTable.tsx b/cmd/scrapeui/frontend/src/components/EntityTable.tsx new file mode 100644 index 000000000..975ddf650 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/EntityTable.tsx @@ -0,0 +1,293 @@ +import { useState, useMemo } from 'preact/hooks'; +import type { ExternalConfigAccess, ExternalConfigAccessLog, ExternalUserGroup, ExternalUser, ExternalGroup } from '../types'; +import { useSort, SortIcon } from '../hooks/useSort'; +import { type Lookups, resolveConfigId, resolve, matchesSearch } from '../utils'; +import { AliasList } from './AliasList'; + +interface Entity { + id: string; + name: string; + aliases?: string[]; + account_id?: string; + user_type?: string; +} + +interface Props { + title: string; + kind: 'user' | 'group' | 'role'; + entities: Entity[]; + access?: ExternalConfigAccess[]; + accessLogs?: ExternalConfigAccessLog[]; + userGroups?: ExternalUserGroup[]; + allUsers?: ExternalUser[]; + allGroups?: ExternalGroup[]; + lookups: Lookups; + search?: string; + selectedId?: string; + onSelect?: (id: string | undefined) => void; +} + +function entityAliases(e: Entity): string[] { + return [e.name, ...(e.aliases || [])].filter(Boolean); +} + +function Section({ title, count, children, defaultOpen = true }: { title: string; count?: number; children: any; defaultOpen?: boolean }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+

setOpen(!open)} + > + {open ? '▼' : '▶'} + {title}{count !== undefined && ` (${count})`} +

+ {open && children} +
+ ); +} + +function matchesEntity(kind: string, aliases: string[], access: ExternalConfigAccess): boolean { + const targets = kind === 'user' ? access.external_user_aliases + : kind === 'group' ? access.external_group_aliases + : access.external_role_aliases; + if (targets?.some(t => aliases.includes(t))) return true; + // Fall back to ID-based matching + const id = kind === 'user' ? access.external_user_id + : kind === 'group' ? access.external_group_id + : access.external_role_id; + return !!id && aliases.includes(id); +} + +function matchesEntityLog(aliases: string[], log: ExternalConfigAccessLog): boolean { + return log.external_user_aliases?.some(t => aliases.includes(t)) || false; +} + +function columnsFor(kind: 'user' | 'group' | 'role'): { key: string; label: string; cls: string }[] { + const base = [ + { key: 'name', label: 'Name', cls: 'px-3 py-2' }, + { key: 'account_id', label: 'Account', cls: 'px-3 py-2' }, + ]; + if (kind === 'role') base.splice(1, 0, { key: 'aliases', label: 'Aliases', cls: 'px-3 py-2' }); + if (kind === 'user') base.push({ key: 'groups', label: 'Groups', cls: 'px-3 py-2' }); + if (kind === 'group') base.push({ key: 'members', label: 'Members', cls: 'px-3 py-2' }); + return base; +} + +export function EntityTable({ title, kind, entities, access, accessLogs, userGroups, allUsers, allGroups, lookups, search, selectedId, onSelect }: Props) { + const filtered = useMemo(() => { + if (!search) return entities; + return entities.filter(e => matchesSearch(search, e.name, ...(e.aliases || []))); + }, [entities, search]); + const { sorted, sort, toggle } = useSort(filtered, 'name'); + const cols = columnsFor(kind); + + // Resolve a v1.ExternalUserGroup to a (userId, groupId) pair using direct + // IDs when present, falling back to alias overlap against the entity lists + // we already have. This handles Azure DevOps memberships, which describe + // identities by descriptor alias rather than by Azure UUID. + const resolveMembership = (ug: ExternalUserGroup): { userId?: string; groupId?: string } => { + let userId = ug.external_user_id; + if (!userId && ug.external_user_aliases?.length && allUsers) { + const u = allUsers.find(x => ug.external_user_aliases!.some(a => a === x.id || x.aliases?.includes(a))); + if (u) userId = u.id; + } + let groupId = ug.external_group_id; + if (!groupId && ug.external_group_aliases?.length && allGroups) { + const g = allGroups.find(x => ug.external_group_aliases!.some(a => a === x.id || x.aliases?.includes(a))); + if (g) groupId = g.id; + } + return { userId, groupId }; + }; + + const resolvedUserGroups = useMemo(() => { + if (!userGroups) return []; + return userGroups.map(resolveMembership).filter(r => r.userId && r.groupId) as { userId: string; groupId: string }[]; + }, [userGroups, allUsers, allGroups]); + + // Count memberships per entity for list display + const membershipCounts = useMemo(() => { + const m: Record = {}; + for (const ug of resolvedUserGroups) { + if (kind === 'user') m[ug.userId] = (m[ug.userId] || 0) + 1; + if (kind === 'group') m[ug.groupId] = (m[ug.groupId] || 0) + 1; + } + return m; + }, [resolvedUserGroups, kind]); + + const selected = useMemo( + () => entities.find(e => e.id === selectedId) || null, + [entities, selectedId], + ); + + const selectedAliases = useMemo( + () => selected ? entityAliases(selected) : [], + [selected], + ); + + const relatedAccess = useMemo(() => { + if (!selected || !access) return []; + return access.filter(a => matchesEntity(kind, selectedAliases, a)); + }, [selected, access, selectedAliases, kind]); + + const relatedLogs = useMemo(() => { + if (!selected || !accessLogs || kind !== 'user') return []; + return accessLogs.filter(a => matchesEntityLog(selectedAliases, a)); + }, [selected, accessLogs, selectedAliases, kind]); + + // For a user: find groups they belong to + const userMemberships = useMemo(() => { + if (!selected || kind !== 'user' || !allGroups) return []; + const groupIds = new Set( + resolvedUserGroups + .filter(ug => ug.userId === selected.id) + .map(ug => ug.groupId), + ); + return allGroups.filter(g => groupIds.has(g.id)); + }, [selected, kind, resolvedUserGroups, allGroups]); + + // For a group: find users that are members + const groupMembers = useMemo(() => { + if (!selected || kind !== 'group' || !allUsers) return []; + const userIds = new Set( + resolvedUserGroups + .filter(ug => ug.groupId === selected.id) + .map(ug => ug.userId), + ); + return allUsers.filter(u => userIds.has(u.id)); + }, [selected, kind, resolvedUserGroups, allUsers]); + + if (!entities || entities.length === 0) { + return
No {title.toLowerCase()} found
; + } + + return ( +
+ {/* Entity list */} +
+ + + + {cols.map(c => ( + + ))} + + + + {sorted.map((e, idx) => ( + onSelect?.(selectedId === e.id ? undefined : e.id)} + > + + {kind === 'role' && ( + + )} + + {(kind === 'user' || kind === 'group') && ( + + )} + + ))} + +
toggle(c.key)}> + {c.label} +
{e.name} + + {e.account_id || ''} + {membershipCounts[e.id] ? ( + + {membershipCounts[e.id]} + + ) : ( + 0 + )} +
+
+ + {/* Detail pane */} +
+ {!selected ? ( +
+ Select a {kind} to view access details +
+ ) : ( +
+
+

{selected.name}

+
{selected.id}
+
+ + {selected.aliases && selected.aliases.length > 0 && ( +
+ +
+ )} + + {kind === 'user' && userMemberships.length > 0 && ( +
+
+ {userMemberships.map(g => ( + {g.name || g.id} + ))} +
+
+ )} + + {kind === 'group' && groupMembers.length > 0 && ( +
+
+ {groupMembers.map(u => ( + {u.name || u.id} + ))} +
+
+ )} + + {relatedAccess.length > 0 && ( +
+
+ {relatedAccess.map((a, i) => ( +
+
{resolveConfigId(lookups, a.external_config_id)}
+
+ {(a.external_role_aliases?.length ? a.external_role_aliases : a.external_role_id ? [a.external_role_id] : []).map((r, j) => ( + {resolve(lookups.roles, r)} + ))} +
+ {a.created_at && {a.created_at}} +
+ ))} +
+
+ )} + + {relatedLogs.length > 0 && ( +
+
+ {relatedLogs.map((a, i) => ( +
+ {resolveConfigId(lookups, a.external_config_id)} + {a.mfa !== undefined && ( + MFA: {a.mfa ? 'Yes' : 'No'} + )} + {a.count != null && x{a.count}} + {a.created_at && {a.created_at}} +
+ ))} +
+
+ )} + + {relatedAccess.length === 0 && relatedLogs.length === 0 && userMemberships.length === 0 && groupMembers.length === 0 && ( +
No access records for this {kind}
+ )} +
+ )} +
+
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/FilterBar.tsx b/cmd/scrapeui/frontend/src/components/FilterBar.tsx new file mode 100644 index 000000000..182368efb --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/FilterBar.tsx @@ -0,0 +1,74 @@ +export interface Filters { + health: Set; + type: Set; +} + +interface Props { + filters: Filters; + onChange: (f: Filters) => void; + healthValues: string[]; + typeValues: string[]; +} + +function toggle(set: Set, val: string): Set { + const next = new Set(set); + if (next.has(val)) next.delete(val); + else next.add(val); + return next; +} + +const HEALTH_COLORS: Record = { + healthy: 'bg-green-100 text-green-700 border-green-300', + unhealthy: 'bg-red-100 text-red-700 border-red-300', + warning: 'bg-yellow-100 text-yellow-700 border-yellow-300', + unknown: 'bg-gray-100 text-gray-600 border-gray-300', +}; + +export function FilterBar({ filters, onChange, healthValues, typeValues }: Props) { + if (healthValues.length === 0 && typeValues.length === 0) return null; + + return ( +
+ {healthValues.map(h => { + const active = filters.health.has(h); + const colors = HEALTH_COLORS[h] || HEALTH_COLORS['unknown']; + return ( + + ); + })} + {healthValues.length > 0 && typeValues.length > 0 && ( + | + )} + {typeValues.map(t => { + const active = filters.type.has(t); + return ( + + ); + })} + {(filters.health.size > 0 || filters.type.size > 0) && ( + + )} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/HARPanel.tsx b/cmd/scrapeui/frontend/src/components/HARPanel.tsx new file mode 100644 index 000000000..852ef17ed --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/HARPanel.tsx @@ -0,0 +1,139 @@ +import { useState, useMemo } from 'preact/hooks'; +import type { HAREntry } from '../types'; +import { statusColor, matchesSearch } from '../utils'; +import { useSort, SortIcon } from '../hooks/useSort'; +import { JsonView } from './JsonView'; + +interface Props { + entries: HAREntry[]; + search?: string; +} + +function tryParseJson(text: string): any | null { + try { return JSON.parse(text); } catch { return null; } +} + +function isJsonType(mime?: string): boolean { + return !!mime && (mime.includes('json') || mime.includes('javascript')); +} + +function BodyView({ text, mimeType }: { text: string; mimeType?: string }) { + if (isJsonType(mimeType)) { + const parsed = tryParseJson(text); + if (parsed !== null) return ; + } + return
{text}
; +} + +function HARRow({ entry }: { entry: HAREntry }) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(!open)} + > + {entry.request.method} + + {entry.request.url} + + + {entry.response.status} + + {entry.time.toFixed(0)}ms + {formatBytes(entry.response.bodySize)} + {entry.response.content?.mimeType || ''} + + {open && ( + + +
+
+
Request Headers
+
+ {entry.request.headers?.map((h, i) => ( +
{h.name}: {h.value}
+ ))} +
+ {entry.request.postData?.text && ( +
+
Request Body
+
+ +
+
+ )} +
+
+
Response Headers
+
+ {entry.response.headers?.map((h, i) => ( +
{h.name}: {h.value}
+ ))} +
+
+
+ {entry.response.content?.text && ( +
+
Response Body
+
+ +
+
+ )} + + + )} + + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 0) return ''; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +const COLS: { key: string; label: string; cls: string }[] = [ + { key: 'request.method', label: 'Method', cls: 'px-2 py-2 w-16' }, + { key: 'request.url', label: 'URL', cls: 'px-2 py-2' }, + { key: 'response.status', label: 'Status', cls: 'px-2 py-2 w-20' }, + { key: 'time', label: 'Time', cls: 'px-2 py-2 w-16 text-right' }, + { key: 'response.bodySize', label: 'Size', cls: 'px-2 py-2 w-16 text-right' }, + { key: 'response.content.mimeType', label: 'Type', cls: 'px-2 py-2 w-40' }, +]; + +export function HARPanel({ entries, search }: Props) { + const filtered = useMemo(() => { + if (!search) return entries; + return entries.filter(e => + matchesSearch(search, e.request.url, e.request.method, e.request.postData?.text, e.response.content?.text) + ); + }, [entries, search]); + const { sorted, sort, toggle } = useSort(filtered, 'time'); + + if (!entries || entries.length === 0) { + return
No HTTP traffic captured
; + } + + return ( +
+ + + + {COLS.map(c => ( + + ))} + + + + {sorted.map((e, i) => )} + +
toggle(c.key)}> + {c.label} +
+
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/JsonView.tsx b/cmd/scrapeui/frontend/src/components/JsonView.tsx new file mode 100644 index 000000000..868e7aa90 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/JsonView.tsx @@ -0,0 +1,64 @@ +import { useState } from 'preact/hooks'; + +interface Props { + data: any; + name?: string; + depth?: number; +} + +export function JsonView({ data, name, depth = 0 }: Props) { + const [open, setOpen] = useState(depth < 2); + + if (data === null || data === undefined) { + return null; + } + + if (typeof data === 'string') { + return "{data}"; + } + + if (typeof data === 'number' || typeof data === 'boolean') { + return {String(data)}; + } + + const isArray = Array.isArray(data); + const entries = isArray ? data.map((v: any, i: number) => [i, v]) : Object.entries(data); + const bracket = isArray ? ['[', ']'] : ['{', '}']; + + if (entries.length === 0) { + return {bracket[0]}{bracket[1]}; + } + + return ( +
0 ? '12px' : '0' }}> + setOpen(!open)} + > + {open ? '▼' : '▶'} + {name && {name}} + {name && : } + {!open && {bracket[0]} {entries.length} {isArray ? 'items' : 'keys'} {bracket[1]}} + {open && {bracket[0]}} + + {open && ( + <> + {entries.map(([key, val]: [any, any]) => ( +
+ {typeof val === 'object' && val !== null ? ( + + ) : ( +
+ {isArray ? '' : String(key)} + {!isArray && : } + +
+ )} +
+ ))} + {bracket[1]} + + )} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/ScrapeConfigPanel.tsx b/cmd/scrapeui/frontend/src/components/ScrapeConfigPanel.tsx new file mode 100644 index 000000000..f084ace7c --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/ScrapeConfigPanel.tsx @@ -0,0 +1,160 @@ +import { useState, useMemo } from 'preact/hooks'; +import { JsonView } from './JsonView'; +import type { PropertyInfo, LogLevelInfo } from '../types'; + +interface Props { + spec: any; + properties?: Record; + logLevel?: LogLevelInfo; +} + +function formatValue(val: any, type?: string): string { + if (val === null || val === undefined) return ''; + if (type === 'duration' && typeof val === 'number') { + // Go's time.Duration serializes as nanoseconds + const ms = val / 1e6; + if (ms < 1000) return `${ms}ms`; + const secs = ms / 1000; + if (secs < 60) return `${secs}s`; + const mins = secs / 60; + if (mins < 60) return `${mins}m`; + return `${mins / 60}h`; + } + if (type === 'bool') return val ? 'on' : 'off'; + return String(val); +} + +function isOverridden(prop: PropertyInfo): boolean { + if (prop.value === null || prop.value === undefined) return false; + return String(prop.value) !== String(prop.default); +} + +const typeBadgeColors: Record = { + bool: 'bg-purple-100 text-purple-700', + int: 'bg-blue-100 text-blue-700', + duration: 'bg-teal-100 text-teal-700', + string: 'bg-gray-100 text-gray-600', +}; + +export function ScrapeConfigPanel({ spec, properties, logLevel }: Props) { + const [propFilter, setPropFilter] = useState(''); + + const sortedProps = useMemo(() => { + if (!properties) return []; + return Object.entries(properties) + .map(([key, info]) => ({ key, ...info })) + .sort((a, b) => a.key.localeCompare(b.key)); + }, [properties]); + + const filteredProps = useMemo(() => { + if (!propFilter) return sortedProps; + const q = propFilter.toLowerCase(); + return sortedProps.filter(p => + p.key.toLowerCase().includes(q) || + formatValue(p.value, p.type).toLowerCase().includes(q) + ); + }, [sortedProps, propFilter]); + + const hasContent = spec || (sortedProps.length > 0) || logLevel; + if (!hasContent) { + return
No scrape configuration available
; + } + + return ( +
+ {/* Log Levels */} + {logLevel && (logLevel.scraper || logLevel.global) && ( +
+

Log Level

+
+ {logLevel.scraper && ( + + + Scraper: {logLevel.scraper} + + )} + {logLevel.global && ( + + + Global: {logLevel.global} + + )} +
+
+ )} + + {/* Properties Table */} + {sortedProps.length > 0 && ( +
+
+

+ Properties + ({sortedProps.length}) +

+
+ + setPropFilter((e.target as HTMLInputElement).value)} + class="pl-6 pr-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 w-48" + /> +
+
+
+ + + + + + + + + + + {filteredProps.map(prop => { + const overridden = isOverridden(prop); + return ( + + + + + + + ); + })} + {filteredProps.length === 0 && ( + + )} + +
KeyValueDefaultType
{prop.key} + {formatValue(prop.value, prop.type) || } + + {formatValue(prop.default, prop.type)} + + {prop.type && ( + + {prop.type} + + )} +
No matching properties
+
+
+ )} + + {/* Scrape Configuration */} + {spec && ( +
+

Scrape Configuration

+
+ +
+
+ )} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/ScraperList.tsx b/cmd/scrapeui/frontend/src/components/ScraperList.tsx new file mode 100644 index 000000000..64cfdc8a0 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/ScraperList.tsx @@ -0,0 +1,44 @@ +import type { ScraperProgress } from '../types'; + +interface Props { + scrapers: ScraperProgress[]; +} + +function statusIcon(status: ScraperProgress['status']): string { + switch (status) { + case 'pending': return 'codicon:circle-outline'; + case 'running': return 'svg-spinners:ring-resize'; + case 'complete': return 'codicon:pass-filled'; + case 'error': return 'codicon:error'; + } +} + +function statusColor(status: ScraperProgress['status']): string { + switch (status) { + case 'pending': return 'text-gray-400'; + case 'running': return 'text-blue-500'; + case 'complete': return 'text-green-500'; + case 'error': return 'text-red-500'; + } +} + +export function ScraperList({ scrapers }: Props) { + if (!scrapers || scrapers.length === 0) return null; + + return ( +
+ {scrapers.map(s => ( +
+ + {s.name} + {s.result_count > 0 && ( + ({s.result_count}) + )} + {(s.duration_secs ?? 0) > 0 && ( + {(s.duration_secs as number).toFixed(1)}s + )} +
+ ))} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/SnapshotPanel.tsx b/cmd/scrapeui/frontend/src/components/SnapshotPanel.tsx new file mode 100644 index 000000000..07abea5fd --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/SnapshotPanel.tsx @@ -0,0 +1,204 @@ +import { useMemo, useState } from 'preact/hooks'; +import type { EntityWindowCounts, ScrapeSnapshot, ScrapeSnapshotDiff, ScrapeSnapshotPair } from '../types'; + +interface Props { + pairs?: Record; +} + +type View = 'diff' | 'after' | 'before'; + +const ZERO: EntityWindowCounts = { + total: 0, + updated_last: 0, updated_hour: 0, updated_day: 0, updated_week: 0, + deleted_last: 0, deleted_hour: 0, deleted_day: 0, deleted_week: 0, +}; + +function isZero(c?: EntityWindowCounts): boolean { + if (!c) return true; + return c.total === 0 && + c.updated_last === 0 && c.updated_hour === 0 && c.updated_day === 0 && c.updated_week === 0 && + c.deleted_last === 0 && c.deleted_hour === 0 && c.deleted_day === 0 && c.deleted_week === 0 && + !c.last_created_at && !c.last_updated_at; +} + +function fmtTime(ts?: string): string { + if (!ts) return ''; + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function signed(n: number): string { + if (n > 0) return `+${n}`; + return `${n}`; +} + +function cls(n: number, isDiff: boolean): string { + if (!isDiff) return 'text-gray-700'; + if (n > 0) return 'text-green-600 font-medium'; + if (n < 0) return 'text-red-600 font-medium'; + return 'text-gray-400'; +} + +function fmt(n: number, isDiff: boolean): string { + if (n === 0) return ''; + return isDiff ? signed(n) : String(n); +} + +const ENTITY_ROWS: { key: keyof ScrapeSnapshot & keyof ScrapeSnapshotDiff; label: string }[] = [ + { key: 'external_users', label: 'External Users' }, + { key: 'external_groups', label: 'External Groups' }, + { key: 'external_roles', label: 'External Roles' }, + { key: 'external_user_groups', label: 'External User Groups' }, + { key: 'config_access', label: 'Config Access' }, + { key: 'config_access_logs', label: 'Access Logs' }, +]; + +function CountsRow({ label, counts, isDiff }: { label: string; counts: EntityWindowCounts; isDiff: boolean }) { + return ( + + {label} + {fmt(counts.total, isDiff)} + {fmt(counts.updated_last, isDiff)} + {fmt(counts.updated_hour, isDiff)} + {fmt(counts.updated_day, isDiff)} + {fmt(counts.updated_week, isDiff)} + {fmt(counts.deleted_last, isDiff)} + {fmt(counts.deleted_hour, isDiff)} + {fmt(counts.deleted_day, isDiff)} + {fmt(counts.deleted_week, isDiff)} + {fmtTime(counts.last_created_at)} + {fmtTime(counts.last_updated_at)} + + ); +} + +function CountsTable({ title, rows, isDiff }: { title: string; rows: { label: string; counts: EntityWindowCounts }[]; isDiff: boolean }) { + if (rows.length === 0) { + return null; + } + return ( +
+

{title}

+ + + + + + + + + + + + + {rows.map((r, i) => ( + + ))} + +
TotalUpdated (L / H / D / W)Deleted (L / H / D / W)Last CreatedLast Updated
+
+ ); +} + +function renderSection(data: ScrapeSnapshot | ScrapeSnapshotDiff | undefined, isDiff: boolean) { + if (!data) { + return
No data
; + } + + const perScraper = data.per_scraper || {}; + const perType = data.per_config_type || {}; + + const scraperRows = Object.keys(perScraper) + .sort() + .filter(k => !isZero(perScraper[k])) + .map(k => ({ label: k, counts: perScraper[k] })); + + const typeRows = Object.keys(perType) + .sort() + .filter(k => !isZero(perType[k])) + .map(k => ({ label: k, counts: perType[k] })); + + const entityRows = ENTITY_ROWS + .map(({ key, label }) => ({ label, counts: (data as any)[key] as EntityWindowCounts || ZERO })) + .filter(r => !isZero(r.counts)); + + const empty = scraperRows.length === 0 && typeRows.length === 0 && entityRows.length === 0; + if (empty && isDiff) { + return
No changes between before and after snapshots.
; + } + + return ( +
+ + + +
+ ); +} + +export function SnapshotPanel({ pairs }: Props) { + const scraperNames = useMemo(() => pairs ? Object.keys(pairs).sort() : [], [pairs]); + const [selectedScraper, setSelectedScraper] = useState(null); + const [userView, setUserView] = useState(null); + + const activeScraper = selectedScraper || scraperNames[0] || null; + const pair = activeScraper && pairs ? pairs[activeScraper] : undefined; + + // Default view picks the first side that actually has data: After for + // successful runs, Before when the scrape failed before reaching the post- + // save capture, Diff as a last resort. + const defaultView: View = pair?.after ? 'after' : pair?.before ? 'before' : 'diff'; + const view = userView ?? defaultView; + + if (!pairs || scraperNames.length === 0) { + return ( +
+ No scrape snapshot captured for this run. Snapshots are only captured when running with a database connection. +
+ ); + } + + const data = pair && (view === 'diff' ? pair.diff : view === 'after' ? pair.after : pair.before); + + return ( +
+
+ {scraperNames.length > 1 && ( + + )} +
+ {(['after', 'diff', 'before'] as View[]).map(v => { + const disabled = + (v === 'after' && !pair?.after) || + (v === 'before' && !pair?.before); + return ( + + ); + })} +
+ {(pair?.after || pair?.before) && ( +
+ run started at {new Date((pair.after || pair.before)!.run_started_at).toLocaleString()} + {!pair.after && (scrape failed — showing pre-scrape state)} +
+ )} +
+ {renderSection(data, view === 'diff')} +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/SplitPane.tsx b/cmd/scrapeui/frontend/src/components/SplitPane.tsx new file mode 100644 index 000000000..9a8dc07b5 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/SplitPane.tsx @@ -0,0 +1,56 @@ +import { useState, useRef, useCallback } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; + +interface Props { + left: ComponentChildren; + right: ComponentChildren; + defaultSplit?: number; + minLeft?: number; + minRight?: number; +} + +export function SplitPane({ left, right, defaultSplit = 50, minLeft = 20, minRight = 20 }: Props) { + const [split, setSplit] = useState(defaultSplit); + const dragging = useRef(false); + const container = useRef(null); + + const onMouseDown = useCallback((e: MouseEvent) => { + e.preventDefault(); + dragging.current = true; + + const onMove = (e: MouseEvent) => { + if (!dragging.current || !container.current) return; + const rect = container.current.getBoundingClientRect(); + const pct = ((e.clientX - rect.left) / rect.width) * 100; + setSplit(Math.max(minLeft, Math.min(100 - minRight, pct))); + }; + + const onUp = () => { + dragging.current = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [minLeft, minRight]); + + return ( +
+
+ {left} +
+
+
+ {right} +
+
+ ); +} diff --git a/cmd/scrapeui/frontend/src/components/Summary.tsx b/cmd/scrapeui/frontend/src/components/Summary.tsx new file mode 100644 index 000000000..d80522b48 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/Summary.tsx @@ -0,0 +1,51 @@ +import type { Counts, SaveSummary } from '../types'; +import { formatDuration } from '../utils'; + +interface Props { + counts: Counts; + saveSummary?: SaveSummary; + startedAt: number; + done: boolean; + elapsed: number; +} + +function Badge({ label, count, color }: { label: string; count: number; color: string }) { + if (count === 0) return null; + return ( + + {count} {label} + + ); +} + +export function Summary({ counts, saveSummary, done, elapsed }: Props) { + return ( +
+ + + + + + + {saveSummary && saveSummary.config_types && (() => { + let added = 0, updated = 0, unchanged = 0; + for (const v of Object.values(saveSummary.config_types)) { + added += v.added; + updated += v.updated; + unchanged += v.unchanged; + } + return ( + <> + {added > 0 && } + {updated > 0 && } + {unchanged > 0 && } + + ); + })()} + + + {done ? 'done' : 'running'} {formatDuration(elapsed)} + +
+ ); +} diff --git a/cmd/scrapeui/frontend/src/globals.d.ts b/cmd/scrapeui/frontend/src/globals.d.ts new file mode 100644 index 000000000..c99391822 --- /dev/null +++ b/cmd/scrapeui/frontend/src/globals.d.ts @@ -0,0 +1,3 @@ +// Build-time constants injected by vite via `define`. See vite.config.ts. +declare const __UI_BUILD_COMMIT__: string; +declare const __UI_BUILD_DATE__: string; diff --git a/cmd/scrapeui/frontend/src/hooks/useRoute.ts b/cmd/scrapeui/frontend/src/hooks/useRoute.ts new file mode 100644 index 000000000..1c21ddf55 --- /dev/null +++ b/cmd/scrapeui/frontend/src/hooks/useRoute.ts @@ -0,0 +1,74 @@ +import { useState, useEffect, useCallback } from 'preact/hooks'; +import type { Tab } from '../types'; + +export interface Route { + tab: Tab; + id?: string; + q?: string; +} + +const VALID_TABS: Tab[] = [ + 'configs', 'logs', 'har', 'users', 'groups', 'roles', + 'access', 'access_logs', 'issues', 'snapshot', 'last_summary', 'spec', +]; + +const DEFAULT_TAB: Tab = 'spec'; + +export function parseRoute(path: string, search: string): Route { + const segments = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); + const params = new URLSearchParams(search); + const q = params.get('q') || undefined; + + if (segments.length === 0) { + return { tab: DEFAULT_TAB, q }; + } + + const first = segments[0]; + if (VALID_TABS.includes(first as Tab)) { + return { + tab: first as Tab, + id: segments[1] ? decodeURIComponent(segments[1]) : undefined, + q, + }; + } + + return { tab: DEFAULT_TAB, q }; +} + +export function buildPath(route: Route): string { + let path = '/' + route.tab; + if (route.id) path += '/' + encodeURIComponent(route.id); + if (route.q) path += '?q=' + encodeURIComponent(route.q); + return path; +} + +export function useRoute(): [Route, (next: Partial) => void] { + const [route, setRoute] = useState(() => + parseRoute(location.pathname, location.search), + ); + + useEffect(() => { + const onPop = () => { + setRoute(parseRoute(location.pathname, location.search)); + }; + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); + }, []); + + const navigate = useCallback((next: Partial) => { + setRoute(prev => { + const merged: Route = { + tab: next.tab ?? prev.tab, + id: 'id' in next ? next.id : prev.id, + q: 'q' in next ? next.q : prev.q, + }; + const path = buildPath(merged); + if (location.pathname + location.search !== path) { + history.pushState(null, '', path); + } + return merged; + }); + }, []); + + return [route, navigate]; +} diff --git a/cmd/scrapeui/frontend/src/hooks/useSort.tsx b/cmd/scrapeui/frontend/src/hooks/useSort.tsx new file mode 100644 index 000000000..907241704 --- /dev/null +++ b/cmd/scrapeui/frontend/src/hooks/useSort.tsx @@ -0,0 +1,51 @@ +import { useState, useMemo } from 'preact/hooks'; + +export type SortDir = 'asc' | 'desc'; + +export interface SortState { + key: string; + dir: SortDir; +} + +export function useSort(items: T[], defaultKey?: string) { + const [sort, setSort] = useState( + defaultKey ? { key: defaultKey, dir: 'asc' } : null, + ); + + function toggle(key: string) { + setSort(prev => { + if (prev?.key === key) { + return prev.dir === 'asc' ? { key, dir: 'desc' } : null; + } + return { key, dir: 'asc' }; + }); + } + + const sorted = useMemo(() => { + if (!items) return []; + if (!sort) return items; + const { key, dir } = sort; + return [...items].sort((a, b) => { + const av = resolve(a, key); + const bv = resolve(b, key); + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av).localeCompare(String(bv)); + return dir === 'asc' ? cmp : -cmp; + }); + }, [items, sort]); + + return { sorted, sort, toggle }; +} + +function resolve(obj: any, path: string): any { + return path.split('.').reduce((o, k) => o?.[k], obj); +} + +export function SortIcon({ active, dir }: { active: boolean; dir?: SortDir }) { + if (!active) return ; + return {dir === 'asc' ? '↑' : '↓'}; +} diff --git a/cmd/scrapeui/frontend/src/index.tsx b/cmd/scrapeui/frontend/src/index.tsx new file mode 100644 index 000000000..678facec3 --- /dev/null +++ b/cmd/scrapeui/frontend/src/index.tsx @@ -0,0 +1,4 @@ +import { render } from 'preact'; +import { App } from './App'; + +render(, document.getElementById('root')!); diff --git a/cmd/scrapeui/frontend/src/types.ts b/cmd/scrapeui/frontend/src/types.ts new file mode 100644 index 000000000..9bf7ccdc7 --- /dev/null +++ b/cmd/scrapeui/frontend/src/types.ts @@ -0,0 +1,304 @@ +export interface ScraperProgress { + name: string; + status: 'pending' | 'running' | 'complete' | 'error'; + started_at?: string; + duration_secs?: number; + error?: string; + result_count: number; +} + +export interface ScrapeResult { + id: string; + name: string; + config_type: string; + config_class?: string; + status?: string; + health?: string; + icon?: string; + labels?: Record; + tags?: Record; + config?: any; + analysis?: any; + properties?: any[]; + description?: string; + source?: string; + aliases?: string[]; + locations?: string[]; + parents?: string[]; + created_at?: string; + deleted_at?: string; + delete_reason?: string; + last_modified?: string; + Action?: string; // "inserted" | "updated" | "unchanged" — uppercase key from Go json tag +} + +export interface ConfigChange { + change_type: string; + action?: string; + severity?: string; + source?: string; + summary?: string; + external_id?: string; + config_type?: string; + diff?: string; + patches?: string; + created_at?: string; + external_created_by?: string; + resolved?: { + action?: string; + config_id?: string; + change_type?: string; + summary?: string; + severity?: string; + }; +} + +export interface UIRelationship { + config_id: string; + related_id: string; + relation: string; + config_name?: string; + related_name?: string; +} + +export interface ConfigAnalysis { + analyzer: string; + message: string; + severity: string; + analysis_type: string; + summary?: string; + status?: string; +} + +export interface ExternalUser { + id: string; + name: string; + aliases?: string[]; + account_id?: string; + user_type?: string; +} + +export interface ExternalGroup { + id: string; + name: string; + aliases?: string[]; + account_id?: string; +} + +export interface ExternalRole { + id: string; + name: string; + aliases?: string[]; +} + +export interface ExternalUserGroup { + external_user_id?: string; + external_group_id?: string; + external_user_aliases?: string[]; + external_group_aliases?: string[]; +} + +export interface ExternalConfigAccess { + id: string; + config_id?: string; + external_config_id?: any; + application_id?: string; + scraper_id?: string; + source?: string; + external_user_id?: string; + external_role_id?: string; + external_group_id?: string; + external_user_aliases?: string[]; + external_role_aliases?: string[]; + external_group_aliases?: string[]; + created_at?: string; + created_by?: string; + deleted_at?: string; + deleted_by?: string; + last_reviewed_at?: string; + last_reviewed_by?: string; + [key: string]: any; +} + +export interface ExternalConfigAccessLog { + config_id?: string; + external_config_id?: any; + external_user_id?: string; + external_user_aliases?: string[]; + mfa?: boolean; + count?: number; + created_at?: string; + properties?: Record; + [key: string]: any; +} + +export interface FullScrapeResults { + configs?: ScrapeResult[]; + changes?: ConfigChange[]; + analysis?: ConfigAnalysis[]; + external_users?: ExternalUser[]; + external_groups?: ExternalGroup[]; + external_roles?: ExternalRole[]; + external_user_groups?: ExternalUserGroup[]; + config_access?: ExternalConfigAccess[]; + config_access_logs?: ExternalConfigAccessLog[]; +} + +// HAR types matching github.com/flanksource/commons/har +export interface HAREntry { + startedDateTime: string; + time: number; + request: HARRequest; + response: HARResponse; + cache: any; + timings: { send: number; wait: number; receive: number }; +} + +export interface HARRequest { + method: string; + url: string; + httpVersion: string; + headers: { name: string; value: string }[]; + queryString: { name: string; value: string }[]; + postData?: { mimeType: string; text: string }; + headersSize: number; + bodySize: number; +} + +export interface HARResponse { + status: number; + statusText: string; + httpVersion: string; + headers: { name: string; value: string }[]; + content: { size: number; mimeType?: string; text?: string; truncated?: boolean }; + redirectURL: string; + headersSize: number; + bodySize: number; +} + +export interface Counts { + configs: number; + changes: number; + analysis: number; + relationships: number; + external_users: number; + external_groups: number; + external_roles: number; + config_access: number; + access_logs: number; + errors: number; +} + +export interface SaveSummary { + config_types?: Record; +} + +export interface ConfigMeta { + parents?: string[]; + location?: string; +} + +export interface Warning { + input?: any; + output?: any; + result?: any; + expr?: string; + error?: string; + count?: number; +} + +export interface ScrapeIssue { + type: string; + message?: string; + change?: ConfigChange; + warning?: Warning; +} + +export interface EntityWindowCounts { + total: number; + updated_last: number; + updated_hour: number; + updated_day: number; + updated_week: number; + deleted_last: number; + deleted_hour: number; + deleted_day: number; + deleted_week: number; + last_created_at?: string; + last_updated_at?: string; +} + +export interface ScrapeSnapshot { + captured_at: string; + run_started_at: string; + per_scraper: Record; + per_config_type: Record; + external_users: EntityWindowCounts; + external_groups: EntityWindowCounts; + external_roles: EntityWindowCounts; + external_user_groups: EntityWindowCounts; + config_access: EntityWindowCounts; + config_access_logs: EntityWindowCounts; +} + +export interface ScrapeSnapshotDiff { + per_scraper?: Record; + per_config_type?: Record; + external_users: EntityWindowCounts; + external_groups: EntityWindowCounts; + external_roles: EntityWindowCounts; + external_user_groups: EntityWindowCounts; + config_access: EntityWindowCounts; + config_access_logs: EntityWindowCounts; +} + +export interface ScrapeSnapshotPair { + before?: ScrapeSnapshot; + after?: ScrapeSnapshot; + diff: ScrapeSnapshotDiff; +} + +export interface PropertyInfo { + value?: any; + default?: any; + type?: string; +} + +export interface LogLevelInfo { + scraper?: string; + global?: string; +} + +export interface BuildInfo { + version: string; + commit: string; + date: string; +} + +export interface Snapshot { + scrapers: ScraperProgress[]; + results: FullScrapeResults; + relationships?: UIRelationship[]; + config_meta?: Record; + issues?: ScrapeIssue[]; + counts: Counts; + save_summary?: SaveSummary; + snapshots?: Record; + scrape_spec?: any; + properties?: Record; + log_level?: LogLevelInfo; + har?: HAREntry[]; + logs: string; + done: boolean; + started_at: number; + build_info?: BuildInfo; + last_scrape_summary?: any; +} + +export interface TypeGroup { + type: string; + items: ScrapeResult[]; + counts: { healthy: number; unhealthy: number; warning: number; unknown: number; errors: number }; +} + +export type Tab = 'configs' | 'logs' | 'har' | 'users' | 'groups' | 'roles' | 'access' | 'access_logs' | 'issues' | 'snapshot' | 'last_summary' | 'spec'; diff --git a/cmd/scrapeui/frontend/src/utils.ts b/cmd/scrapeui/frontend/src/utils.ts new file mode 100644 index 000000000..013c9c09e --- /dev/null +++ b/cmd/scrapeui/frontend/src/utils.ts @@ -0,0 +1,239 @@ +import type { ScrapeResult, TypeGroup, FullScrapeResults } from './types'; + +export function groupByType(items: ScrapeResult[]): TypeGroup[] { + const groups = new Map(); + for (const item of items) { + const key = item.config_type || 'Unknown'; + const list = groups.get(key) || []; + list.push(item); + groups.set(key, list); + } + + return Array.from(groups.entries()) + .map(([type, items]) => ({ + type, + items, + counts: countHealth(items), + })) + .sort((a, b) => a.type.localeCompare(b.type)); +} + +export function countHealth(items: ScrapeResult[]) { + const c = { healthy: 0, unhealthy: 0, warning: 0, unknown: 0, errors: 0 }; + for (const item of items) { + switch (item.health) { + case 'healthy': c.healthy++; break; + case 'unhealthy': c.unhealthy++; break; + case 'warning': c.warning++; break; + default: c.unknown++; break; + } + } + return c; +} + +export function healthIcon(health?: string): string { + switch (health) { + case 'healthy': return 'codicon:pass-filled'; + case 'unhealthy': return 'codicon:error'; + case 'warning': return 'codicon:warning'; + default: return 'codicon:circle-outline'; + } +} + +export function healthColor(health?: string): string { + switch (health) { + case 'healthy': return 'text-green-500'; + case 'unhealthy': return 'text-red-500'; + case 'warning': return 'text-yellow-500'; + default: return 'text-gray-400'; + } +} + +const TYPE_ICONS: Record = { + 'Kubernetes': 'logos:kubernetes', + 'AWS': 'logos:aws', + 'Azure': 'logos:microsoft-azure', + 'GCP': 'logos:google-cloud', + 'File': 'codicon:file', + 'SQL': 'codicon:database', + 'HTTP': 'codicon:globe', + 'Terraform': 'logos:terraform-icon', + 'GitHub': 'logos:github-icon', + 'Trivy': 'simple-icons:trivy', + 'Orphaned Changes': 'codicon:warning', +}; + +export function typeIcon(configType: string): string { + const prefix = configType.split('::')[0]; + return TYPE_ICONS[prefix] || 'codicon:symbol-misc'; +} + +export function filterItems( + items: ScrapeResult[], + healthFilter: Set, + typeFilter: Set, +): ScrapeResult[] { + return items.filter(item => { + if (healthFilter.size > 0 && !healthFilter.has(item.health || 'unknown')) return false; + if (typeFilter.size > 0 && !typeFilter.has(item.config_type)) return false; + return true; + }); +} + +export function formatDuration(ms: number): string { + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remSecs = secs % 60; + return `${mins}m ${remSecs}s`; +} + +export function collectTypes(items: ScrapeResult[]): string[] { + const types = new Set(); + for (const item of items) { + if (item.config_type) types.add(item.config_type); + } + return Array.from(types).sort(); +} + +export interface Lookups { + users: Map; // alias/id -> name + groups: Map; // alias/id -> name + roles: Map; // alias/id -> name + configs: Map; // id -> name (type) +} + +export function buildLookups(results?: FullScrapeResults): Lookups { + const users = new Map(); + const groups = new Map(); + const roles = new Map(); + const configs = new Map(); + + for (const u of results?.external_users || []) { + users.set(u.id, u.name); + if (u.name) users.set(u.name, u.name); + for (const a of u.aliases || []) users.set(a, u.name); + } + for (const g of results?.external_groups || []) { + groups.set(g.id, g.name); + if (g.name) groups.set(g.name, g.name); + for (const a of g.aliases || []) groups.set(a, g.name); + } + for (const r of results?.external_roles || []) { + roles.set(r.id, r.name); + if (r.name) roles.set(r.name, r.name); + for (const a of r.aliases || []) roles.set(a, r.name); + } + for (const c of results?.configs || []) { + const label = c.name ? `${c.name} (${c.config_type})` : c.id; + configs.set(c.id, label); + } + return { users, groups, roles, configs }; +} + +export function resolve(lookup: Map, key: string): string { + return lookup.get(key) || key; +} + +export function resolveConfigId(lookups: Lookups, extId: any): string { + if (!extId) return ''; + if (typeof extId === 'string') return lookups.configs.get(extId) || extId; + const eid = extId.external_id || extId.config_id || ''; + return lookups.configs.get(eid) || eid; +} + +export function statusColor(status: number): string { + if (status >= 200 && status < 300) return 'text-green-600'; + if (status >= 300 && status < 400) return 'text-blue-600'; + if (status >= 400 && status < 500) return 'text-yellow-600'; + if (status >= 500) return 'text-red-600'; + return 'text-gray-600'; +} + +function containsCI(text: string | undefined | null, q: string): boolean { + return !!text && text.toLowerCase().includes(q); +} + +export type SearchCounts = Record; + +export function globalSearch( + q: string, + results?: FullScrapeResults, + har?: import('./types').HAREntry[], + logs?: string, +): SearchCounts { + const counts: SearchCounts = {}; + if (!q) return counts; + const lq = q.toLowerCase(); + + let n = 0; + for (const c of results?.configs || []) { + if (containsCI(c.name, lq) || containsCI(c.config_type, lq) || + containsCI(JSON.stringify(c.config), lq) || + c.aliases?.some(a => containsCI(a, lq)) || + Object.entries(c.labels || {}).some(([k, v]) => containsCI(k, lq) || containsCI(v, lq)) || + Object.entries(c.tags || {}).some(([k, v]) => containsCI(k, lq) || containsCI(v, lq))) + n++; + } + if (n) counts.configs = n; + + n = 0; + for (const e of har || []) { + if (containsCI(e.request.url, lq) || containsCI(e.request.method, lq) || + containsCI(e.request.postData?.text, lq) || + containsCI(e.response.content?.text, lq)) + n++; + } + if (n) counts.har = n; + + n = 0; + for (const u of results?.external_users || []) + if (containsCI(u.name, lq) || u.aliases?.some(a => containsCI(a, lq))) n++; + if (n) counts.users = n; + + n = 0; + for (const g of results?.external_groups || []) + if (containsCI(g.name, lq) || g.aliases?.some(a => containsCI(a, lq))) n++; + if (n) counts.groups = n; + + n = 0; + for (const r of results?.external_roles || []) + if (containsCI(r.name, lq) || r.aliases?.some(a => containsCI(a, lq))) n++; + if (n) counts.roles = n; + + n = 0; + for (const a of results?.config_access || []) + if (a.external_user_aliases?.some(x => containsCI(x, lq)) || + a.external_role_aliases?.some(x => containsCI(x, lq)) || + a.external_group_aliases?.some(x => containsCI(x, lq))) + n++; + if (n) counts.access = n; + + n = 0; + for (const a of results?.config_access_logs || []) + if (a.external_user_aliases?.some(x => containsCI(x, lq))) n++; + if (n) counts.access_logs = n; + + if (containsCI(logs, lq)) counts.logs = 1; + + n = 0; + for (const ch of results?.changes || []) + if (containsCI(ch.summary, lq) || containsCI(ch.change_type, lq) || + containsCI(ch.diff, lq) || containsCI(ch.external_created_by, lq)) + n++; + if (n) counts.changes = n; + + return counts; +} + +export function matchesSearch(q: string, ...fields: (string | undefined | null)[]): boolean { + if (!q) return true; + const lq = q.toLowerCase(); + return fields.some(f => containsCI(f, lq)); +} + +export function matchesSearchArr(q: string, arr: (string | undefined)[]): boolean { + if (!q) return true; + const lq = q.toLowerCase(); + return arr.some(f => containsCI(f, lq)); +} diff --git a/cmd/scrapeui/frontend/tsconfig.json b/cmd/scrapeui/frontend/tsconfig.json new file mode 100644 index 000000000..06cd275ac --- /dev/null +++ b/cmd/scrapeui/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/cmd/scrapeui/frontend/vite.config.ts b/cmd/scrapeui/frontend/vite.config.ts new file mode 100644 index 000000000..61f1dc4de --- /dev/null +++ b/cmd/scrapeui/frontend/vite.config.ts @@ -0,0 +1,164 @@ +import { defineConfig, type Plugin } from 'vite'; +import preact from '@preact/preset-vite'; +import { execSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +// Capture git commit + build time once at vite startup so the UI header can +// display the frontend build identity alongside the Go binary's. Failing to +// resolve git metadata (e.g. running from a tarball) falls back to safe +// defaults rather than breaking the build. +function uiBuildCommit(): string { + try { + return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} +const UI_BUILD_COMMIT = uiBuildCommit(); +const UI_BUILD_DATE = new Date().toISOString(); + +function fileApiPlugin(): Plugin { + return { + name: 'file-api', + configureServer(server) { + const file = process.env.FILE; + if (!file) return; + + const abs = resolve(file); + let raw: any; + try { + raw = JSON.parse(readFileSync(abs, 'utf-8')); + } catch (e) { + console.error(`Failed to read ${abs}:`, e); + return; + } + + // Wrap raw FullScrapeResults into a Snapshot shape + const snap = { + scrapers: [], + results: { + configs: raw.Configs || raw.configs || [], + changes: raw.Changes || raw.changes || [], + analysis: raw.Analysis || raw.analysis || [], + external_users: raw.ExternalUsers || raw.external_users || [], + external_groups: raw.ExternalGroups || raw.external_groups || [], + external_roles: raw.ExternalRoles || raw.external_roles || [], + external_user_groups: raw.ExternalUserGroups || raw.external_user_groups || [], + config_access: raw.ConfigAccess || raw.config_access || [], + config_access_logs: raw.ConfigAccessLogs || raw.config_access_logs || [], + }, + relationships: raw.Relationships || raw.relationships || [], + counts: { + configs: (raw.Configs || raw.configs || []).length, + changes: (raw.Changes || raw.changes || []).length, + analysis: (raw.Analysis || raw.analysis || []).length, + relationships: (raw.Relationships || raw.relationships || []).length, + external_users: (raw.ExternalUsers || raw.external_users || []).length, + external_groups: (raw.ExternalGroups || raw.external_groups || []).length, + external_roles: (raw.ExternalRoles || raw.external_roles || []).length, + config_access: (raw.ConfigAccess || raw.config_access || []).length, + access_logs: (raw.ConfigAccessLogs || raw.config_access_logs || []).length, + errors: 0, + }, + har: raw.har || raw.HAR || [], + logs: '', + done: true, + started_at: Date.now(), + }; + + console.log(`Serving ${abs} — ${snap.counts.configs} configs, ${snap.counts.relationships} relationships`); + + server.middlewares.use('/api/scrape/stream', (_req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write(`data: ${JSON.stringify(snap)}\n\n`); + res.write('event: done\ndata: {}\n\n'); + res.end(); + }); + + server.middlewares.use('/api/scrape', (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(snap)); + }); + + server.middlewares.use('/api/config/', (req, res) => { + const id = decodeURIComponent((req.url || '').replace(/^\//, '')); + const configs: any[] = snap.results.configs || []; + const item = configs.find(c => c.id === id); + if (!item) { + res.writeHead(404); + res.end('not found'); + return; + } + const rels = (snap.relationships || []).filter( + (r: any) => r.config_id === id || r.related_id === id, + ); + const changes = ((snap.results.changes as any[]) || []).filter( + (c: any) => c.source && c.source.includes(id), + ); + const detail = { + ...item, + _meta: (snap.config_meta || {})[id], + _relationships: rels, + _changes: changes, + }; + const safe = id.replace(/[^a-zA-Z0-9._-]/g, '_'); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${safe}.json"`, + }); + res.end(JSON.stringify(detail, null, 2)); + }); + }, + }; +} + +// Dev-only plugin: rewrite deep SPA routes (/configs/{id}, /groups/{id}, ...) +// back to '/' so the index HTML is served. Mirrors isSPARoute in server.go. +function spaHistoryFallback(): Plugin { + const prefixes = [ + '/configs', '/logs', '/har', '/users', '/groups', + '/roles', '/access', '/access_logs', '/issues', '/snapshot', '/last_summary', '/spec', + ]; + return { + name: 'spa-history-fallback', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + const url = req.url || '/'; + if (req.method !== 'GET' || url.startsWith('/api/') || url.startsWith('/@') || url.startsWith('/src/') || url.startsWith('/node_modules/')) { + return next(); + } + const pathOnly = url.split('?')[0]; + if (prefixes.some(p => pathOnly === p || pathOnly.startsWith(p + '/'))) { + req.url = '/'; + } + return next(); + }); + }, + }; +} + +export default defineConfig({ + plugins: [preact(), spaHistoryFallback(), fileApiPlugin()], + define: { + __UI_BUILD_COMMIT__: JSON.stringify(UI_BUILD_COMMIT), + __UI_BUILD_DATE__: JSON.stringify(UI_BUILD_DATE), + }, + build: { + lib: { + entry: 'src/index.tsx', + name: 'ScrapeUI', + formats: ['iife'], + fileName: () => 'scrapeui.js', + }, + outDir: 'dist', + minify: true, + rollupOptions: { + output: { inlineDynamicImports: true }, + }, + }, +}); diff --git a/cmd/scrapeui/server.go b/cmd/scrapeui/server.go new file mode 100644 index 000000000..d808b8ce7 --- /dev/null +++ b/cmd/scrapeui/server.go @@ -0,0 +1,453 @@ +package scrapeui + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/flanksource/commons/har" + v1 "github.com/flanksource/config-db/api/v1" + "github.com/flanksource/duty/models" + "github.com/google/uuid" +) + +// mergeEntitiesByID returns a new slice containing one entry per unique ID, +// preferring entries from `incoming` over entries already in `existing` when +// the same ID appears in both (the SaveResults-produced entity is always +// newer). IDs with a zero UUID are treated as unique. +func mergeEntitiesByID[T any](existing, incoming []T, getID func(T) uuid.UUID) []T { + if len(incoming) == 0 { + return existing + } + out := make([]T, 0, len(existing)+len(incoming)) + byID := make(map[uuid.UUID]int, len(existing)+len(incoming)) + for _, e := range existing { + id := getID(e) + if id == uuid.Nil { + out = append(out, e) + continue + } + byID[id] = len(out) + out = append(out, e) + } + for _, e := range incoming { + id := getID(e) + if id == uuid.Nil { + out = append(out, e) + continue + } + if idx, ok := byID[id]; ok { + out[idx] = e + continue + } + byID[id] = len(out) + out = append(out, e) + } + return out +} + +type Server struct { + mu sync.RWMutex + scrapers []ScraperProgress + results v1.FullScrapeResults + relationships []UIRelationship + configMeta map[string]ConfigMeta + issues []ScrapeIssue + summary *SaveSummary + snapshots map[string]*v1.ScrapeSnapshotPair + har []har.Entry + scrapeSpec any + properties map[string]PropertyInfo + logLevel *LogLevelInfo + logBuf *bytes.Buffer + done bool + startedAt int64 + buildInfo *BuildInfo + lastScrapeSummary *v1.ScrapeSummary + updated chan struct{} +} + +// SetBuildInfo stores the build-time version/commit/date so the frontend can +// display it. Called once at startup by cmd/run.go after constructing the +// server. Kept on Server rather than passed to NewServer to avoid adding yet +// another constructor argument. +func (s *Server) SetBuildInfo(info BuildInfo) { + s.mu.Lock() + s.buildInfo = &info + s.mu.Unlock() +} + +func (s *Server) SetLastScrapeSummary(summary v1.ScrapeSummary) { + s.mu.Lock() + s.lastScrapeSummary = &summary + s.mu.Unlock() +} + +func (s *Server) SetProperties(props map[string]PropertyInfo, logLevel LogLevelInfo) { + s.mu.Lock() + s.properties = props + s.logLevel = &logLevel + s.mu.Unlock() + s.notify() +} + +func NewServer(scraperNames []string, scrapeSpec any, logBuf *bytes.Buffer) *Server { + scrapers := make([]ScraperProgress, len(scraperNames)) + for i, name := range scraperNames { + scrapers[i] = ScraperProgress{ + Name: ScraperName(name), + Status: ScraperPending, + } + } + return &Server{ + scrapers: scrapers, + scrapeSpec: scrapeSpec, + logBuf: logBuf, + updated: make(chan struct{}, 1), + startedAt: time.Now().UnixMilli(), + } +} + +func (s *Server) UpdateScraper(name string, status ScraperStatus, results []v1.ScrapeResult, summary *v1.ScrapeSummary, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + displayName := ScraperName(name) + for i := range s.scrapers { + if s.scrapers[i].Name != displayName { + continue + } + s.scrapers[i].Status = status + if status == ScraperRunning { + now := time.Now() + s.scrapers[i].StartedAt = &now + } + if status == ScraperComplete || status == ScraperError { + if s.scrapers[i].StartedAt != nil { + s.scrapers[i].DurationSec = time.Since(*s.scrapers[i].StartedAt).Seconds() + } + } + if err != nil { + s.scrapers[i].Error = err.Error() + } + if results != nil { + s.relationships = append(s.relationships, BuildUIRelationships(results)...) + for k, v := range BuildConfigMeta(results, s.relationships) { + if s.configMeta == nil { + s.configMeta = map[string]ConfigMeta{} + } + s.configMeta[k] = v + } + merged := MergeResults(results) + s.scrapers[i].ResultCount = len(merged.Configs) + s.results.Configs = append(s.results.Configs, merged.Configs...) + s.results.Changes = append(s.results.Changes, merged.Changes...) + s.results.Analysis = append(s.results.Analysis, merged.Analysis...) + s.results.Relationships = append(s.results.Relationships, merged.Relationships...) + s.results.ExternalUserGroups = append(s.results.ExternalUserGroups, merged.ExternalUserGroups...) + s.results.ConfigAccess = append(s.results.ConfigAccess, merged.ConfigAccess...) + s.results.ConfigAccessLogs = append(s.results.ConfigAccessLogs, merged.ConfigAccessLogs...) + + // External users/groups/roles: prefer the canonical post-merge + // entities from the summary (AAD-supplied winner IDs), but also + // merge in the raw scraper output so results are visible even + // when summary.Entities is empty — e.g. when SaveResults ran but + // the SQL merge short-circuited without repopulating Entities, + // or when running with --no-save. + s.results.ExternalUsers = mergeEntitiesByID(s.results.ExternalUsers, merged.ExternalUsers, func(u models.ExternalUser) uuid.UUID { return u.ID }) + s.results.ExternalGroups = mergeEntitiesByID(s.results.ExternalGroups, merged.ExternalGroups, func(g models.ExternalGroup) uuid.UUID { return g.ID }) + s.results.ExternalRoles = mergeEntitiesByID(s.results.ExternalRoles, merged.ExternalRoles, func(r models.ExternalRole) uuid.UUID { return r.ID }) + if summary != nil { + s.results.ExternalUsers = mergeEntitiesByID(s.results.ExternalUsers, summary.ExternalUsers.Entities, func(u models.ExternalUser) uuid.UUID { return u.ID }) + s.results.ExternalGroups = mergeEntitiesByID(s.results.ExternalGroups, summary.ExternalGroups.Entities, func(g models.ExternalGroup) uuid.UUID { return g.ID }) + s.results.ExternalRoles = mergeEntitiesByID(s.results.ExternalRoles, summary.ExternalRoles.Entities, func(r models.ExternalRole) uuid.UUID { return r.ID }) + } + } + if summary != nil { + s.summary = ConvertSaveSummary(summary) + for i := range summary.OrphanedChanges { + s.issues = append(s.issues, ScrapeIssue{Type: "orphaned", Message: "Change has no matching config", Change: &summary.OrphanedChanges[i]}) + } + for i := range summary.FKErrorChanges { + s.issues = append(s.issues, ScrapeIssue{Type: "fk_error", Message: "Foreign key constraint violation", Change: &summary.FKErrorChanges[i]}) + } + for i := range summary.Warnings { + s.issues = append(s.issues, ScrapeIssue{Type: "warning", Message: summary.Warnings[i].Error, Warning: &summary.Warnings[i]}) + } + for configType, cs := range summary.ConfigTypes { + for _, w := range cs.Warnings { + s.issues = append(s.issues, ScrapeIssue{Type: "warning", Message: fmt.Sprintf("[%s] %s", configType, w)}) + } + } + } + break + } + s.notify() +} + +func (s *Server) SetHAR(entries []har.Entry) { + s.mu.Lock() + s.har = entries + s.mu.Unlock() + s.notify() +} + +// SetSnapshots records the before/after/diff snapshot pair captured by the +// scrape run for the given scraper. Keyed by scraper name so multi-scraper +// runs keep each pair distinct. +func (s *Server) SetSnapshots(scraperName string, pair *v1.ScrapeSnapshotPair) { + if pair == nil { + return + } + s.mu.Lock() + if s.snapshots == nil { + s.snapshots = map[string]*v1.ScrapeSnapshotPair{} + } + s.snapshots[ScraperName(scraperName)] = pair + s.mu.Unlock() + s.notify() +} + +func NewStaticServer(snap Snapshot) *Server { + snap.Done = true + uiRels := snap.Relationships + if len(uiRels) == 0 { + uiRels = BuildUIRelationshipsFromDB(snap.Results.Relationships, snap.Results.Configs) + } + configMeta := snap.ConfigMeta + if len(configMeta) == 0 && len(uiRels) > 0 { + configMeta = BuildConfigMetaFromRelationships(uiRels) + } + snap.Counts = BuildCounts(snap.Results, uiRels) + if snap.StartedAt == 0 { + snap.StartedAt = time.Now().UnixMilli() + } + return &Server{ + scrapers: snap.Scrapers, + results: snap.Results, + relationships: uiRels, + configMeta: configMeta, + har: snap.HAR, + done: true, + startedAt: snap.StartedAt, + updated: make(chan struct{}, 1), + } +} + +func (s *Server) SetDone() { + s.mu.Lock() + s.done = true + s.mu.Unlock() + s.notify() +} + +func (s *Server) notify() { + select { + case s.updated <- struct{}{}: + default: + } +} + +func (s *Server) snapshot() Snapshot { + logs := "" + if s.logBuf != nil { + logs = s.logBuf.String() + } + return Snapshot{ + Scrapers: s.scrapers, + Results: s.results, + Relationships: s.relationships, + ConfigMeta: s.configMeta, + Issues: s.issues, + Counts: BuildCounts(s.results, s.relationships), + SaveSummary: s.summary, + Snapshots: s.snapshots, + ScrapeSpec: s.scrapeSpec, + Properties: s.properties, + LogLevel: s.logLevel, + HAR: s.har, + Logs: logs, + Done: s.done, + StartedAt: s.startedAt, + BuildInfo: s.buildInfo, + LastScrapeSummary: s.lastScrapeSummary, + } +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handlePage) + mux.HandleFunc("/api/scrape", s.handleJSON) + mux.HandleFunc("/api/scrape/stream", s.handleSSE) + mux.HandleFunc("/api/config/", s.handleConfigItem) + return mux +} + +func (s *Server) handleConfigItem(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/config/") + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + + s.mu.RLock() + defer s.mu.RUnlock() + + var item *v1.ScrapeResult + for i := range s.results.Configs { + if s.results.Configs[i].ID == id { + item = &s.results.Configs[i] + break + } + } + if item == nil { + http.NotFound(w, r) + return + } + + type configItemDetail struct { + v1.ScrapeResult + Meta *ConfigMeta `json:"_meta,omitempty"` + Relationships []UIRelationship `json:"_relationships,omitempty"` + Changes []v1.ChangeResult `json:"_changes,omitempty"` + } + + detail := configItemDetail{ScrapeResult: *item} + if meta, ok := s.configMeta[id]; ok { + detail.Meta = &meta + } + for _, rel := range s.relationships { + if rel.ConfigExternalID == id || rel.RelatedExternalID == id { + detail.Relationships = append(detail.Relationships, rel) + } + } + for _, ch := range s.results.Changes { + if ch.Source != "" && strings.Contains(ch.Source, id) { + detail.Changes = append(detail.Changes, ch) + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.json"`, sanitizeFilename(id))) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(detail) //nolint:errcheck +} + +func sanitizeFilename(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + return b.String() +} + +// SPA routes that the Preact app handles client-side. Any request for one of +// these prefixes (or the bare root) should return the HTML shell so that +// deep links like /configs/{id} or /groups/{id} work on refresh. +var spaRoutes = []string{ + "/configs", "/logs", "/har", "/users", "/groups", + "/roles", "/access", "/access_logs", "/issues", "/snapshot", "/last_summary", "/spec", +} + +func isSPARoute(path string) bool { + if path == "/" { + return true + } + for _, prefix := range spaRoutes { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + } + return false +} + +func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) { + if !isSPARoute(r.URL.Path) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, pageHTML()) +} + +func pageHTML() string { + return ` + + + + + Scrape Results + + + + +
+ + +` +} + +func (s *Server) handleJSON(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + data := s.snapshot() + s.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) //nolint:errcheck +} + +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + s.mu.RLock() + initial := s.snapshot() + s.mu.RUnlock() + if b, err := json.Marshal(initial); err == nil { + fmt.Fprintf(w, "data: %s\n\n", b) + flusher.Flush() + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-s.updated: + case <-ticker.C: + } + + s.mu.RLock() + data := s.snapshot() + s.mu.RUnlock() + + b, _ := json.Marshal(data) + fmt.Fprintf(w, "data: %s\n\n", b) + flusher.Flush() + + if data.Done { + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() + return + } + } +} diff --git a/cmd/scrapeui/types.go b/cmd/scrapeui/types.go new file mode 100644 index 000000000..8430c677d --- /dev/null +++ b/cmd/scrapeui/types.go @@ -0,0 +1,115 @@ +package scrapeui + +import ( + "time" + + "github.com/flanksource/commons/har" + v1 "github.com/flanksource/config-db/api/v1" +) + +type ScraperStatus string + +const ( + ScraperPending ScraperStatus = "pending" + ScraperRunning ScraperStatus = "running" + ScraperComplete ScraperStatus = "complete" + ScraperError ScraperStatus = "error" +) + +type ScraperProgress struct { + Name string `json:"name"` + Status ScraperStatus `json:"status"` + StartedAt *time.Time `json:"started_at,omitempty"` + DurationSec float64 `json:"duration_secs,omitempty"` + Error string `json:"error,omitempty"` + ResultCount int `json:"result_count"` +} + +type Counts struct { + Configs int `json:"configs"` + Changes int `json:"changes"` + Analysis int `json:"analysis"` + Relationships int `json:"relationships"` + ExternalUsers int `json:"external_users"` + ExternalGroups int `json:"external_groups"` + ExternalRoles int `json:"external_roles"` + ConfigAccess int `json:"config_access"` + AccessLogs int `json:"access_logs"` + Errors int `json:"errors"` +} + +type SaveSummary struct { + ConfigTypes map[string]TypeSummary `json:"config_types,omitempty"` +} + +type TypeSummary struct { + Added int `json:"added"` + Updated int `json:"updated"` + Unchanged int `json:"unchanged"` + Changes int `json:"changes"` +} + +// UIRelationship is a frontend-friendly relationship that uses +// external IDs and resolved names instead of internal DB UUIDs. +type UIRelationship struct { + ConfigExternalID string `json:"config_id"` + RelatedExternalID string `json:"related_id"` + Relation string `json:"relation"` + ConfigName string `json:"config_name,omitempty"` + RelatedName string `json:"related_name,omitempty"` +} + +// ConfigMeta carries resolved metadata (parents, locations) per config external ID. +type ConfigMeta struct { + Parents []string `json:"parents,omitempty"` + Location string `json:"location,omitempty"` +} + +// ScrapeIssue represents an orphaned change, FK error, or other pipeline issue. +type ScrapeIssue struct { + Type string `json:"type"` // "orphaned", "fk_error", "warning" + Message string `json:"message,omitempty"` + Change *v1.ChangeResult `json:"change,omitempty"` + Warning *v1.Warning `json:"warning,omitempty"` +} + +// PropertyInfo is a UI-friendly representation of a resolved property. +type PropertyInfo struct { + Value any `json:"value,omitempty"` + Default any `json:"default,omitempty"` + Type string `json:"type,omitempty"` +} + +// LogLevelInfo carries the effective log levels for display in the Spec tab. +type LogLevelInfo struct { + Scraper string `json:"scraper,omitempty"` + Global string `json:"global,omitempty"` +} + +// BuildInfo carries the build-time version/commit/date for display in the +// scrape UI. Populated by the server at startup from the cmd package. +type BuildInfo struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` +} + +type Snapshot struct { + Scrapers []ScraperProgress `json:"scrapers"` + Results v1.FullScrapeResults `json:"results"` + Relationships []UIRelationship `json:"relationships,omitempty"` + ConfigMeta map[string]ConfigMeta `json:"config_meta,omitempty"` + Issues []ScrapeIssue `json:"issues,omitempty"` + Counts Counts `json:"counts"` + SaveSummary *SaveSummary `json:"save_summary,omitempty"` + Snapshots map[string]*v1.ScrapeSnapshotPair `json:"snapshots,omitempty"` + ScrapeSpec any `json:"scrape_spec,omitempty"` + Properties map[string]PropertyInfo `json:"properties,omitempty"` + LogLevel *LogLevelInfo `json:"log_level,omitempty"` + HAR []har.Entry `json:"har,omitempty"` + Logs string `json:"logs"` + Done bool `json:"done"` + StartedAt int64 `json:"started_at"` + BuildInfo *BuildInfo `json:"build_info,omitempty"` + LastScrapeSummary *v1.ScrapeSummary `json:"last_scrape_summary,omitempty"` +} diff --git a/cmd/ui.go b/cmd/ui.go new file mode 100644 index 000000000..2451129f0 --- /dev/null +++ b/cmd/ui.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "encoding/json" + + "github.com/flanksource/commons/har" + "github.com/flanksource/commons/logger" + v1 "github.com/flanksource/config-db/api/v1" + "github.com/flanksource/config-db/cmd/scrapeui" + "github.com/flanksource/duty/models" + "github.com/spf13/cobra" +) + +// jsonResults matches the JSON shape produced by `config-db run --json`. +// Fields have no json tags because clicky serializes using Go field names (PascalCase). +type jsonResults struct { + Configs []v1.ScrapeResult + Changes []v1.ChangeResult + Artifacts []models.Artifact + Analysis []models.ConfigAnalysis + Relationships []scrapeui.UIRelationship + ConfigMeta map[string]scrapeui.ConfigMeta + ExternalRoles []models.ExternalRole + ExternalUsers []models.ExternalUser + ExternalGroups []models.ExternalGroup + ExternalUserGroups []v1.ExternalUserGroup + ConfigAccess []v1.ExternalConfigAccess + ConfigAccessLogs []v1.ExternalConfigAccessLog + HAR []har.Entry `json:"har,omitempty"` +} + +var UI = &cobra.Command{ + Use: "ui ", + Short: "Launch the scrape UI to view saved JSON results", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + data, err := os.ReadFile(args[0]) + if err != nil { + logger.Fatalf("Failed to read file: %v", err) + } + + var results jsonResults + if err := json.Unmarshal(data, &results); err != nil { + logger.Fatalf("Failed to parse JSON: %v", err) + } + + snap := scrapeui.Snapshot{ + Results: v1.FullScrapeResults{ + Configs: results.Configs, + Changes: results.Changes, + Analysis: results.Analysis, + ExternalRoles: results.ExternalRoles, + ExternalUsers: results.ExternalUsers, + ExternalGroups: results.ExternalGroups, + ExternalUserGroups: results.ExternalUserGroups, + ConfigAccess: results.ConfigAccess, + ConfigAccessLogs: results.ConfigAccessLogs, + }, + Relationships: results.Relationships, + ConfigMeta: results.ConfigMeta, + HAR: results.HAR, + } + + srv := scrapeui.NewStaticServer(snap) + + addr := fmt.Sprintf("localhost:%d", uiPort) + listener, listenErr := net.Listen("tcp", addr) + if listenErr != nil && uiPort != 0 { + logger.Warnf("Port %d in use, picking a free port", uiPort) + listener, listenErr = net.Listen("tcp", "localhost:0") + } + if listenErr != nil { + logger.Fatalf("Failed to start UI server: %v", listenErr) + } + + port := listener.Addr().(*net.TCPAddr).Port + url := fmt.Sprintf("http://localhost:%d", port) + + go http.Serve(listener, srv.Handler()) //nolint:errcheck + + time.Sleep(100 * time.Millisecond) + logger.Infof("Scrape UI at %s", url) + openBrowser(url) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + }, +} + +func init() { + UI.Flags().IntVar(&uiPort, "ui-port", 9001, "Port for the UI server (0 to pick a free port)") +} From ac307f425f40c64217788a0fcc7f8687a1a960a2 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 21 Apr 2026 09:15:06 +0545 Subject: [PATCH 2/3] fix: build --- build/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build/Dockerfile b/build/Dockerfile index e1cc64196..2b1ffd520 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -8,6 +8,13 @@ COPY Makefile /app COPY external/diffgen /app/external/diffgen RUN make rust-diffgen +FROM node:20-bookworm AS scrapeui-builder +WORKDIR /app/cmd/scrapeui/frontend +COPY cmd/scrapeui/frontend/package.json cmd/scrapeui/frontend/package-lock.json ./ +RUN npm ci +COPY cmd/scrapeui/frontend ./ +RUN npm run build + FROM golang:1.26-bookworm AS builder-base WORKDIR /app @@ -22,6 +29,7 @@ FROM builder-base AS builder COPY ./ ./ COPY --from=rust-builder /app/external/diffgen/target ./external/diffgen/target +COPY --from=scrapeui-builder /app/cmd/scrapeui/frontend/dist ./cmd/scrapeui/frontend/dist RUN make build-prod From e6f098872ba5318b84f42472d47dcd6faf12e134 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 21 Apr 2026 09:43:27 +0545 Subject: [PATCH 3/3] fix(scrapeui): address PR review issues and stabilize CI lint Review feedback surfaced several regressions in the new scrape UI path: duplicate terminal output, copied links that did not route correctly, scraper status collisions by display name, and static snapshot views dropping saved fields. The run command also bypassed centralized shutdown hooks in UI mode, and reading logs from bytes.Buffer could race with concurrent writes. This change fixes the UI/run flow by printing output only once, routing copied config links via pathname, preserving stable scraper identities, carrying full snapshot metadata in static mode, and invoking shutdown hooks when UI mode receives signals. It also wraps log buffering in a concurrency-safe writer and registers UI server shutdown hooks. To keep build/lint green in environments that do not build frontend assets first, embed fallbacks are added for dist JS/CSS and make resources now guarantees those files exist before typecheck/golangci-lint. Accessibility improvements were also applied to icon-only copy controls. --- Makefile | 10 +- cmd/run.go | 24 +- cmd/scrapeui/assets.go | 3 + cmd/scrapeui/frontend/.gitignore | 3 + cmd/scrapeui/frontend/dist/scrapeui.css | 1 + cmd/scrapeui/frontend/dist/scrapeui.js | 1 + cmd/scrapeui/frontend/package-lock.json | 1044 ++++++++++++++++- cmd/scrapeui/frontend/package.json | 6 +- .../frontend/src/components/AliasList.tsx | 3 +- .../frontend/src/components/DetailPanel.tsx | 4 +- cmd/scrapeui/frontend/src/index.tsx | 1 + cmd/scrapeui/frontend/src/styles.css | 1 + cmd/scrapeui/safe_buffer.go | 24 + cmd/scrapeui/server.go | 120 +- cmd/scrapeui/types.go | 37 +- 15 files changed, 1179 insertions(+), 103 deletions(-) create mode 100644 cmd/scrapeui/frontend/dist/scrapeui.css create mode 100644 cmd/scrapeui/frontend/dist/scrapeui.js create mode 100644 cmd/scrapeui/frontend/src/styles.css create mode 100644 cmd/scrapeui/safe_buffer.go diff --git a/Makefile b/Makefile index 9b3d073b1..eaf566f68 100644 --- a/Makefile +++ b/Makefile @@ -81,8 +81,16 @@ manifests: generate gen-schemas ## Generate WebhookConfiguration, ClusterRole an generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./api/..." +.PHONY: scrapeui-fallback-assets +scrapeui-fallback-assets: + @mkdir -p cmd/scrapeui/frontend/dist + @test -f cmd/scrapeui/frontend/dist/scrapeui.js || \ + echo "console.warn('scrapeui fallback bundle: run make scrapeui-build for full UI');" > cmd/scrapeui/frontend/dist/scrapeui.js + @test -f cmd/scrapeui/frontend/dist/scrapeui.css || \ + echo "/* scrapeui fallback styles: run make scrapeui-build for full UI */" > cmd/scrapeui/frontend/dist/scrapeui.css + .PHONY: resources -resources: fmt manifests +resources: scrapeui-fallback-assets fmt manifests test: manifests generate fmt vet envtest ## Run tests. $(MAKE) gotest diff --git a/cmd/run.go b/cmd/run.go index 3c2f6a4e7..922d37de6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -1,7 +1,6 @@ package cmd import ( - "bytes" gocontext "context" "encoding/base64" "encoding/json" @@ -58,7 +57,7 @@ var Run = &cobra.Command{ Use: "run ", Short: "Run scrapers and return", Run: func(cmd *cobra.Command, configFiles []string) { - var logBuf bytes.Buffer + var logBuf scrapeui.SafeBuffer var harCollector *har.Collector if logger.IsTraceEnabled() { @@ -231,7 +230,6 @@ var Run = &cobra.Command{ // Restore stderr-only logging before rendering logger.Use(os.Stderr) - printOutput(allResults, lastSummary, lastSnapshotPair, harCollector, logBuf.String()) if uiServer != nil { if harCollector != nil { uiServer.SetHAR(harCollector.Entries()) @@ -266,12 +264,14 @@ var Run = &cobra.Command{ if uiServer == nil { printOutput(allResults, lastSummary, lastSnapshotPair, harCollector, logBuf.String()) - } - - if uiServer != nil { + } else { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig + shutdown.Shutdown() + if hasErrors { + os.Exit(1) + } return } @@ -635,7 +635,7 @@ func ensureScraper(ctx context.Context, sc *v1.ScrapeConfig) error { return nil } -func startScrapeUI(scraperNames []string, scrapeSpec any, logBuf *bytes.Buffer) *scrapeui.Server { +func startScrapeUI(scraperNames []string, scrapeSpec any, logBuf *scrapeui.SafeBuffer) *scrapeui.Server { srv := scrapeui.NewServer(scraperNames, scrapeSpec, logBuf) bi := GetBuildInfo() srv.SetBuildInfo(scrapeui.BuildInfo{Version: bi.Version, Commit: bi.Commit, Date: bi.Date}) @@ -652,7 +652,15 @@ func startScrapeUI(scraperNames []string, scrapeSpec any, logBuf *bytes.Buffer) port := listener.Addr().(*net.TCPAddr).Port url := fmt.Sprintf("http://localhost:%d", port) - go http.Serve(listener, srv.Handler()) //nolint:errcheck + httpServer := &http.Server{Handler: srv.Handler()} + shutdown.AddHook(func() { + ctx, cancel := gocontext.WithTimeout(gocontext.Background(), 5*time.Second) + defer cancel() + _ = httpServer.Shutdown(ctx) + _ = listener.Close() + }) + + go httpServer.Serve(listener) //nolint:errcheck time.Sleep(100 * time.Millisecond) logger.Infof("Scrape UI at %s", url) diff --git a/cmd/scrapeui/assets.go b/cmd/scrapeui/assets.go index be0444f6b..1cb466820 100644 --- a/cmd/scrapeui/assets.go +++ b/cmd/scrapeui/assets.go @@ -4,3 +4,6 @@ import _ "embed" //go:embed frontend/dist/scrapeui.js var bundleJS string + +//go:embed frontend/dist/scrapeui.css +var bundleCSS string diff --git a/cmd/scrapeui/frontend/.gitignore b/cmd/scrapeui/frontend/.gitignore index b94707787..f021dc21c 100644 --- a/cmd/scrapeui/frontend/.gitignore +++ b/cmd/scrapeui/frontend/.gitignore @@ -1,2 +1,5 @@ node_modules/ dist/ +!dist/ +!dist/scrapeui.js +!dist/scrapeui.css diff --git a/cmd/scrapeui/frontend/dist/scrapeui.css b/cmd/scrapeui/frontend/dist/scrapeui.css new file mode 100644 index 000000000..e2d7b043c --- /dev/null +++ b/cmd/scrapeui/frontend/dist/scrapeui.css @@ -0,0 +1 @@ +/* scrapeui fallback styles: run make scrapeui-build for full UI */ diff --git a/cmd/scrapeui/frontend/dist/scrapeui.js b/cmd/scrapeui/frontend/dist/scrapeui.js new file mode 100644 index 000000000..06e6c02ed --- /dev/null +++ b/cmd/scrapeui/frontend/dist/scrapeui.js @@ -0,0 +1 @@ +console.warn('scrapeui fallback bundle: run make scrapeui-build for full UI'); diff --git a/cmd/scrapeui/frontend/package-lock.json b/cmd/scrapeui/frontend/package-lock.json index 01aae2cc2..85601b6f3 100644 --- a/cmd/scrapeui/frontend/package-lock.json +++ b/cmd/scrapeui/frontend/package-lock.json @@ -6,10 +6,13 @@ "": { "name": "@flanksource/config-db-scrape-ui", "dependencies": { + "iconify-icon": "^3.0.2", "preact": "^10.25.0" }, "devDependencies": { "@preact/preset-vite": "^2.9.0", + "@tailwindcss/cli": "^4.1.13", + "tailwindcss": "^4.1.13", "typescript": "^5.3.0", "vite": "^6.0.0" } @@ -45,7 +48,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -772,6 +774,12 @@ "node": ">=18" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -822,6 +830,333 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@preact/preset-vite": { "version": "2.10.5", "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", @@ -1255,28 +1590,313 @@ "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/cli": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz", + "integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "enhanced-resolve": "^5.19.0", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.2.2" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -1285,7 +1905,10 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 20" + } }, "node_modules/@types/estree": { "version": "1.0.8", @@ -1344,7 +1967,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1435,6 +2057,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1501,6 +2133,20 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1616,6 +2262,13 @@ "node": ">=6.9.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1626,6 +2279,51 @@ "he": "bin/he" } }, + "node_modules/iconify-icon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-3.0.2.tgz", + "integrity": "sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1666,6 +2364,279 @@ "dev": true, "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1686,6 +2657,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1712,6 +2693,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-html-parser": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", @@ -1797,7 +2785,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -1809,7 +2796,6 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -1899,6 +2885,27 @@ "node": ">=16" } }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1967,7 +2974,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/cmd/scrapeui/frontend/package.json b/cmd/scrapeui/frontend/package.json index 0ccc6b7a7..9d2dbf344 100644 --- a/cmd/scrapeui/frontend/package.json +++ b/cmd/scrapeui/frontend/package.json @@ -3,14 +3,18 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", + "build": "vite build && npm run build:css", + "build:css": "tailwindcss -i ./src/styles.css -o ./dist/scrapeui.css --content './src/**/*.{ts,tsx}' --minify", "dev": "vite" }, "dependencies": { + "iconify-icon": "^3.0.2", "preact": "^10.25.0" }, "devDependencies": { "@preact/preset-vite": "^2.9.0", + "@tailwindcss/cli": "^4.1.13", + "tailwindcss": "^4.1.13", "typescript": "^5.3.0", "vite": "^6.0.0" } diff --git a/cmd/scrapeui/frontend/src/components/AliasList.tsx b/cmd/scrapeui/frontend/src/components/AliasList.tsx index 4101827de..444ebdbb1 100644 --- a/cmd/scrapeui/frontend/src/components/AliasList.tsx +++ b/cmd/scrapeui/frontend/src/components/AliasList.tsx @@ -10,7 +10,8 @@ export function AliasList({ aliases }: Props) {
  • {alias}