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..eaf566f68 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: @@ -77,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 @@ -116,7 +128,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 +226,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 +313,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/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 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..922d37de6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -1,17 +1,21 @@ package cmd import ( - "bytes" gocontext "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" + "net" "net/http" "os" + "os/exec" + "os/signal" "path" + "runtime" "strings" + "syscall" "time" "github.com/flanksource/clicky" @@ -22,6 +26,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,13 +49,15 @@ var outputDir string var debugPort int var export bool var save bool +var uiEnabled bool +var uiPort int // Run ... 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() { @@ -130,11 +137,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 +179,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 +214,10 @@ var Run = &cobra.Command{ } } + if uiServer != nil && summary != nil { + uiServer.SetLastScrapeSummary(*summary) + } + allResults = append(allResults, results...) if summary != nil { lastSummary = summary @@ -192,7 +230,50 @@ 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()) + } + + 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()) + } else { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + shutdown.Shutdown() + if hasErrors { + os.Exit(1) + } + return + } if hasErrors { os.Exit(1) @@ -554,11 +635,61 @@ func ensureScraper(ctx context.Context, sc *v1.ScrapeConfig) error { return nil } +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}) + 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) + + 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) + 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..1cb466820 --- /dev/null +++ b/cmd/scrapeui/assets.go @@ -0,0 +1,9 @@ +package scrapeui + +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/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..f021dc21c --- /dev/null +++ b/cmd/scrapeui/frontend/.gitignore @@ -0,0 +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 new file mode 100644 index 000000000..85601b6f3 --- /dev/null +++ b/cmd/scrapeui/frontend/package-lock.json @@ -0,0 +1,3079 @@ +{ + "name": "@flanksource/config-db-scrape-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "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" + } + }, + "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", + "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/@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", + "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/@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", + "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/@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/@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": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "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", + "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/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", + "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/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", + "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/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", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "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", + "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/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", + "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/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", + "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-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", + "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", + "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", + "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/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", + "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", + "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..9d2dbf344 --- /dev/null +++ b/cmd/scrapeui/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "@flanksource/config-db-scrape-ui", + "private": true, + "type": "module", + "scripts": { + "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/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..444ebdbb1 --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/AliasList.tsx @@ -0,0 +1,27 @@ +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..d1a181ddb --- /dev/null +++ b/cmd/scrapeui/frontend/src/components/DetailPanel.tsx @@ -0,0 +1,431 @@ +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..ff1311052 --- /dev/null +++ b/cmd/scrapeui/frontend/src/index.tsx @@ -0,0 +1,5 @@ +import 'iconify-icon'; +import { render } from 'preact'; +import { App } from './App'; + +render(, document.getElementById('root')!); diff --git a/cmd/scrapeui/frontend/src/styles.css b/cmd/scrapeui/frontend/src/styles.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/cmd/scrapeui/frontend/src/styles.css @@ -0,0 +1 @@ +@import "tailwindcss"; 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/safe_buffer.go b/cmd/scrapeui/safe_buffer.go new file mode 100644 index 000000000..8a65be4fc --- /dev/null +++ b/cmd/scrapeui/safe_buffer.go @@ -0,0 +1,24 @@ +package scrapeui + +import "sync" + +// SafeBuffer is a concurrency-safe string buffer used for log fan-out. +// It implements io.Writer and protects concurrent reads/writes. +type SafeBuffer struct { + mu sync.RWMutex + buf []byte +} + +func (b *SafeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + b.buf = append(b.buf, p...) + b.mu.Unlock() + return len(p), nil +} + +func (b *SafeBuffer) String() string { + b.mu.RLock() + s := string(b.buf) + b.mu.RUnlock() + return s +} diff --git a/cmd/scrapeui/server.go b/cmd/scrapeui/server.go new file mode 100644 index 000000000..c9b3192be --- /dev/null +++ b/cmd/scrapeui/server.go @@ -0,0 +1,465 @@ +package scrapeui + +import ( + "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 *SafeBuffer + 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 *SafeBuffer) *Server { + scrapers := make([]ScraperProgress, len(scraperNames)) + for i, name := range scraperNames { + scrapers[i] = ScraperProgress{ + RawName: name, + 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() + + for i := range s.scrapers { + if s.scrapers[i].RawName != name { + 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] = 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() + } + for i := range snap.Scrapers { + if snap.Scrapers[i].RawName == "" { + snap.Scrapers[i].RawName = snap.Scrapers[i].Name + } + } + return &Server{ + scrapers: snap.Scrapers, + results: snap.Results, + relationships: uiRels, + configMeta: configMeta, + issues: snap.Issues, + summary: snap.SaveSummary, + snapshots: snap.Snapshots, + har: snap.HAR, + scrapeSpec: snap.ScrapeSpec, + properties: snap.Properties, + logLevel: snap.LogLevel, + done: true, + startedAt: snap.StartedAt, + buildInfo: snap.BuildInfo, + lastScrapeSummary: snap.LastScrapeSummary, + 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") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") + 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..30153630e --- /dev/null +++ b/cmd/scrapeui/types.go @@ -0,0 +1,116 @@ +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 { + RawName string `json:"-"` + 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)") +}