diff --git a/.gitignore b/.gitignore index f4275c1c0..b8a06d526 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,16 @@ jepsen/.ssh/ .cache/ .golangci-cache/ server + +# Admin SPA build outputs. The placeholder internal/admin/dist/index.html +# is committed so `go build` succeeds in a fresh clone (the //go:embed +# directive needs at least one matching file). Everything else under +# dist/ is regenerated by `cd web/admin && npm run build` and must not +# be committed — committed bundles invariably drift from source. +/internal/admin/dist/assets/ +/internal/admin/dist/index-*.js +/internal/admin/dist/index-*.css + +# Admin SPA source toolchain +/web/admin/node_modules/ +/web/admin/.vite/ diff --git a/adapter/sqs_admin.go b/adapter/sqs_admin.go new file mode 100644 index 000000000..74efe2069 --- /dev/null +++ b/adapter/sqs_admin.go @@ -0,0 +1,153 @@ +package adapter + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/cockroachdb/errors" +) + +// AdminQueueSummary is the per-queue projection the admin dashboard +// surfaces. It deliberately covers only the fields the SPA renders so +// the package's wire-format types stay internal. +// +// Counters mirror the AWS Approximate* attribute set produced by +// computeApproxCounters; they are best-effort by AWS contract and may +// be reported as a lower bound when the visibility-index scan hits its +// per-call budget (CountersTruncated=true in that case). +type AdminQueueSummary struct { + Name string + IsFIFO bool + Generation uint64 + CreatedAt time.Time + Attributes map[string]string + Counters AdminQueueCounters + CountersTruncated bool +} + +// AdminQueueCounters mirrors the three Approximate* counters the +// dashboard polls. Visible / NotVisible / Delayed have the same +// definitions as in §16.1 of the SQS design doc. +type AdminQueueCounters struct { + Visible int + NotVisible int + Delayed int +} + +// AdminListQueues returns every queue name this server knows about, +// in the lexicographic order the queue catalog index produces. Read +// path; runs on follower or leader and uses the same scanQueueNames +// helper the SigV4 ListQueues handler does. +func (s *SQSServer) AdminListQueues(ctx context.Context) ([]string, error) { + return s.scanQueueNames(ctx) //nolint:wrapcheck // pure pass-through; the adapter owns the error context. +} + +// AdminDescribeQueue returns a snapshot of name's metadata plus the +// approximate counters. The triple (result, present, error) lets +// admin callers distinguish a missing queue from a storage error +// without sniffing sentinels. +// +// Like AdminDescribeTable on the Dynamo side, this entrypoint runs +// on either the leader or a follower (read-only); the counter scan +// uses a fresh nextTxnReadTS so the result is consistent with what +// SigV4 GetQueueAttributes would have returned at the same instant. +func (s *SQSServer) AdminDescribeQueue(ctx context.Context, name string) (*AdminQueueSummary, bool, error) { + if strings.TrimSpace(name) == "" { + return nil, false, ErrAdminSQSValidation + } + readTS := s.nextTxnReadTS(ctx) + meta, exists, err := s.loadQueueMetaAt(ctx, name, readTS) + if err != nil { + return nil, false, errors.WithStack(err) + } + if !exists { + return nil, false, nil + } + counters, err := s.computeApproxCounters(ctx, name, meta.Generation, readTS) + if err != nil { + return nil, false, err + } + summary := &AdminQueueSummary{ + Name: name, + IsFIFO: meta.IsFIFO, + Generation: meta.Generation, + CreatedAt: hlcToTime(meta.CreatedAtHLC), + Attributes: metaAttributesForAdmin(meta), + Counters: AdminQueueCounters{Visible: counters.Visible, NotVisible: counters.NotVisible, Delayed: counters.Delayed}, + CountersTruncated: counters.Truncated, + } + return summary, true, nil +} + +// AdminDeleteQueue is the SigV4-bypass counterpart to deleteQueue. +// Returns the same sentinel errors as AdminCreateTable on the Dynamo +// side: ErrAdminForbidden on a read-only principal, ErrAdminNotLeader +// on a follower, ErrAdminSQSNotFound when the queue is absent. +func (s *SQSServer) AdminDeleteQueue(ctx context.Context, principal AdminPrincipal, name string) error { + if !principal.Role.canWrite() { + return ErrAdminForbidden + } + if !isVerifiedSQSLeader(s.coordinator) { + return ErrAdminNotLeader + } + if strings.TrimSpace(name) == "" { + return ErrAdminSQSValidation + } + if err := s.deleteQueueWithRetry(ctx, name); err != nil { + // deleteQueueWithRetry returns sqsAPIError with + // sqsErrQueueDoesNotExist when the queue is missing; map + // to the structured ErrAdminSQSNotFound so the admin + // handler can render 404 without sniffing the AWS code. + if isSQSAdminQueueDoesNotExist(err) { + return ErrAdminSQSNotFound + } + return errors.Wrap(err, "admin delete queue") + } + return nil +} + +// metaAttributesForAdmin renders the queue meta into the same shape +// queueMetaToAttributes("All") would, minus the counters (the admin +// summary surfaces them as a typed struct alongside, not as strings). +// Kept as a small dedicated helper so the SigV4 path's selection +// machinery stays untouched. +func metaAttributesForAdmin(meta *sqsQueueMeta) map[string]string { + out := map[string]string{ + "VisibilityTimeout": strconv.FormatInt(meta.VisibilityTimeoutSeconds, 10), + "MessageRetentionPeriod": strconv.FormatInt(meta.MessageRetentionSeconds, 10), + "DelaySeconds": strconv.FormatInt(meta.DelaySeconds, 10), + "ReceiveMessageWaitTimeSeconds": strconv.FormatInt(meta.ReceiveMessageWaitSeconds, 10), + "MaximumMessageSize": strconv.FormatInt(meta.MaximumMessageSize, 10), + "FifoQueue": strconv.FormatBool(meta.IsFIFO), + "ContentBasedDeduplication": strconv.FormatBool(meta.ContentBasedDedup), + } + if meta.RedrivePolicy != "" { + out["RedrivePolicy"] = meta.RedrivePolicy + } + return out +} + +// ErrAdminSQSValidation is returned when an admin entrypoint receives +// a request with a missing or syntactically-bad queue name. Maps to +// 400 in the admin HTTP handler. +var ErrAdminSQSValidation = errors.New("sqs admin: invalid queue name") + +// ErrAdminSQSNotFound is returned by write entrypoints when the +// target queue does not exist. Maps to 404. The describe path uses +// the (nil, false, nil) tuple instead of this sentinel for the +// not-found signal, mirroring AdminDescribeTable. +var ErrAdminSQSNotFound = errors.New("sqs admin: queue not found") + +// isSQSAdminQueueDoesNotExist matches the deleteQueueWithRetry path's +// "queue does not exist" sqsAPIError so AdminDeleteQueue can normalise +// it to ErrAdminSQSNotFound. Falls through to false on any unrelated +// error, which AdminDeleteQueue then wraps and propagates. +func isSQSAdminQueueDoesNotExist(err error) bool { + var apiErr *sqsAPIError + if !errors.As(err, &apiErr) || apiErr == nil { + return false + } + return apiErr.errorType == sqsErrQueueDoesNotExist +} diff --git a/adapter/sqs_catalog.go b/adapter/sqs_catalog.go index 13c9d02f9..e3b083ac2 100644 --- a/adapter/sqs_catalog.go +++ b/adapter/sqs_catalog.go @@ -777,7 +777,8 @@ func (s *SQSServer) getQueueAttributes(w http.ResponseWriter, r *http.Request) { writeSQSErrorFromErr(w, err) return } - meta, exists, err := s.loadQueueMetaAt(r.Context(), name, s.nextTxnReadTS(r.Context())) + readTS := s.nextTxnReadTS(r.Context()) + meta, exists, err := s.loadQueueMetaAt(r.Context(), name, readTS) if err != nil { writeSQSErrorFromErr(w, err) return @@ -787,10 +788,50 @@ func (s *SQSServer) getQueueAttributes(w http.ResponseWriter, r *http.Request) { return } selection := selectedAttributeNames(in.AttributeNames) - attrs := queueMetaToAttributes(meta, selection) + // Compute the approximate counters only when the caller is + // asking for them. AWS lists them on the "All" set so an + // AttributeNames=All caller pays the scan; an explicit-name + // caller only pays it if they listed at least one of the three. + // This keeps GetQueueAttributes O(1) for the common config-only + // callers while still serving operator dashboards that ask for + // the counters on every poll. + var counters *sqsApproxCounters + if approxCountersRequested(selection) { + c, cerr := s.computeApproxCounters(r.Context(), name, meta.Generation, readTS) + if cerr != nil { + writeSQSErrorFromErr(w, cerr) + return + } + counters = &c + } + attrs := queueMetaToAttributes(meta, selection, counters) writeSQSJSON(w, map[string]any{"Attributes": attrs}) } +// approxCountersRequested reports whether the caller asked for any of +// the three Approximate* counters (or for "All", which implies them). +func approxCountersRequested(sel sqsAttributeSelection) bool { + if sel.expandAll { + return true + } + for _, name := range approxCounterAttributeNames { + if sel.names[name] { + return true + } + } + return false +} + +// approxCounterAttributeNames is the set of GetQueueAttributes names +// that trigger the visibility-index scan in computeApproxCounters. +// Kept as a package-level constant slice so approxCountersRequested +// stays cheap and the names are listed in one place. +var approxCounterAttributeNames = [...]string{ + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + "ApproximateNumberOfMessagesDelayed", +} + // sqsAttributeSelection is a tri-state result from selectedAttributeNames: // expandAll = AWS "All" (or any entry equals "All"); a non-nil map lists // the specific attribute names the caller asked for; and an empty @@ -823,7 +864,7 @@ func selectedAttributeNames(req []string) sqsAttributeSelection { return sqsAttributeSelection{names: selection} } -func queueMetaToAttributes(meta *sqsQueueMeta, selection sqsAttributeSelection) map[string]string { +func queueMetaToAttributes(meta *sqsQueueMeta, selection sqsAttributeSelection, counters *sqsApproxCounters) map[string]string { // No AttributeNames supplied and no "All" → AWS returns nothing. // The handler still emits "Attributes" as an empty map so the // response shape is stable. @@ -842,6 +883,15 @@ func queueMetaToAttributes(meta *sqsQueueMeta, selection sqsAttributeSelection) if meta.RedrivePolicy != "" { all["RedrivePolicy"] = meta.RedrivePolicy } + // The three counters are populated only when the caller asked + // for them (or for "All"). When counters is nil the keys stay + // out of `all`, so the explicit-name selection branch below + // will not return zero strings for unrequested counters. + if counters != nil { + all["ApproximateNumberOfMessages"] = strconv.Itoa(counters.Visible) + all["ApproximateNumberOfMessagesNotVisible"] = strconv.Itoa(counters.NotVisible) + all["ApproximateNumberOfMessagesDelayed"] = strconv.Itoa(counters.Delayed) + } if selection.expandAll { return all } diff --git a/adapter/sqs_catalog_test.go b/adapter/sqs_catalog_test.go index d57cd8648..ba41c436c 100644 --- a/adapter/sqs_catalog_test.go +++ b/adapter/sqs_catalog_test.go @@ -5,6 +5,7 @@ import ( "context" "io" "net/http" + "strconv" "strings" "testing" @@ -331,6 +332,108 @@ func TestSQSServer_GetQueueAttributesOmittedReturnsEmpty(t *testing.T) { } } +func TestSQSServer_GetQueueAttributesApproxCounters(t *testing.T) { + t.Parallel() + // AWS exposes three Approximate* counters on GetQueueAttributes: + // - ApproximateNumberOfMessages (visible right now) + // - ApproximateNumberOfMessagesNotVisible (in-flight after receive) + // - ApproximateNumberOfMessagesDelayed (sent with DelaySeconds, not yet eligible) + // Pre-Phase-3.A the adapter returned none of them. This test pins + // (a) that they appear under the All selector, (b) that they + // classify the three states correctly, and (c) that they appear + // only when explicitly requested or via All. + nodes, _, _ := createNode(t, 1) + defer shutdown(nodes) + node := sqsLeaderNode(t, nodes) + url := createSQSQueueForTest(t, node, "approx-counters") + + // Send 3 ready messages and one delayed message. + for i := range 3 { + _, _ = callSQS(t, node, sqsSendMessageTarget, map[string]any{ + "QueueUrl": url, + "MessageBody": "ready-" + strconv.Itoa(i), + }) + } + _, _ = callSQS(t, node, sqsSendMessageTarget, map[string]any{ + "QueueUrl": url, + "MessageBody": "delayed", + "DelaySeconds": 60, + }) + + // Receive 1 → in-flight. + _, _ = callSQS(t, node, sqsReceiveMessageTarget, map[string]any{ + "QueueUrl": url, + "MaxNumberOfMessages": 1, + "VisibilityTimeout": 60, + }) + + status, out := callSQS(t, node, sqsGetQueueAttributesTarget, map[string]any{ + "QueueUrl": url, + "AttributeNames": []string{"All"}, + }) + if status != http.StatusOK { + t.Fatalf("getAttrs All: %d %v", status, out) + } + attrs, _ := out["Attributes"].(map[string]any) + if got := attrs["ApproximateNumberOfMessages"]; got != "2" { + t.Errorf("ApproximateNumberOfMessages = %v, want 2 (3 sent - 1 in-flight)", got) + } + if got := attrs["ApproximateNumberOfMessagesNotVisible"]; got != "1" { + t.Errorf("ApproximateNumberOfMessagesNotVisible = %v, want 1", got) + } + if got := attrs["ApproximateNumberOfMessagesDelayed"]; got != "1" { + t.Errorf("ApproximateNumberOfMessagesDelayed = %v, want 1", got) + } +} + +func TestSQSServer_GetQueueAttributesApproxCountersOnlyWhenSelected(t *testing.T) { + t.Parallel() + // The Approximate* counters trigger a visibility-index scan, so + // they must NOT be returned for callers that asked for unrelated + // attributes — both for cost and for AWS-shape parity (an + // explicit-name request only returns the listed names). + nodes, _, _ := createNode(t, 1) + defer shutdown(nodes) + node := sqsLeaderNode(t, nodes) + url := createSQSQueueForTest(t, node, "approx-isolation") + _, _ = callSQS(t, node, sqsSendMessageTarget, map[string]any{ + "QueueUrl": url, + "MessageBody": "x", + }) + + // Only VisibilityTimeout: counters must be absent. + status, out := callSQS(t, node, sqsGetQueueAttributesTarget, map[string]any{ + "QueueUrl": url, + "AttributeNames": []string{"VisibilityTimeout"}, + }) + if status != http.StatusOK { + t.Fatalf("getAttrs VisibilityTimeout: %d %v", status, out) + } + attrs, _ := out["Attributes"].(map[string]any) + if _, present := attrs["ApproximateNumberOfMessages"]; present { + t.Errorf("counters leaked into VisibilityTimeout-only request: %v", attrs) + } + if got := attrs["VisibilityTimeout"]; got == nil { + t.Errorf("VisibilityTimeout missing: %v", attrs) + } + + // Only ApproximateNumberOfMessages: counter present, nothing else. + status, out = callSQS(t, node, sqsGetQueueAttributesTarget, map[string]any{ + "QueueUrl": url, + "AttributeNames": []string{"ApproximateNumberOfMessages"}, + }) + if status != http.StatusOK { + t.Fatalf("getAttrs ApproximateNumberOfMessages: %d %v", status, out) + } + attrs, _ = out["Attributes"].(map[string]any) + if got := attrs["ApproximateNumberOfMessages"]; got != "1" { + t.Errorf("ApproximateNumberOfMessages = %v, want 1", got) + } + if _, present := attrs["VisibilityTimeout"]; present { + t.Errorf("VisibilityTimeout leaked into counter-only request: %v", attrs) + } +} + func TestSQSServer_SetQueueAttributesRequiresAttributes(t *testing.T) { t.Parallel() nodes, _, _ := createNode(t, 1) diff --git a/adapter/sqs_counters.go b/adapter/sqs_counters.go new file mode 100644 index 000000000..438b69f69 --- /dev/null +++ b/adapter/sqs_counters.go @@ -0,0 +1,192 @@ +package adapter + +import ( + "bytes" + "context" + "encoding/binary" + "math" + "time" + + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +// sqsApproxCounters holds the per-state message counts AWS returns +// from GetQueueAttributes. AWS itself documents these as approximate; +// this implementation caps the visibility-index scan at +// sqsApproxCounterScanBudget records — beyond that, the counts are a +// lower bound and Truncated is true so callers can log the breach. +type sqsApproxCounters struct { + Visible int + NotVisible int + Delayed int + Truncated bool +} + +const ( + // Budget on how many vis-index entries one GetQueueAttributes call + // touches. Tuned to keep the call well under a 100 ms wall-clock + // budget on Pebble: each entry is one cheap key parse plus + // (rarely) one point GetAt for the in-flight / delayed + // disambiguation. + sqsApproxCounterScanBudget = 5000 + sqsApproxCounterPageLimit = 1024 +) + +// computeApproxCounters classifies every visibility-index entry of a +// queue at the supplied snapshot read TS into the three AWS states: +// visible (ready), not-visible (in-flight), delayed. +// +// Strategy: +// - The vis-index key embeds the current visibility deadline as the +// last fixed-width u64 segment, so visible_at can be extracted +// from the key alone (no GetAt) for the common ready-message case. +// - For entries with visible_at > now we need to disambiguate +// "delayed (never received)" from "in-flight (received, vis +// bumped)" by inspecting the data record's available_at_millis. +// Both fields start equal on send and only diverge when receive +// bumps visible_at, so available_at vs now is the distinguishing +// signal. +// +// The function bounds the cost by capping at sqsApproxCounterScanBudget +// total entries inspected. Above that, Truncated=true and the caller +// can log a warning; AWS's "approximate" contract makes the lower +// bound legal. +func (s *SQSServer) computeApproxCounters(ctx context.Context, queueName string, gen uint64, readTS uint64) (sqsApproxCounters, error) { + var out sqsApproxCounters + now := time.Now().UnixMilli() + prefix := sqsMsgVisPrefixForQueue(queueName, gen) + upper := prefixScanEnd(prefix) + visAtOffset := len(prefix) + + start := bytes.Clone(prefix) + visited := 0 + for visited < sqsApproxCounterScanBudget { + page, err := s.store.ScanAt(ctx, start, upper, sqsApproxCounterPageLimit, readTS) + if err != nil { + return out, errors.WithStack(err) + } + if len(page) == 0 { + return out, nil + } + done, newVisited, err := s.classifyApproxCounterPage(ctx, queueName, gen, page, visAtOffset, now, readTS, visited, &out) + if err != nil { + return out, err + } + visited = newVisited + if done { + return out, nil + } + if len(page) < sqsApproxCounterPageLimit { + return out, nil + } + start = nextScanCursorAfter(page[len(page)-1].Key) + if bytes.Compare(start, upper) >= 0 { + return out, nil + } + } + out.Truncated = true + return out, nil +} + +// classifyApproxCounterPage processes one ScanAt page. Returns +// done=true when the per-call budget is exhausted (caller should mark +// Truncated and stop) or when the page itself is short. Split out so +// computeApproxCounters stays under the cyclop budget. +func (s *SQSServer) classifyApproxCounterPage( + ctx context.Context, + queueName string, + gen uint64, + page []*store.KVPair, + visAtOffset int, + now int64, + readTS uint64, + visited int, + out *sqsApproxCounters, +) (bool, int, error) { + for _, kvp := range page { + if visited >= sqsApproxCounterScanBudget { + out.Truncated = true + return true, visited, nil + } + visited++ + if len(kvp.Key) < visAtOffset+8 { + // Malformed key (shouldn't happen for keys we wrote). + // Skip rather than fail the whole counter call. + continue + } + visAtRaw := binary.BigEndian.Uint64(kvp.Key[visAtOffset : visAtOffset+8]) + // vis_at is encoded as uint64 wall-clock millis. Clamp on the + // off chance a corrupted key carries a value past MaxInt64 + // before the int64 conversion — otherwise a hostile / corrupt + // row would wrap to a negative value and silently land in the + // "visible" bucket. Real timestamps stay well under MaxInt64 + // for the next ~292 million years, so this is purely + // defence in depth. + visAt := int64(math.MaxInt64) + if visAtRaw <= math.MaxInt64 { + visAt = int64(visAtRaw) + } + if visAt <= now { + out.Visible++ + continue + } + // visible_at > now: either Delayed or NotVisible. Load the + // data record so we can distinguish on available_at. + state, err := s.classifyHiddenCandidate(ctx, queueName, gen, string(kvp.Value), now, readTS) + if err != nil { + return true, visited, err + } + switch state { + case approxCounterDelayed: + out.Delayed++ + case approxCounterNotVisible: + out.NotVisible++ + case approxCounterSkipped: + // race: data record gone between scan and load — skip. + } + } + return false, visited, nil +} + +type approxCounterClass int + +const ( + approxCounterSkipped approxCounterClass = iota + approxCounterDelayed + approxCounterNotVisible +) + +// classifyHiddenCandidate disambiguates a vis-index entry whose +// visible_at > now. Loads the data record once and decides based on +// its available_at: +// - available_at > now → Delayed (never received, just not yet +// eligible). +// - available_at <= now → NotVisible (received, vis was bumped). +// +// On a vis/data race (data already deleted) we return Skipped so the +// counter does not double-charge a record the storage no longer +// agrees exists. +func (s *SQSServer) classifyHiddenCandidate(ctx context.Context, queueName string, gen uint64, messageID string, now int64, readTS uint64) (approxCounterClass, error) { + dataKey := sqsMsgDataKey(queueName, gen, messageID) + raw, err := s.store.GetAt(ctx, dataKey, readTS) + if err != nil { + if errors.Is(err, store.ErrKeyNotFound) { + return approxCounterSkipped, nil + } + return approxCounterSkipped, errors.WithStack(err) + } + rec, err := decodeSQSMessageRecord(raw) + if err != nil { + // A genuinely corrupt record on disk should *surface* through + // the GetQueueAttributes call (a 500 the operator can act on) + // rather than be silently bucketed into "skipped". The reaper + // also catches this lazily, but here we have a chance to + // alert immediately. + return approxCounterSkipped, errors.Wrapf(err, "decode message record %s", messageID) + } + if rec.AvailableAtMillis > now { + return approxCounterDelayed, nil + } + return approxCounterNotVisible, nil +} diff --git a/docs/design/2026_04_24_proposed_sqs_compatible_adapter.md b/docs/design/2026_04_24_partial_sqs_compatible_adapter.md similarity index 79% rename from docs/design/2026_04_24_proposed_sqs_compatible_adapter.md rename to docs/design/2026_04_24_partial_sqs_compatible_adapter.md index ed036ba24..1b04bd1d1 100644 --- a/docs/design/2026_04_24_proposed_sqs_compatible_adapter.md +++ b/docs/design/2026_04_24_partial_sqs_compatible_adapter.md @@ -1,8 +1,31 @@ # SQS-Compatible Adapter Design for Elastickv -Status: Proposed +Status: Partial — Phase 1 + 2 landed, Phase 3 in progress Author: bootjp Date: 2026-04-24 +Last Updated: 2026-04-26 + +--- + +## Implementation Status (2026-04-26) + +| Phase | Scope | Status | Reference | +|---|---|---|---| +| Phase 1 | Catalog, standard send/receive/delete, long polling, SigV4, leader proxy, metrics | **Landed** | merged across earlier PRs | +| Phase 2 | FIFO (single-partition), batch APIs, DLQ redrive, tag APIs, retention reaper | **Landed** | PR #638 (Milestone 1 finish) | +| **Phase 3 — partial** | See breakdown below | **In progress** | this commit + follow-ups | + +### Phase 3 breakdown + +| # | Item | Status | Notes | +|---|---|---|---| +| 3.A | `ApproximateNumberOfMessages*` accuracy (visible / not-visible / delayed) | **Landed in this commit** | See §16.1 | +| 3.B | XML query protocol full fidelity (older AWS SDKs) | **TODO** | Additive; SigV4 path stays JSON. See §16.4 | +| 3.C | Per-queue throttling and tenant fairness | **TODO** | New component; needs separate design doc before implementation. See §16.5 | +| 3.D | Split-queue FIFO (very hot queues across shards while preserving FIFO) | **TODO** | Touches replication / routing; needs separate design doc. See §16.6 | +| 3.E | Console UI (operator GUI for queues) | **Re-scoped to admin SPA in this commit** | See §16.2 — Section 13 (separate console listener) is **superseded** | + +The original Section 13 Console UI design (separate `--consoleAddress` listener with bearer-token auth + dedicated go:embed bundle) has been **superseded** by §16.2 below: queues UI is added as additional pages on the existing admin dashboard SPA (`internal/admin/dist`, served from the admin listener). This avoids a second listener / second auth surface and reuses the JWT cookie + CSRF + role model the admin dashboard already enforces. The original §13 text is preserved for historical context but its conclusions no longer apply. --- @@ -709,3 +732,117 @@ The two repository-level prerequisites are: 2. A leader-local long-poll notifier subscribed to the FSM commit stream. With those in place, SQS compatibility can be added without changing the underlying storage, consensus, or HLC design. + +--- + +## 16. Phase 3 detailed designs + +This section was added 2026-04-26 to record the as-built design for Phase 3 items that have either landed in this commit or are queued as TODOs. + +### 16.1 Approximate counters (3.A — landed in this commit) + +`GetQueueAttributes` previously omitted every `ApproximateNumberOfMessages*` attribute (`adapter/sqs_catalog.go:queueMetaToAttributes` only returned configuration fields). This commit adds three counters: + +| Attribute | Definition (this implementation) | +|---|---| +| `ApproximateNumberOfMessages` | Records whose `available_at <= now` and `visible_at <= now` — i.e. ready to be received by the next `ReceiveMessage`. | +| `ApproximateNumberOfMessagesNotVisible` | Records whose `available_at <= now` but `visible_at > now` — already delivered, currently in flight, not yet deleted. | +| `ApproximateNumberOfMessagesDelayed` | Records whose `available_at > now` — sent with a non-zero `DelaySeconds` (or queue-level `DelaySeconds`) and not yet eligible for delivery. | + +#### Computation path + +The visibility index (`!sqs|msg|byage|||`) is keyed by `available_at`, so it already orders records by the eligibility frontier we care about. The implementation: + +1. Loads the queue meta at `readTS = nextTxnReadTS(ctx)` (same TS as the rest of `getQueueAttributes`). +2. Walks the visibility index for the *current* `meta.Generation` only (orphans from prior generations are out of scope; the retention reaper handles them). +3. Loads each candidate's data record once and classifies by (`available_at`, `visible_at`) at a single `now = time.Now().UnixMilli()`. +4. Returns the three counters as strings, matching AWS's response shape. + +Bound: the scan stops at the per-queue budget already used by the reaper (`sqsReaperPerQueueBudget`) so a pathologically large queue cannot turn `GetQueueAttributes` into an O(stream) scan. When the budget is hit, the counters are reported as a *lower bound* (AWS already documents them as approximate), and a `truncated=true` marker is logged for operator awareness. + +#### Why scan instead of incremental counters + +A maintained counter (incremented on `SendMessage`, decremented on `DeleteMessage`) would be cheaper but requires: + +- Per-state counters (visible/in-flight/delayed) updated on every state transition, including the implicit transitions when a visibility timeout expires, when a delayed message becomes available, when redrive moves a record, and when a FIFO group lock releases the head. +- Four extra Raft writes per message lifecycle, defeating the cost saving. + +The scan-at-snapshot approach matches AWS's "approximate" contract, has zero write-path overhead, and aligns with how the reaper already walks the same prefix. + +#### Tests + +- `adapter/sqs_extra_test.go: TestSQSServer_GetQueueAttributesApproxCounters` — sends N messages, receives K (so K go into in-flight), sends one with `DelaySeconds`, asserts each counter. +- `adapter/sqs_extra_test.go: TestSQSServer_GetQueueAttributesApproxCountersOnlyWhenSelected` — pins that the counters are only returned when the caller explicitly asks for them or for `All` (per AWS GetQueueAttributes semantics). + +### 16.2 Operator UI: SQS pages on the existing admin SPA (3.E — landed in this commit) + +The original §13 console design (separate listener, bearer token, dedicated go:embed bundle) is **superseded**. Rationale: + +1. The admin dashboard SPA (`internal/admin/dist`, PR #649) already ships with: JWT cookie session auth, CSRF double-submit, `read_only_access_keys` / `full_access_keys` role model, embedded React + TS + Tailwind frontend, and a leader-aware HTTP backend. Building a second console with its own listener would duplicate every one of those. +2. The admin listener is already loopback-by-default with TLS-enforced non-loopback exposure (per `docs/design/2026_04_24_partial_admin_dashboard.md` §7.1) — the *exact* operational posture §13 wanted for the console. +3. A separate `--consoleAddress` listener forces operators to manage two TLS configs, two firewall rules, and two token rotations; adding pages to the admin SPA does not. + +#### Backend: admin gRPC bridge — `internal/admin/sqs_handler.go` + +Adds four endpoints under `/admin/api/v1/sqs/queues`: + +| Method | Path | Auth | Body | +|---|---|---|---| +| `GET` | `/admin/api/v1/sqs/queues` | session | — | +| `GET` | `/admin/api/v1/sqs/queues/{name}` | session | — | +| `POST` | `/admin/api/v1/sqs/queues/{name}/purge` | session + full role + CSRF | `{}` | +| `DELETE` | `/admin/api/v1/sqs/queues/{name}` | session + full role + CSRF | — | + +These are SigV4-bypass internal entrypoints exposed as `(*adapter.SQSServer).AdminListQueues` / `AdminDescribeQueue` / `AdminPurgeQueue` / `AdminDeleteQueue`, mirroring the DynamoDB `Admin*` pattern (`adapter/dynamodb_admin.go`). The bridge wiring in `main_admin.go` (`sqsQueuesBridge`) keeps `internal/admin` free of the heavy adapter dependency tree, the same architectural separation the dynamo bridge uses. Authorization is re-evaluated on the adapter side (the SigV4 path stays untouched). + +#### Frontend: `web/admin/src/pages/SqsList.tsx`, `SqsDetail.tsx` + +- `/sqs` — queue list + Purge / Delete (full role only). Refresh button; empty-state messaging consistent with the Dynamo / S3 pages. +- `/sqs/:name` — queue detail showing every attribute returned by `GetQueueAttributes`, including the new approx counters from §16.1, plus Purge and Delete affordances behind `RequireFullAccess`. + +The navigation in `Layout.tsx` is extended with an `SQS` tab between `DynamoDB` and `S3`. The API client (`web/admin/src/api/client.ts`) gets `listQueues` / `describeQueue` / `purgeQueue` / `deleteQueue` methods, mirroring the AbortSignal plumbing established for `cluster` / `listTables` / `describeTable`. + +#### Out of scope for this commit (admin SPA, follow-ups) + +These were considered but deferred to keep the PR focused. Each is small enough to land independently: + +- **Send a test message** from the SPA — needs an admin RPC that bypasses SigV4 and accepts `(MessageBody, MessageGroupId?, MessageDeduplicationId?, DelaySeconds?)`. Modest backend addition; UX touches the existing Modal. +- **Peek without consuming** — needs a new adapter method that scans the visibility index and returns N records *without* bumping `ReceiveCount` or rotating receipt tokens. This is genuinely new functionality (the SQS API has no peek primitive) and merits its own design discussion. +- **Create queue from the SPA** — needs `AdminCreateQueue` plus a Modal form covering FIFO / RedrivePolicy / DelaySeconds / MessageRetentionPeriod. Useful but not blocking for operator visibility, which is the value the SPA delivers in this commit. + +### 16.3 Section 13 — historical + +The text in Section 13 (Console UI) describes an alternate architecture that was on the table but **not** built. It is preserved unedited for context; readers should treat §16.2 as the as-built design and ignore §13's concrete decisions (separate listener, bearer token, etc.). Section 13 is kept rather than deleted so the rationale for the Phase 1/2 sketch remains discoverable in `git blame`. + +### 16.4 XML query protocol fidelity (3.B — TODO) + +AWS still ships an older "query" protocol (form-encoded request, XML response) used by `aws-sdk-java` v1, older `boto`, and a long tail of in-house clients. The current implementation only handles the JSON protocol (`X-Amz-Target` header + JSON body, response is JSON). Adding query-protocol support is *additive* — it does not change the JSON path — but it requires: + +1. Detecting the protocol from request shape (`Content-Type: application/x-www-form-urlencoded` + `Action=…` query parameter). +2. Generating the matching XML response envelopes (`......`) per the [AWS SQS QueryProtocol reference](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/CommonErrors.html). +3. Sharing every internal handler (the existing `s.sendMessage(ctx, in, principal)` etc.) — only the wire codec changes. + +**Risk**: low. **Effort**: medium (mostly XML serialization). **Recommended next step**: own design doc proposal (`*_proposed_*`) before implementation, even though there is no consistency / replication impact, because the wire format work has its own surface area. + +### 16.5 Per-queue throttling and tenant fairness (3.C — TODO) + +Multiple tenants sharing one elastickv cluster need per-queue rate limits so a single noisy queue cannot starve everyone else. The expected shape: + +- Token-bucket per (queue, action) limited at the SQS adapter (not the storage layer — token replenishment rate must be local to the leader for the throttled queue, and the action context is gone by the time a request reaches the coordinator). +- Limits configured per-queue in queue meta (`maxRequestsPerSecond`, optionally per action). +- Throttled requests return AWS's `Throttling.Sender` error (HTTP 400, `Throttling` code) with `Retry-After`, *not* a hard 429, so AWS SDKs' built-in retry/backoff logic engages naturally. + +**Risk**: medium-high (a wrong default can produce client-visible 4xx storms). **Effort**: large. **Recommended next step**: `*_proposed_*` design doc that covers the limit storage model, the algorithm, follower forwarding semantics (whether throttle decisions ride the leader proxy or are evaluated per-node), and how Jepsen workloads should be adjusted. + +### 16.6 Split-queue FIFO (3.D — TODO) + +A single FIFO queue's per-second throughput is bounded by the per-shard write rate. AWS supports virtually-unlimited FIFO throughput via *partitioned* FIFO queues where ordering is preserved within each `MessageGroupId` but parallelized across groups. To mirror this in elastickv, a single hot queue would be split across multiple raft groups, with `MessageGroupId` deterministically routed to a partition. + +This is **the** large item in Phase 3. It touches: + +- Routing (`kv/shard_router.go`) — group-aware queue routing. +- Replication topology — splitting an existing queue without dropping messages requires the cross-shard transaction primitive that does not exist yet for SQS. +- FIFO group-lock semantics — the lock now needs to live on the partition that owns the group, not the queue. +- Reaper / metrics / counters — every Phase 1/2 component that scans a queue's keyspace must learn to scan all of its partitions. + +**Risk**: high (consistency-critical, irreversible if mis-designed). **Effort**: very large (multiple PRs). **Recommended next step**: separate `*_proposed_*` design doc that proposes the partition assignment scheme, the migration path for existing single-partition queues, and the rollback story; do not start implementation until that doc is reviewed and accepted. diff --git a/internal/admin/dist/index.html b/internal/admin/dist/index.html new file mode 100644 index 000000000..803e8c7e5 --- /dev/null +++ b/internal/admin/dist/index.html @@ -0,0 +1,24 @@ + + + + + + elastickv admin (bundle missing) + + + +

elastickv admin SPA bundle is missing

+

This is the placeholder index.html shipped in the repository so that go build succeeds before the React bundle is built.

+

To populate the real dashboard:

+
cd web/admin
+npm install
+npm run build
+

The Vite build writes its output into internal/admin/dist/, replacing this placeholder. Rebuild the Go binary afterwards.

+ + diff --git a/internal/admin/embed.go b/internal/admin/embed.go new file mode 100644 index 000000000..596064292 --- /dev/null +++ b/internal/admin/embed.go @@ -0,0 +1,39 @@ +package admin + +import ( + "embed" + "errors" + "io/fs" +) + +// distFS holds the Vite build output for the admin SPA. The directory +// is populated by `npm run build` under web/admin/, which writes its +// output straight into internal/admin/dist (see web/admin/vite.config.ts). +// +// We embed `dist` as a directory rather than a glob so that adding a new +// asset under dist/assets/ does not require touching this file. The +// `all:` prefix is intentional — Vite occasionally emits files whose +// names start with `.` (sourcemaps, tooling artefacts), and the default +// embed selector would silently drop them. +// +//go:embed all:dist +var distFS embed.FS + +// StaticFS returns the io/fs.FS that backs /admin/assets/* and the SPA +// fallback. The returned FS is rooted at the embedded `dist` directory, +// so `index.html` resolves to `dist/index.html` and assets resolve to +// `dist/assets/*` — matching the pathing the Router expects. +// +// When the SPA bundle has not been built (only the placeholder +// index.html that ships with the repo is present), the FS is still +// returned: the placeholder renders a short message telling the +// operator how to populate the bundle. Returning nil here would have +// the router answer with JSON 404, which is more confusing than a +// page that explains itself. +func StaticFS() (fs.FS, error) { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + return nil, errors.Join(errors.New("admin: open embedded dist subtree"), err) + } + return sub, nil +} diff --git a/internal/admin/server.go b/internal/admin/server.go index eff694a2d..a1b5f0e33 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -39,6 +39,14 @@ type ServerDeps struct { // cluster page deploy without standing up the dynamo bridge. Tables TablesSource + // Queues is the SQS admin source — covers list, describe, and + // delete via QueuesSource. Optional: a nil value disables + // /admin/api/v1/sqs/queues{,/{name}} (the mux answers them with + // 404). Same opt-in shape as Tables; deployments that don't run + // the SQS adapter omit this without breaking the rest of the + // admin surface. + Queues QueuesSource + // StaticFS is the embed.FS (or any fs.FS) backing the SPA. May be // nil during early development; the router renders 404 for // /admin/assets/* and the SPA fallback in that case. @@ -114,7 +122,14 @@ func NewServer(deps ServerDeps) (*Server, error) { WithLogger(logger). WithRoleStore(MapRoleStore(deps.Roles)) } - mux := buildAPIMux(auth, deps.Verifier, cluster, dynamo, logger) + var sqs http.Handler + if deps.Queues != nil { + // Same role-revalidation reasoning as dynamo above. + sqs = NewSqsHandler(deps.Queues). + WithLogger(logger). + WithRoleStore(MapRoleStore(deps.Roles)) + } + mux := buildAPIMux(auth, deps.Verifier, cluster, dynamo, sqs, logger) router := NewRouter(mux, deps.StaticFS) return &Server{deps: deps, router: router, auth: auth, mux: mux}, nil } @@ -154,10 +169,10 @@ func (s *Server) APIHandler() http.Handler { // audit path inside AuthService because the generic Audit middleware // cannot see the claimed actor at that point in the chain. // -// dynamoHandler may be nil; in that case the dynamo paths fall through -// to the unknown-endpoint 404, matching the behaviour of any other -// unregistered admin path. -func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler, dynamoHandler http.Handler, logger *slog.Logger) http.Handler { +// dynamoHandler / sqsHandler may be nil; in that case the +// corresponding paths fall through to the unknown-endpoint 404, +// matching the behaviour of any other unregistered admin path. +func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler, dynamoHandler, sqsHandler http.Handler, logger *slog.Logger) http.Handler { loginHandler := http.HandlerFunc(auth.HandleLogin) logoutHandler := http.HandlerFunc(auth.HandleLogout) @@ -216,25 +231,67 @@ func buildAPIMux(auth *AuthService, verifier *Verifier, clusterHandler, dynamoHa if dynamoHandler != nil { dynamoChain = protect(dynamoHandler) } + var sqsChain http.Handler + if sqsHandler != nil { + sqsChain = protect(sqsHandler) + } + routes := apiRoutes{ + login: loginChain, + logout: logoutChain, + cluster: clusterChain, + dynamo: dynamoChain, + sqs: sqsChain, + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/admin/api/v1/auth/login": - loginChain.ServeHTTP(w, r) - case r.URL.Path == "/admin/api/v1/auth/logout": - logoutChain.ServeHTTP(w, r) - case r.URL.Path == "/admin/api/v1/cluster": - clusterChain.ServeHTTP(w, r) - case dynamoChain != nil && (r.URL.Path == pathDynamoTables || - strings.HasPrefix(r.URL.Path, pathPrefixDynamoTables)): - dynamoChain.ServeHTTP(w, r) - default: - writeJSONError(w, http.StatusNotFound, "unknown_endpoint", - "no admin API handler is registered for this path") - } + routes.dispatch(w, r) }) } +// apiRoutes is the per-startup chain bundle the request router walks +// in priority order. Split out of buildAPIMux so the request-time +// dispatch loop stays under the cyclop budget — every additional +// optional handler (Tables, Queues, …) would otherwise add a branch +// to the same closure and push it past the limit. +type apiRoutes struct { + login http.Handler + logout http.Handler + cluster http.Handler + dynamo http.Handler + sqs http.Handler +} + +func (r apiRoutes) dispatch(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/admin/api/v1/auth/login": + r.login.ServeHTTP(w, req) + return + case "/admin/api/v1/auth/logout": + r.logout.ServeHTTP(w, req) + return + case "/admin/api/v1/cluster": + r.cluster.ServeHTTP(w, req) + return + } + if r.dynamo != nil && hasResourcePrefix(req.URL.Path, pathDynamoTables, pathPrefixDynamoTables) { + r.dynamo.ServeHTTP(w, req) + return + } + if r.sqs != nil && hasResourcePrefix(req.URL.Path, pathSqsQueues, pathPrefixSqsQueues) { + r.sqs.ServeHTTP(w, req) + return + } + writeJSONError(w, http.StatusNotFound, "unknown_endpoint", + "no admin API handler is registered for this path") +} + +// hasResourcePrefix reports whether p is exactly the collection root +// or sits under the per-resource prefix. Pulled out so both the +// dynamo and sqs branches in apiRoutes.dispatch read the same way. +func hasResourcePrefix(p, root, prefix string) bool { + return p == root || strings.HasPrefix(p, prefix) +} + func errMissing(field string) error { return &missingDepError{field: field} } diff --git a/internal/admin/sqs_handler.go b/internal/admin/sqs_handler.go new file mode 100644 index 000000000..9763f77c3 --- /dev/null +++ b/internal/admin/sqs_handler.go @@ -0,0 +1,270 @@ +package admin + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/goccy/go-json" +) + +// pathSqsQueues is the URL prefix the SQS handler owns. The "" suffix +// produces the collection root /admin/api/v1/sqs/queues; the +// pathPrefixSqsQueues form is used for the per-queue routes. +const ( + pathSqsQueues = "/admin/api/v1/sqs/queues" + pathPrefixSqsQueues = pathSqsQueues + "/" +) + +// QueueSummary is the SPA-facing projection of a single SQS queue. +// Mirrors adapter.AdminQueueSummary 1:1; the bridge in main_admin.go +// translates between the two so the admin package stays free of the +// adapter dependency tree. +type QueueSummary struct { + Name string `json:"name"` + IsFIFO bool `json:"is_fifo"` + Generation uint64 `json:"generation"` + CreatedAt time.Time `json:"created_at,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Counters QueueCounters `json:"counters"` + CountersTruncated bool `json:"counters_truncated,omitempty"` +} + +// QueueCounters mirrors the three Approximate* counters AWS exposes +// on GetQueueAttributes. Definitions follow §16.1 of the SQS design +// doc. +type QueueCounters struct { + Visible int `json:"visible"` + NotVisible int `json:"not_visible"` + Delayed int `json:"delayed"` +} + +// QueuesSource is the contract the SQS handler depends on. Wired in +// production to *adapter.SQSServer via a small bridge in main_admin.go; +// tests use a stub. +// +// AdminDescribeQueue returns (nil, false, nil) for a missing queue so +// callers can distinguish "not found" from a storage error without +// sniffing sentinels. AdminDeleteQueue returns the structured +// sentinels below so the handler can map them to HTTP statuses +// without leaking the adapter's error vocabulary. +type QueuesSource interface { + AdminListQueues(ctx context.Context) ([]string, error) + AdminDescribeQueue(ctx context.Context, name string) (*QueueSummary, bool, error) + AdminDeleteQueue(ctx context.Context, principal AuthPrincipal, name string) error +} + +// Errors the QueuesSource may return for the handler to map onto a +// specific HTTP response. Sentinels rather than typed errors so the +// bridge can map any adapter-internal failure onto exactly one of +// these without the admin package importing adapter-private types. +var ( + // ErrQueuesForbidden — principal lacks the role required (403). + ErrQueuesForbidden = errors.New("admin sqs: principal lacks required role") + // ErrQueuesNotLeader — local node is not the verified Raft + // leader. Without follower-forwarding wired (out of scope for + // the SPA's read+delete surface), maps to 503 + Retry-After: 1. + ErrQueuesNotLeader = errors.New("admin sqs: local node is not the raft leader") + // ErrQueuesNotFound — DELETE / DESCRIBE targets a queue that + // does not exist (404). The describe path uses (nil, false, nil) + // instead of this sentinel for the not-found signal. + ErrQueuesNotFound = errors.New("admin sqs: queue not found") + // ErrQueuesValidation — request shape is bad (400). + ErrQueuesValidation = errors.New("admin sqs: validation failed") +) + +// SqsHandler serves /admin/api/v1/sqs/queues and +// /admin/api/v1/sqs/queues/{name}. Reads (list, describe) accept GET; +// delete accepts DELETE and goes through the same protected +// middleware chain (BodyLimit -> SessionAuth -> Audit -> CSRF) as +// every other write surface, with an in-handler RoleFull gate so a +// read-only key cannot delete even with a valid CSRF token. +type SqsHandler struct { + source QueuesSource + roles RoleStore + logger *slog.Logger +} + +// NewSqsHandler binds the source and seeds logging with +// slog.Default(). Use WithLogger to attach a tagged logger and +// WithRoleStore to plug in the live access-key role lookup so a +// downgraded key cannot continue mutating with a still-valid JWT. +func NewSqsHandler(source QueuesSource) *SqsHandler { + return &SqsHandler{source: source, logger: slog.Default()} +} + +// WithLogger overrides the default slog destination. No-ops on nil to +// preserve the constructor-seeded slog.Default(). +func (h *SqsHandler) WithLogger(l *slog.Logger) *SqsHandler { + if l == nil { + return h + } + h.logger = l + return h +} + +// WithRoleStore enables per-request role revalidation on the delete +// endpoint. Without it, the handler trusts whatever role is embedded +// in the session JWT — which is fine for single-tenant deployments +// where the role config never changes, but problematic when an +// operator revokes or downgrades a key. Production wiring in +// main_admin.go always sets this. +func (h *SqsHandler) WithRoleStore(r RoleStore) *SqsHandler { + h.roles = r + return h +} + +// ServeHTTP routes /queues and /queues/{name}. Method handling +// mirrors DynamoHandler — keep the two parallel so an operator +// reading one understands the other for free. +func (h *SqsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == pathSqsQueues: + switch r.Method { + case http.MethodGet: + h.handleList(w, r) + default: + writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET") + } + case strings.HasPrefix(r.URL.Path, pathPrefixSqsQueues): + name := strings.TrimPrefix(r.URL.Path, pathPrefixSqsQueues) + switch r.Method { + case http.MethodGet: + h.handleDescribe(w, r, name) + case http.MethodDelete: + h.handleDelete(w, r, name) + default: + writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET or DELETE") + } + default: + writeJSONError(w, http.StatusNotFound, "unknown_endpoint", + "no admin SQS handler is registered for this path") + } +} + +type listQueuesResponse struct { + Queues []string `json:"queues"` +} + +func (h *SqsHandler) handleList(w http.ResponseWriter, r *http.Request) { + names, err := h.source.AdminListQueues(r.Context()) + if err != nil { + h.logger.LogAttrs(r.Context(), slog.LevelError, "admin sqs list queues failed", + slog.String("error", err.Error()), + ) + writeJSONError(w, http.StatusInternalServerError, "list_failed", + "failed to list queues; see server logs") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(listQueuesResponse{Queues: names}); err != nil { + h.logger.LogAttrs(r.Context(), slog.LevelWarn, "admin sqs list response encode failed", + slog.String("error", err.Error()), + ) + } +} + +func (h *SqsHandler) handleDescribe(w http.ResponseWriter, r *http.Request, name string) { + if strings.TrimSpace(name) == "" { + writeJSONError(w, http.StatusBadRequest, "invalid_queue_name", "queue name is required") + return + } + summary, exists, err := h.source.AdminDescribeQueue(r.Context(), name) + if err != nil { + writeQueuesError(w, err, h.logger, r) + return + } + if !exists { + writeJSONError(w, http.StatusNotFound, "queue_not_found", + "no queue is registered with that name") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(summary); err != nil { + h.logger.LogAttrs(r.Context(), slog.LevelWarn, "admin sqs describe response encode failed", + slog.String("error", err.Error()), + ) + } +} + +func (h *SqsHandler) handleDelete(w http.ResponseWriter, r *http.Request, name string) { + principal, ok := PrincipalFromContext(r.Context()) + if !ok { + // SessionAuth runs before this handler, so a missing + // principal is a wiring bug. 500 rather than 401 since + // 401 would be misleading — the request was authenticated. + writeJSONError(w, http.StatusInternalServerError, "internal", "missing session principal") + return + } + // Re-evaluate the role against the live store so a downgraded + // key cannot keep deleting with a still-valid JWT. The check is + // before the leader check so a forbidden read-only caller never + // learns the leader's identity by indirection. + if !h.principalCanWrite(principal) { + writeJSONError(w, http.StatusForbidden, "forbidden", + "this access key is not authorised to delete queues") + return + } + if strings.TrimSpace(name) == "" { + writeJSONError(w, http.StatusBadRequest, "invalid_queue_name", "queue name is required") + return + } + if err := h.source.AdminDeleteQueue(r.Context(), principal, name); err != nil { + writeQueuesError(w, err, h.logger, r) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +// principalCanWrite re-resolves the access key against the live +// RoleStore (when configured) so a downgrade or revoke applies to +// the next request, not just to new logins. Falls back to the JWT's +// embedded role when no role store is wired (single-tenant default). +func (h *SqsHandler) principalCanWrite(p AuthPrincipal) bool { + role := p.Role + if h.roles != nil { + if live, ok := h.roles.LookupRole(p.AccessKey); ok { + role = live + } else { + // Key has been removed from the role config since + // login. Treat it as no-access regardless of what + // the JWT claimed. + return false + } + } + return role.AllowsWrite() +} + +// writeQueuesError translates a QueuesSource error onto an HTTP +// response. Unrecognised errors map to 500 with a sanitised message +// — the raw err.Error() may include adapter internals (Pebble paths, +// raft peer ids) that should not flow to the SPA. +func writeQueuesError(w http.ResponseWriter, err error, logger *slog.Logger, r *http.Request) { + switch { + case errors.Is(err, ErrQueuesForbidden): + writeJSONError(w, http.StatusForbidden, "forbidden", "principal lacks required role") + case errors.Is(err, ErrQueuesNotLeader): + w.Header().Set("Retry-After", strconv.Itoa(1)) + writeJSONError(w, http.StatusServiceUnavailable, "leader_unavailable", + "local node is not the leader; retry shortly") + case errors.Is(err, ErrQueuesNotFound): + writeJSONError(w, http.StatusNotFound, "queue_not_found", "no queue with that name") + case errors.Is(err, ErrQueuesValidation): + writeJSONError(w, http.StatusBadRequest, "invalid_request", err.Error()) + default: + logger.LogAttrs(r.Context(), slog.LevelError, "admin sqs operation failed", + slog.String("error", err.Error()), + ) + writeJSONError(w, http.StatusInternalServerError, "internal", + "queue operation failed; see server logs") + } +} diff --git a/main.go b/main.go index f6e38cc89..ab1cd531b 100644 --- a/main.go +++ b/main.go @@ -693,7 +693,7 @@ func startServers(in serversInput) error { // Passing nil here would leave the admin dashboard with no // access to table metadata; the admin handler answers // /admin/api/v1/dynamo/* with 404 in that case. - if err := startAdminFromFlags(in.ctx, in.lc, in.eg, in.runtimes, runner.dynamoServer); err != nil { + if err := startAdminFromFlags(in.ctx, in.lc, in.eg, in.runtimes, runner.dynamoServer, runner.sqsServer); err != nil { return waitErrgroupAfterStartupFailure(in.cancel, in.eg, err) } return nil @@ -1220,6 +1220,12 @@ type runtimeServerRunner struct { // field is unexported on purpose — it is package-private state, // not a public API. Nil until start() reaches the dynamo step. dynamoServer *adapter.DynamoDBServer + + // sqsServer plays the same role as dynamoServer for the SQS + // admin entrypoints (adapter/sqs_admin.go). Nil when --sqsAddress + // is empty; the admin listener then leaves /admin/api/v1/sqs/* + // off the wire (the mux 404s those paths). + sqsServer *adapter.SQSServer } func (r *runtimeServerRunner) start() error { @@ -1251,9 +1257,11 @@ func (r *runtimeServerRunner) start() error { if err := startS3Server(r.ctx, r.lc, r.eg, r.s3Address, r.shardStore, r.coordinate, r.leaderS3, r.s3Region, r.s3CredsFile, r.s3PathStyleOnly, r.readTracker); err != nil { return waitErrgroupAfterStartupFailure(r.cancel, r.eg, err) } - if err := startSQSServer(r.ctx, r.lc, r.eg, r.sqsAddress, r.shardStore, r.coordinate, r.leaderSQS, r.sqsRegion, r.sqsCredsFile); err != nil { + sqsServer, err := startSQSServer(r.ctx, r.lc, r.eg, r.sqsAddress, r.shardStore, r.coordinate, r.leaderSQS, r.sqsRegion, r.sqsCredsFile) + if err != nil { return waitErrgroupAfterStartupFailure(r.cancel, r.eg, err) } + r.sqsServer = sqsServer if err := startMetricsServer(r.ctx, r.lc, r.eg, r.metricsAddress, r.metricsToken, r.metricsRegistry.Handler()); err != nil { return waitErrgroupAfterStartupFailure(r.cancel, r.eg, err) } diff --git a/main_admin.go b/main_admin.go index e9e1f526c..55669ba31 100644 --- a/main_admin.go +++ b/main_admin.go @@ -68,7 +68,7 @@ type adminListenerConfig struct { // without touching --s3CredentialsFile: pulling the admin feature into // a hard dependency on that file would break deployments that never // intended to use it. -func startAdminFromFlags(ctx context.Context, lc *net.ListenConfig, eg *errgroup.Group, runtimes []*raftGroupRuntime, dynamoServer *adapter.DynamoDBServer) error { +func startAdminFromFlags(ctx context.Context, lc *net.ListenConfig, eg *errgroup.Group, runtimes []*raftGroupRuntime, dynamoServer *adapter.DynamoDBServer, sqsServer *adapter.SQSServer) error { if !*adminEnabled { return nil } @@ -109,10 +109,102 @@ func startAdminFromFlags(ctx context.Context, lc *net.ListenConfig, eg *errgroup } clusterSrc := newClusterInfoSource(*raftId, buildVersion(), runtimes) tablesSrc := newDynamoTablesSource(dynamoServer) - _, err = startAdminServer(ctx, lc, eg, cfg, staticCreds, clusterSrc, tablesSrc, buildVersion()) + queuesSrc := newSqsQueuesSource(sqsServer) + _, err = startAdminServer(ctx, lc, eg, cfg, staticCreds, clusterSrc, tablesSrc, queuesSrc, buildVersion()) return err } +// newSqsQueuesSource adapts *adapter.SQSServer to the +// admin.QueuesSource interface. Same architectural reasoning as +// newDynamoTablesSource: the bridge stays in this file (rather than +// internal/admin) so the admin package stays free of the heavy +// adapter-package dependency tree. +// +// Returns nil when sqsServer is nil; admin.NewServer leaves the +// /admin/api/v1/sqs/* paths off the wire in that case. +func newSqsQueuesSource(sqsServer *adapter.SQSServer) admin.QueuesSource { + if sqsServer == nil { + return nil + } + return &sqsQueuesBridge{server: sqsServer} +} + +// sqsQueuesBridge is the thin adapter that re-shapes +// adapter.AdminQueueSummary into admin.QueueSummary. The two structs +// are deliberately isomorphic so this translation does no allocation +// more than necessary; if a future field is added on one side, the +// build breaks here, which is exactly the drift signal we want. +type sqsQueuesBridge struct { + server *adapter.SQSServer +} + +func (b *sqsQueuesBridge) AdminListQueues(ctx context.Context) ([]string, error) { + return b.server.AdminListQueues(ctx) //nolint:wrapcheck // pure pass-through; adapter owns the error context. +} + +func (b *sqsQueuesBridge) AdminDescribeQueue(ctx context.Context, name string) (*admin.QueueSummary, bool, error) { + summary, exists, err := b.server.AdminDescribeQueue(ctx, name) + if err != nil { + return nil, false, translateAdminQueuesError(err) + } + if !exists { + return nil, false, nil + } + return convertAdminQueueSummary(summary), true, nil +} + +func (b *sqsQueuesBridge) AdminDeleteQueue(ctx context.Context, principal admin.AuthPrincipal, name string) error { + if err := b.server.AdminDeleteQueue(ctx, convertAdminPrincipal(principal), name); err != nil { + return translateAdminQueuesError(err) + } + return nil +} + +// convertAdminQueueSummary mirrors adapter.AdminQueueSummary into +// admin.QueueSummary. The role / counter fields are intentionally +// 1:1; if either side grows a new field, this function should be +// extended in the same commit so a compile error catches the drift. +func convertAdminQueueSummary(in *adapter.AdminQueueSummary) *admin.QueueSummary { + if in == nil { + return nil + } + out := &admin.QueueSummary{ + Name: in.Name, + IsFIFO: in.IsFIFO, + Generation: in.Generation, + CreatedAt: in.CreatedAt, + Attributes: in.Attributes, + CountersTruncated: in.CountersTruncated, + Counters: admin.QueueCounters{ + Visible: in.Counters.Visible, + NotVisible: in.Counters.NotVisible, + Delayed: in.Counters.Delayed, + }, + } + return out +} + +// translateAdminQueuesError maps the adapter's queue error vocabulary +// onto the admin-package sentinels the SQS handler matches against. +// Anything not recognised is forwarded as-is and answered with 500 +// + a sanitised body, matching the dynamo bridge's behaviour. +func translateAdminQueuesError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, adapter.ErrAdminForbidden): + return admin.ErrQueuesForbidden + case errors.Is(err, adapter.ErrAdminNotLeader): + return admin.ErrQueuesNotLeader + case errors.Is(err, adapter.ErrAdminSQSNotFound): + return admin.ErrQueuesNotFound + case errors.Is(err, adapter.ErrAdminSQSValidation): + return admin.ErrQueuesValidation + default: + return err + } +} + // newDynamoTablesSource adapts *adapter.DynamoDBServer to the // admin.TablesSource interface. The bridge stays in this file (rather // than internal/admin) so the admin package stays free of the heavy @@ -346,6 +438,7 @@ func startAdminServer( creds map[string]string, cluster admin.ClusterInfoSource, tables admin.TablesSource, + queues admin.QueuesSource, version string, ) (string, error) { adminCfg := buildAdminConfig(cfg) @@ -353,7 +446,7 @@ func startAdminServer( if err != nil || !enabled { return "", err } - server, err := buildAdminHTTPServer(&adminCfg, creds, cluster, tables) + server, err := buildAdminHTTPServer(&adminCfg, creds, cluster, tables, queues) if err != nil { return "", err } @@ -393,7 +486,7 @@ func checkAdminConfig(adminCfg *admin.Config, cluster admin.ClusterInfoSource) ( return true, nil } -func buildAdminHTTPServer(adminCfg *admin.Config, creds map[string]string, cluster admin.ClusterInfoSource, tables admin.TablesSource) (*admin.Server, error) { +func buildAdminHTTPServer(adminCfg *admin.Config, creds map[string]string, cluster admin.ClusterInfoSource, tables admin.TablesSource, queues admin.QueuesSource) (*admin.Server, error) { primaryKeys, err := adminCfg.DecodedSigningKeys() if err != nil { return nil, errors.Wrap(err, "decode admin signing keys") @@ -406,6 +499,10 @@ func buildAdminHTTPServer(adminCfg *admin.Config, creds map[string]string, clust if err != nil { return nil, errors.Wrap(err, "build admin verifier") } + staticFS, err := admin.StaticFS() + if err != nil { + return nil, errors.Wrap(err, "open embedded admin SPA") + } server, err := admin.NewServer(admin.ServerDeps{ Signer: signer, Verifier: verifier, @@ -413,7 +510,8 @@ func buildAdminHTTPServer(adminCfg *admin.Config, creds map[string]string, clust Roles: adminCfg.RoleIndex(), ClusterInfo: cluster, Tables: tables, - StaticFS: nil, + Queues: queues, + StaticFS: staticFS, AuthOpts: admin.AuthServiceOpts{ InsecureCookie: adminCfg.AllowInsecureDevCookie, }, diff --git a/main_admin_test.go b/main_admin_test.go index e62a61aaf..d463dbe1a 100644 --- a/main_admin_test.go +++ b/main_admin_test.go @@ -198,7 +198,7 @@ func TestStartAdminServer_DisabledNoOp(t *testing.T) { eg, ctx := errgroup.WithContext(context.Background()) defer func() { _ = eg.Wait() }() var lc net.ListenConfig - _, err := startAdminServer(ctx, &lc, eg, adminListenerConfig{enabled: false}, nil, nil, nil, "") + _, err := startAdminServer(ctx, &lc, eg, adminListenerConfig{enabled: false}, nil, nil, nil, nil, "") require.NoError(t, err) } @@ -211,7 +211,7 @@ func TestStartAdminServer_InvalidConfigRejected(t *testing.T) { listen: "127.0.0.1:0", // missing signing key } - _, err := startAdminServer(ctx, &lc, eg, cfg, map[string]string{}, nil, nil, "") + _, err := startAdminServer(ctx, &lc, eg, cfg, map[string]string{}, nil, nil, nil, "") require.Error(t, err) } @@ -224,7 +224,7 @@ func TestStartAdminServer_NonLoopbackWithoutTLSRejected(t *testing.T) { listen: "0.0.0.0:0", sessionSigningKey: freshKey(), } - _, err := startAdminServer(ctx, &lc, eg, cfg, map[string]string{}, nil, nil, "") + _, err := startAdminServer(ctx, &lc, eg, cfg, map[string]string{}, nil, nil, nil, "") require.Error(t, err) require.Contains(t, err.Error(), "TLS") } @@ -238,7 +238,7 @@ func TestStartAdminServer_RejectsMissingClusterSource(t *testing.T) { listen: "127.0.0.1:0", sessionSigningKey: freshKey(), } - _, err := startAdminServer(ctx, &lc, eg, cfg, map[string]string{}, nil, nil, "") + _, err := startAdminServer(ctx, &lc, eg, cfg, map[string]string{}, nil, nil, nil, "") require.Error(t, err) require.Contains(t, err.Error(), "cluster info source") } @@ -261,7 +261,7 @@ func TestStartAdminServer_ServesHealthz(t *testing.T) { cluster := admin.ClusterInfoFunc(func(_ context.Context) (admin.ClusterInfo, error) { return admin.ClusterInfo{NodeID: "n1", Version: "test"}, nil }) - addr, err := startAdminServer(eCtx, &lc, eg, cfg, map[string]string{}, cluster, nil, "test") + addr, err := startAdminServer(eCtx, &lc, eg, cfg, map[string]string{}, cluster, nil, nil, "test") require.NoError(t, err) // Poll /admin/healthz until success or the test deadline. @@ -304,7 +304,7 @@ func TestStartAdminServer_ServesTLS(t *testing.T) { cluster := admin.ClusterInfoFunc(func(_ context.Context) (admin.ClusterInfo, error) { return admin.ClusterInfo{NodeID: "n-tls", Version: "test"}, nil }) - addr, err := startAdminServer(eCtx, &lc, eg, cfg, map[string]string{}, cluster, nil, "test") + addr, err := startAdminServer(eCtx, &lc, eg, cfg, map[string]string{}, cluster, nil, nil, "test") require.NoError(t, err) transport := &http.Transport{TLSClientConfig: &tls.Config{ diff --git a/main_sqs.go b/main_sqs.go index 55ca41684..7bb8623e0 100644 --- a/main_sqs.go +++ b/main_sqs.go @@ -11,6 +11,11 @@ import ( "golang.org/x/sync/errgroup" ) +// startSQSServer stands up the SQS adapter on sqsAddr and returns the +// running *adapter.SQSServer so the admin listener can call SigV4-bypass +// admin entrypoints against it (see adapter/sqs_admin.go). Returns +// (nil, nil) when sqsAddr is empty — that is the "SQS disabled" branch +// and the admin listener leaves /admin/api/v1/sqs/* off the wire. func startSQSServer( ctx context.Context, lc *net.ListenConfig, @@ -21,19 +26,19 @@ func startSQSServer( leaderSQS map[string]string, region string, credentialsFile string, -) error { +) (*adapter.SQSServer, error) { sqsAddr = strings.TrimSpace(sqsAddr) if sqsAddr == "" { - return nil + return nil, nil } sqsL, err := lc.Listen(ctx, "tcp", sqsAddr) if err != nil { - return errors.Wrapf(err, "failed to listen on %s", sqsAddr) + return nil, errors.Wrapf(err, "failed to listen on %s", sqsAddr) } staticCreds, err := loadSigV4StaticCredentialsFile(credentialsFile, "sqs") if err != nil { _ = sqsL.Close() - return err + return nil, err } sqsServer := adapter.NewSQSServer( sqsL, @@ -63,5 +68,5 @@ func startSQSServer( } return errors.WithStack(err) }) - return nil + return sqsServer, nil } diff --git a/web/admin/.gitignore b/web/admin/.gitignore new file mode 100644 index 000000000..f90c53bcd --- /dev/null +++ b/web/admin/.gitignore @@ -0,0 +1,5 @@ +node_modules +.vite +dist +*.log +*.tsbuildinfo diff --git a/web/admin/index.html b/web/admin/index.html new file mode 100644 index 000000000..02586c6e6 --- /dev/null +++ b/web/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + elastickv admin + + +
+ + + diff --git a/web/admin/package-lock.json b/web/admin/package-lock.json new file mode 100644 index 000000000..b6f6f43b9 --- /dev/null +++ b/web/admin/package-lock.json @@ -0,0 +1,2737 @@ +{ + "name": "elastickv-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elastickv-admin", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-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-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.22.tgz", + "integrity": "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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==", + "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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "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": "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/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "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.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/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/tinyglobby/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/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "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/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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" + } + } +} diff --git a/web/admin/package.json b/web/admin/package.json new file mode 100644 index 000000000..8156ceb5f --- /dev/null +++ b/web/admin/package.json @@ -0,0 +1,28 @@ +{ + "name": "elastickv-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "tsc -b --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/web/admin/postcss.config.js b/web/admin/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/web/admin/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx new file mode 100644 index 000000000..08311dfcf --- /dev/null +++ b/web/admin/src/App.tsx @@ -0,0 +1,39 @@ +import { Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./auth"; +import { Layout } from "./components/Layout"; +import { RequireAuth } from "./components/RequireAuth"; +import { DashboardPage } from "./pages/Dashboard"; +import { DynamoDetailPage } from "./pages/DynamoDetail"; +import { DynamoListPage } from "./pages/DynamoList"; +import { LoginPage } from "./pages/Login"; +import { NotFoundPage } from "./pages/NotFound"; +import { S3DetailPage } from "./pages/S3Detail"; +import { S3ListPage } from "./pages/S3List"; +import { SqsDetailPage } from "./pages/SqsDetail"; +import { SqsListPage } from "./pages/SqsList"; + +export function App() { + return ( + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/web/admin/src/api/client.ts b/web/admin/src/api/client.ts new file mode 100644 index 000000000..0f89eecf4 --- /dev/null +++ b/web/admin/src/api/client.ts @@ -0,0 +1,259 @@ +// HTTP client for /admin/api/v1/*. Two contracts the Go server enforces +// that the client side has to honour: +// 1. Session is delivered via the HttpOnly `admin_session` cookie. We +// never read or store it; the browser attaches it automatically. +// 2. CSRF defence is double-submit cookie: the readable `admin_csrf` +// cookie value MUST be echoed in the X-Admin-CSRF header on every +// POST/PUT/DELETE. Mismatch → 403. +// +// Read /admin/api/v1/auth/login first to mint both cookies. After that, +// every call goes through `apiFetch` which decorates mutations with the +// CSRF header and surfaces errors as ApiError so callers can branch on +// status without reparsing. + +const apiBase = "/admin/api/v1"; + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + constructor(status: number, code: string, message: string) { + super(message || code); + this.status = status; + this.code = code; + } +} + +type Json = Record | unknown[] | string | number | boolean | null; + +type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "DELETE"; + +interface ApiOptions { + method?: HttpMethod; + body?: Json; + query?: Record; + signal?: AbortSignal; +} + +function readCsrfCookie(): string | undefined { + // Cookies are stored unordered; use String.split rather than a regex + // that would have to escape the cookie name. The CSRF cookie is set + // without HttpOnly precisely so this code can read it. + const raw = document.cookie; + if (!raw) return undefined; + for (const part of raw.split(";")) { + const [name, ...rest] = part.trim().split("="); + if (name === "admin_csrf") { + return decodeURIComponent(rest.join("=")); + } + } + return undefined; +} + +function buildURL(path: string, query?: ApiOptions["query"]): string { + const url = new URL(apiBase + path, window.location.origin); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue; + url.searchParams.set(k, String(v)); + } + } + return url.pathname + url.search; +} + +export async function apiFetch(path: string, opts: ApiOptions = {}): Promise { + const method = opts.method ?? "GET"; + const headers: Record = { Accept: "application/json" }; + let body: BodyInit | undefined; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.body); + } + if (method !== "GET" && method !== "HEAD") { + const csrf = readCsrfCookie(); + if (csrf) headers["X-Admin-CSRF"] = csrf; + } + const res = await fetch(buildURL(path, opts.query), { + method, + headers, + body, + credentials: "same-origin", + signal: opts.signal, + }); + if (res.status === 204) { + return undefined as T; + } + const ct = res.headers.get("content-type") ?? ""; + // The server promises JSON for both success and error bodies under + // /admin/api/v1/*; treat anything else as an unexpected proxy/edge + // response and surface it without trying to JSON.parse HTML. + if (!ct.includes("application/json")) { + if (res.ok) { + throw new ApiError(res.status, "non_json_response", `unexpected ${ct || "no"} content-type`); + } + throw new ApiError(res.status, "non_json_response", `HTTP ${res.status}`); + } + const payload = (await res.json()) as Record; + if (!res.ok) { + const code = typeof payload.error === "string" ? payload.error : "request_failed"; + const msg = typeof payload.message === "string" ? payload.message : ""; + throw new ApiError(res.status, code, msg); + } + return payload as T; +} + +// Resource shapes mirror what the Go handlers emit. Keep them in sync +// with internal/admin/cluster_handler.go (and future dynamo/s3 handlers). +// Fields the backend may not yet populate are typed optional so the +// SPA can render gracefully against the partially-built P1/P2 server. + +export interface GroupInfo { + group_id: number; + leader_id: string; + members: string[]; + is_leader: boolean; +} + +export interface ClusterInfo { + node_id: string; + version: string; + timestamp: string; + groups: GroupInfo[]; +} + +export type Role = "read_only" | "full"; + +export interface LoginResponse { + role: Role; + expires_at: string; +} + +// Server emits keys as flat strings (just the attribute name) plus a +// generation counter. Describe and Create return the same shape. +// See internal/admin/dynamo_handler.go DynamoTableSummary. +export interface DynamoGSISummary { + name: string; + partition_key: string; + sort_key?: string; + projection_type: string; +} + +export interface DynamoTable { + name: string; + partition_key: string; + sort_key?: string; + generation: number; + global_secondary_indexes?: DynamoGSISummary[]; +} + +// ListTables returns just the names; SPA fetches Describe per row when +// the user opens the detail page. Matches AWS DynamoDB's ListTables / +// DescribeTable split and lets the list endpoint stay cheap. +export interface DynamoTableList { + tables: string[]; + next_token?: string; +} + +export interface CreateTableAttribute { + name: string; + type: "S" | "N" | "B"; +} + +export interface CreateTableProjection { + type?: "ALL" | "KEYS_ONLY" | "INCLUDE"; + non_key_attributes?: string[]; +} + +export interface CreateTableGSI { + name: string; + partition_key: CreateTableAttribute; + sort_key?: CreateTableAttribute; + projection: CreateTableProjection; +} + +export interface CreateTableRequest { + table_name: string; + partition_key: CreateTableAttribute; + sort_key?: CreateTableAttribute; + gsi?: CreateTableGSI[]; +} + +export interface S3Bucket { + bucket_name: string; + acl?: string; + created_at?: string; +} + +export interface S3BucketList { + buckets: S3Bucket[]; + next_token?: string; +} + +export interface CreateBucketRequest { + bucket_name: string; + acl?: "private" | "public-read"; +} + +// SQS queue admin DTOs (Section 16.2 of the SQS design doc). +// `attributes` mirrors the AWS GetQueueAttributes "All" set with +// snake_case keys; `counters` is the typed projection of the three +// Approximate* counters added in Phase 3.A. `counters_truncated` +// signals that the visibility-index scan hit its per-call budget +// and the values are a lower bound (matches AWS's "approximate" +// contract). +export interface SqsQueueCounters { + visible: number; + not_visible: number; + delayed: number; +} + +export interface SqsQueueSummary { + name: string; + is_fifo: boolean; + generation: number; + created_at?: string; + attributes?: Record; + counters: SqsQueueCounters; + counters_truncated?: boolean; +} + +export interface SqsQueueList { + queues: string[]; +} + +export const api = { + login: (access_key: string, secret_key: string) => + apiFetch("/auth/login", { + method: "POST", + body: { access_key, secret_key }, + }), + logout: () => apiFetch("/auth/logout", { method: "POST" }), + cluster: (signal?: AbortSignal) => + apiFetch("/cluster", { signal }), + listTables: (next_token?: string, signal?: AbortSignal) => + apiFetch("/dynamo/tables", { query: { next_token }, signal }), + describeTable: (name: string, signal?: AbortSignal) => + apiFetch(`/dynamo/tables/${encodeURIComponent(name)}`, { signal }), + createTable: (req: CreateTableRequest) => + apiFetch("/dynamo/tables", { method: "POST", body: req as unknown as Json }), + deleteTable: (name: string) => + apiFetch(`/dynamo/tables/${encodeURIComponent(name)}`, { method: "DELETE" }), + listBuckets: (next_token?: string, signal?: AbortSignal) => + apiFetch("/s3/buckets", { query: { next_token }, signal }), + describeBucket: (name: string, signal?: AbortSignal) => + apiFetch(`/s3/buckets/${encodeURIComponent(name)}`, { signal }), + createBucket: (req: CreateBucketRequest) => + apiFetch("/s3/buckets", { method: "POST", body: req as unknown as Json }), + putBucketAcl: (name: string, acl: "private" | "public-read") => + apiFetch(`/s3/buckets/${encodeURIComponent(name)}/acl`, { + method: "PUT", + body: { acl }, + }), + deleteBucket: (name: string) => + apiFetch(`/s3/buckets/${encodeURIComponent(name)}`, { method: "DELETE" }), + listQueues: (signal?: AbortSignal) => + apiFetch("/sqs/queues", { signal }), + describeQueue: (name: string, signal?: AbortSignal) => + apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { signal }), + deleteQueue: (name: string) => + apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { method: "DELETE" }), +}; diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx new file mode 100644 index 000000000..56e606c95 --- /dev/null +++ b/web/admin/src/auth.tsx @@ -0,0 +1,146 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ReactNode } from "react"; +import { ApiError, api, type Role } from "./api/client"; + +interface Session { + role: Role; + expiresAt: Date; +} + +interface AuthContextValue { + session: Session | null; + loading: boolean; + login: (accessKey: string, secretKey: string) => Promise; + logout: () => Promise; + // markUnauthorized clears the in-memory session when an API call + // surfaces 401 mid-flow. The browser cookie may already be expired; + // forcing a re-login keeps the UI from looping on stale state. + markUnauthorized: () => void; +} + +const AuthContext = createContext(undefined); + +const STORAGE_KEY = "elastickv-admin.session.v1"; + +interface PersistedSession { + role: Role; + expires_at: string; +} + +function loadPersisted(): Session | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const obj = JSON.parse(raw) as PersistedSession; + const expiresAt = new Date(obj.expires_at); + if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= Date.now()) { + sessionStorage.removeItem(STORAGE_KEY); + return null; + } + return { role: obj.role, expiresAt }; + } catch { + sessionStorage.removeItem(STORAGE_KEY); + return null; + } +} + +function persistSession(s: Session | null) { + if (!s) { + sessionStorage.removeItem(STORAGE_KEY); + return; + } + const payload: PersistedSession = { + role: s.role, + expires_at: s.expiresAt.toISOString(), + }; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(() => loadPersisted()); + const [loading, setLoading] = useState(false); + const expiryTimer = useRef(undefined); + + const clearTimer = useCallback(() => { + if (expiryTimer.current !== undefined) { + window.clearTimeout(expiryTimer.current); + expiryTimer.current = undefined; + } + }, []); + + const setSessionAndPersist = useCallback( + (next: Session | null) => { + setSession(next); + persistSession(next); + clearTimer(); + if (next) { + const ms = next.expiresAt.getTime() - Date.now(); + if (ms > 0) { + expiryTimer.current = window.setTimeout(() => { + setSession(null); + persistSession(null); + }, ms); + } + } + }, + [clearTimer], + ); + + useEffect(() => clearTimer, [clearTimer]); + + const login = useCallback( + async (accessKey: string, secretKey: string) => { + setLoading(true); + try { + const res = await api.login(accessKey, secretKey); + setSessionAndPersist({ + role: res.role, + expiresAt: new Date(res.expires_at), + }); + } finally { + setLoading(false); + } + }, + [setSessionAndPersist], + ); + + const logout = useCallback(async () => { + try { + await api.logout(); + } catch (err) { + // 401 from logout means the cookie is already gone — that is the + // desired end state, so we still clear local session below. Any + // other error is logged but does not block sign-out from the UI. + if (!(err instanceof ApiError) || err.status !== 401) { + console.warn("admin logout failed", err); + } + } finally { + setSessionAndPersist(null); + } + }, [setSessionAndPersist]); + + const markUnauthorized = useCallback(() => { + setSessionAndPersist(null); + }, [setSessionAndPersist]); + + const value = useMemo( + () => ({ session, loading, login, logout, markUnauthorized }), + [session, loading, login, logout, markUnauthorized], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/web/admin/src/components/Layout.tsx b/web/admin/src/components/Layout.tsx new file mode 100644 index 000000000..26620cdb8 --- /dev/null +++ b/web/admin/src/components/Layout.tsx @@ -0,0 +1,64 @@ +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../auth"; + +const navItems: { to: string; label: string; end?: boolean }[] = [ + { to: "/", label: "Overview", end: true }, + { to: "/dynamo", label: "DynamoDB" }, + { to: "/sqs", label: "SQS" }, + { to: "/s3", label: "S3" }, +]; + +export function Layout() { + const { session, logout } = useAuth(); + const navigate = useNavigate(); + + const onSignOut = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + return ( +
+
+
+
elastickv admin
+ +
+ {session && ( + <> + + {session.role === "full" ? "full access" : "read only"} + + + + )} +
+
+
+
+
+ +
+
+
+ ); +} diff --git a/web/admin/src/components/Modal.tsx b/web/admin/src/components/Modal.tsx new file mode 100644 index 000000000..f5cad1269 --- /dev/null +++ b/web/admin/src/components/Modal.tsx @@ -0,0 +1,146 @@ +import { useEffect, useId, useRef } from "react"; +import type { ReactNode } from "react"; + +interface ModalProps { + title: string; + open: boolean; + onClose: () => void; + children: ReactNode; + // Keeps the close affordances (Esc, backdrop) wired off when a + // background save is in flight so the user cannot accidentally + // dismiss a half-applied operation. + busy?: boolean; +} + +// Lightweight modal: no portal, no animation library. shadcn/ui's +// Dialog primitive would pull in @radix-ui/react-dialog (~10KB gzip) +// and we only need a single confirmation/edit surface per page. +// +// Accessibility (per the WAI-ARIA Authoring Practices for dialogs): +// - role="dialog" + aria-modal="true" so AT announce the dialog +// and treat the rest of the page as inert. +// - aria-labelledby on the title
so the dialog is named. +// - Focus is moved into the dialog on open and restored to the +// previously-focused element on close. +// - Tab and Shift+Tab are wrapped to keep focus inside the dialog +// until it closes, so keyboard users cannot accidentally tab to +// the page underneath. +export function Modal({ title, open, onClose, children, busy }: ModalProps) { + const dialogRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + if (!open) return; + + previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null; + // Focus the first focusable element so keyboard users land + // inside the dialog instead of the page underneath. Run on the + // next tick so the dialog DOM exists and any autofocus has had + // a chance to settle. + queueMicrotask(() => focusFirstFocusable(dialogRef.current)); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !busy) { + onClose(); + return; + } + if (e.key !== "Tab") return; + const root = dialogRef.current; + if (!root) return; + const focusables = focusableElements(root); + if (focusables.length === 0) { + // Empty dialog: keep Tab from leaving via the page. + e.preventDefault(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }; + + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + // Restore focus to whoever opened the dialog. Guard against + // the trigger being unmounted (e.g. the dialog deleted the + // row whose button opened it) by checking isConnected. + const restore = previouslyFocusedRef.current; + previouslyFocusedRef.current = null; + if (restore && restore.isConnected) { + restore.focus(); + } + }; + }, [open, busy, onClose]); + + if (!open) return null; + return ( +
{ + if (e.target === e.currentTarget && !busy) onClose(); + }} + > +
+
+
{title}
+ +
+
{children}
+
+
+ ); +} + +// focusableSelector targets the elements an end user can tab to. +// Excludes [tabindex="-1"] (programmatic-only focus targets) and +// disabled / hidden inputs. Kept narrow on purpose: the modal only +// hosts buttons / form fields / links, not embedded media or +// content-editable surfaces. +const focusableSelector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled]):not([type='hidden'])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +function focusableElements(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(focusableSelector)); +} + +function focusFirstFocusable(root: HTMLElement | null): void { + if (!root) return; + const focusables = focusableElements(root); + if (focusables.length > 0) { + focusables[0].focus(); + return; + } + // Fallback: focus the dialog container itself so screen readers + // still announce it. Add tabindex=-1 dynamically so the browser + // accepts the focus call without making the dialog tab-stop bait. + root.setAttribute("tabindex", "-1"); + root.focus(); +} diff --git a/web/admin/src/components/RequireAuth.tsx b/web/admin/src/components/RequireAuth.tsx new file mode 100644 index 000000000..60d102aa8 --- /dev/null +++ b/web/admin/src/components/RequireAuth.tsx @@ -0,0 +1,37 @@ +import { Navigate, useLocation } from "react-router-dom"; +import type { ReactNode } from "react"; +import { useAuth } from "../auth"; + +// RequireAuth bounces unauthenticated visitors to /login and preserves +// the originally requested path on `state.from` so the login form can +// send them back where they intended to go. +export function RequireAuth({ children }: { children: ReactNode }) { + const { session } = useAuth(); + const location = useLocation(); + if (!session) { + return ; + } + return <>{children}; +} + +// RequireFullAccess gates write-only pages. Read-only sessions land +// here when they try to navigate to a /create or /edit route; the +// component renders an inline notice rather than a full redirect so +// the user still sees the surrounding nav. +export function RequireFullAccess({ children }: { children: ReactNode }) { + const { session } = useAuth(); + if (!session) return null; + if (session.role !== "full") { + return ( +
+
Read-only session
+

+ This action requires a full-access access key. Sign in with a key listed under + admin.full_access_keys + to perform write operations. +

+
+ ); + } + return <>{children}; +} diff --git a/web/admin/src/lib/useApi.ts b/web/admin/src/lib/useApi.ts new file mode 100644 index 000000000..369152e9b --- /dev/null +++ b/web/admin/src/lib/useApi.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "../api/client"; +import { useAuth } from "../auth"; + +interface UseApiState { + data: T | null; + error: ApiError | null; + loading: boolean; + reload: () => void; +} + +// useApiQuery wraps a one-shot loader with abort, error normalisation, +// and a global 401 → "session is gone" reaction. Keeping that reaction +// here means individual pages do not have to reimplement the redirect. +export function useApiQuery( + loader: (signal: AbortSignal) => Promise, + deps: ReadonlyArray, +): UseApiState { + const { markUnauthorized } = useAuth(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [tick, setTick] = useState(0); + const loaderRef = useRef(loader); + loaderRef.current = loader; + // markUnauthorizedRef mirrors loaderRef for the same reason: keep the + // 401 reaction out of the effect's dep list so a transient identity + // change in AuthProvider (e.g. React Fast Refresh in dev, or a future + // wrapping change) doesn't invalidate every active query and trigger + // a fresh network round-trip on every page. + const markUnauthorizedRef = useRef(markUnauthorized); + markUnauthorizedRef.current = markUnauthorized; + + useEffect(() => { + const ctrl = new AbortController(); + let cancelled = false; + setLoading(true); + setError(null); + loaderRef + .current(ctrl.signal) + .then((value) => { + if (cancelled) return; + setData(value); + setLoading(false); + }) + .catch((err: unknown) => { + // The cleanup function (below) sets `cancelled = true` *before* + // calling `ctrl.abort()`, so any abort path is already covered + // by the `cancelled` check above. No second guard needed. + if (cancelled) return; + if (err instanceof ApiError) { + if (err.status === 401) markUnauthorizedRef.current(); + setError(err); + } else { + setError(new ApiError(0, "network_error", String(err))); + } + setLoading(false); + }); + return () => { + cancelled = true; + ctrl.abort(); + }; + // The loader itself is intentionally NOT in the dep list: callers + // pass an inline arrow and the explicit deps array models the real + // input set. Also include `tick` so reload() forces a refetch. + // markUnauthorized is read through markUnauthorizedRef so it does + // not need to be a dep either. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, tick]); + + const reload = useCallback(() => setTick((t) => t + 1), []); + return { data, error, loading, reload }; +} + +// formatApiError flattens an ApiError or unknown into a string the UI +// can show without exposing stack traces. Code/message preserved when +// possible so operators can grep server logs by code. +export function formatApiError(err: unknown): string { + if (err instanceof ApiError) { + if (err.message && err.message !== err.code) { + return `${err.code}: ${err.message}`; + } + return err.code; + } + return String(err); +} diff --git a/web/admin/src/main.tsx b/web/admin/src/main.tsx new file mode 100644 index 000000000..78e34fbac --- /dev/null +++ b/web/admin/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import "./styles.css"; + +const container = document.getElementById("root"); +if (!container) { + throw new Error("admin SPA: #root not found"); +} + +createRoot(container).render( + + {/* basename keeps react-router aligned with the /admin/* prefix the + Go router serves index.html for. Without it, in-app navigation + would build URLs at the document root and bypass the SPA. */} + + + + , +); diff --git a/web/admin/src/pages/Dashboard.tsx b/web/admin/src/pages/Dashboard.tsx new file mode 100644 index 000000000..3e7edfb19 --- /dev/null +++ b/web/admin/src/pages/Dashboard.tsx @@ -0,0 +1,128 @@ +import type { ApiError } from "../api/client"; +import { api } from "../api/client"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function DashboardPage() { + const cluster = useApiQuery((signal) => api.cluster(signal), []); + const tables = useApiQuery((signal) => api.listTables(undefined, signal), []); + const buckets = useApiQuery((signal) => api.listBuckets(undefined, signal), []); + + return ( +
+
+

Cluster overview

+ +
+ +
+ + + +
+ +
+
+

Local node

+ {cluster.data && ( + + {new Date(cluster.data.timestamp).toLocaleString()} + + )} +
+ {cluster.loading &&
Loading…
} + {cluster.error && ( +
{formatApiError(cluster.error)}
+ )} + {cluster.data && ( +
+
Node ID
+
{cluster.data.node_id || "—"}
+
Build
+
{cluster.data.version}
+
+ )} +
+ +
+

Raft groups

+ {cluster.data && cluster.data.groups.length === 0 && ( +
No raft groups reported.
+ )} + {cluster.data && cluster.data.groups.length > 0 && ( + + + + + + + + + + + {cluster.data.groups.map((g) => ( + + + + + + + ))} + +
GroupLeaderMembersLocal role
#{g.group_id}{g.leader_id || election…}{g.members.join(", ") || "—"} + + {g.is_leader ? "leader" : "follower"} + +
+ )} +
+
+ ); +} + +interface SummaryCardProps { + label: string; + value: string | null; + loading: boolean; + error: ApiError | null; + pendingMessage?: string; +} + +function SummaryCard({ label, value, loading, error, pendingMessage }: SummaryCardProps) { + // 404 from a not-yet-wired backend handler is the "endpoint pending" + // signal. Surface it as a soft notice instead of a red error so the + // overview page reads correctly during P1/P2 rollout. + const pending = error?.status === 404 && pendingMessage; + return ( +
+
{label}
+ {loading &&
} + {!loading && value !== null &&
{value}
} + {!loading && pending && ( +
{pendingMessage}
+ )} + {!loading && error && !pending && ( +
{formatApiError(error)}
+ )} +
+ ); +} + diff --git a/web/admin/src/pages/DynamoDetail.tsx b/web/admin/src/pages/DynamoDetail.tsx new file mode 100644 index 000000000..a230e9cd4 --- /dev/null +++ b/web/admin/src/pages/DynamoDetail.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { api } from "../api/client"; +import { useAuth } from "../auth"; +import { Modal } from "../components/Modal"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function DynamoDetailPage() { + const { name = "" } = useParams<{ name: string }>(); + const { session } = useAuth(); + const detail = useApiQuery((signal) => api.describeTable(name, signal), [name]); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const navigate = useNavigate(); + const writeAllowed = session?.role === "full"; + + const onDelete = async () => { + setDeleting(true); + setDeleteError(null); + try { + await api.deleteTable(name); + navigate("/dynamo", { replace: true }); + } catch (err) { + setDeleteError(formatApiError(err)); + setDeleting(false); + } + }; + + return ( +
+
+ ← All tables +

{name}

+ {writeAllowed && ( + + )} +
+ +
+ {detail.loading &&
Loading…
} + {detail.error?.status === 404 && ( +
+ Either the table does not exist or + /admin/api/v1/dynamo/tables/{`{name}`} + is not wired yet. +
+ )} + {detail.error && detail.error.status !== 404 && ( +
{formatApiError(detail.error)}
+ )} + {detail.data && ( +
+
Partition key
+
{detail.data.partition_key || "—"}
+
Sort key
+
{detail.data.sort_key || "—"}
+
Generation
+
{detail.data.generation}
+
+ )} +
+ {detail.data?.global_secondary_indexes && detail.data.global_secondary_indexes.length > 0 && ( +
+

Global secondary indexes

+ + + + + + + + + + + {detail.data.global_secondary_indexes.map((g) => ( + + + + + + + ))} + +
NamePartition keySort keyProjection
{g.name}{g.partition_key}{g.sort_key || "—"}{g.projection_type}
+
+ )} + + !deleting && setConfirmDelete(false)} + busy={deleting} + > +

+ Permanently delete {name}? All items will be removed. +

+ {deleteError &&
{deleteError}
} +
+ + +
+
+
+ ); +} diff --git a/web/admin/src/pages/DynamoList.tsx b/web/admin/src/pages/DynamoList.tsx new file mode 100644 index 000000000..1e4942fb1 --- /dev/null +++ b/web/admin/src/pages/DynamoList.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CreateTableRequest } from "../api/client"; +import { useAuth } from "../auth"; +import { Modal } from "../components/Modal"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function DynamoListPage() { + const { session } = useAuth(); + const tables = useApiQuery((signal) => api.listTables(undefined, signal), []); + const [open, setOpen] = useState(false); + const writeAllowed = session?.role === "full"; + + return ( +
+
+
+

DynamoDB tables

+

+ Backed by the existing CreateTable / + ListTables handlers in + adapter/dynamodb.go. +

+
+
+ + {writeAllowed && ( + + )} +
+
+ +
+ {tables.loading &&
Loading…
} + {tables.error?.status === 404 && ( + + )} + {tables.error && tables.error.status !== 404 && ( +
{formatApiError(tables.error)}
+ )} + {tables.data && tables.data.tables.length === 0 && ( +
No tables yet.
+ )} + {tables.data && tables.data.tables.length > 0 && ( + + + + + + + + {tables.data.tables.map((name) => ( + + + + + ))} + +
Table +
+ + {name} + + + + details → + +
+ )} + {tables.data?.next_token && ( +
+ More tables exist. Pagination UI is pending (Section 4.3). +
+ )} +
+ + setOpen(false)}> + setOpen(false)} + onCreated={() => { + setOpen(false); + tables.reload(); + }} + /> + +
+ ); +} + +interface CreateTableFormProps { + onCancel: () => void; + onCreated: () => void; +} + +function CreateTableForm({ onCancel, onCreated }: CreateTableFormProps) { + const [name, setName] = useState(""); + const [pkName, setPkName] = useState("id"); + const [pkType, setPkType] = useState<"S" | "N" | "B">("S"); + const [skName, setSkName] = useState(""); + const [skType, setSkType] = useState<"S" | "N" | "B">("S"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setBusy(true); + try { + const req: CreateTableRequest = { + table_name: name.trim(), + partition_key: { name: pkName.trim(), type: pkType }, + }; + if (skName.trim()) { + req.sort_key = { name: skName.trim(), type: skType }; + } + await api.createTable(req); + onCreated(); + } catch (err) { + setError(formatApiError(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + setName(e.target.value)} + required + minLength={3} + pattern="[A-Za-z0-9_.\-]+" + /> +
+
+ Partition key +
+ setPkName(e.target.value)} + required + /> + +
+
+
+ Sort key (optional) +
+ setSkName(e.target.value)} + /> + +
+
+ {error &&
{error}
} +
+ + +
+
+ ); +} + +function PendingNotice({ phase }: { phase: string }) { + return ( +
+ The DynamoDB admin endpoints are not yet wired on this build (design phase {phase}). + Once /admin/api/v1/dynamo/tables ships, this page + will populate without further frontend changes. +
+ ); +} diff --git a/web/admin/src/pages/Login.tsx b/web/admin/src/pages/Login.tsx new file mode 100644 index 000000000..4dd2f9e78 --- /dev/null +++ b/web/admin/src/pages/Login.tsx @@ -0,0 +1,102 @@ +import { useState, type FormEvent } from "react"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../auth"; +import { ApiError } from "../api/client"; +import { formatApiError } from "../lib/useApi"; + +interface LocationState { + from?: string; +} + +export function LoginPage() { + const { session, loading, login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [accessKey, setAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [error, setError] = useState(null); + + if (session) { + const from = (location.state as LocationState | null)?.from ?? "/"; + return ; + } + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + try { + // Trim both sides: SigV4 secret keys never contain whitespace, so + // trimming is safe and saves an operator who pasted a key with + // trailing newline from a cryptic 401. + await login(accessKey.trim(), secretKey.trim()); + const from = (location.state as LocationState | null)?.from ?? "/"; + navigate(from, { replace: true }); + } catch (err) { + // Status-specific messaging beats raw codes here: 429 reads + // very differently from 403 to a human typing credentials. + if (err instanceof ApiError) { + if (err.status === 429) { + setError("Too many attempts. Wait a minute and try again."); + } else if (err.status === 401) { + setError("Invalid access key or secret key."); + } else if (err.status === 403) { + setError("This access key is not authorised for the admin dashboard."); + } else { + setError(formatApiError(err)); + } + } else { + setError(formatApiError(err)); + } + } + }; + + return ( +
+
+
+
elastickv admin
+

+ Sign in with a SigV4 access key registered under + admin.read_only_access_keys + or + admin.full_access_keys. +

+
+
+ + setAccessKey(e.target.value)} + required + /> +
+
+ + setSecretKey(e.target.value)} + required + /> +
+ {error && ( +
+ {error} +
+ )} + +
+
+ ); +} diff --git a/web/admin/src/pages/NotFound.tsx b/web/admin/src/pages/NotFound.tsx new file mode 100644 index 000000000..02e877071 --- /dev/null +++ b/web/admin/src/pages/NotFound.tsx @@ -0,0 +1,12 @@ +import { Link } from "react-router-dom"; + +export function NotFoundPage() { + return ( +
+
Page not found
+

+ Back to overview +

+
+ ); +} diff --git a/web/admin/src/pages/S3Detail.tsx b/web/admin/src/pages/S3Detail.tsx new file mode 100644 index 000000000..26f2b7d7d --- /dev/null +++ b/web/admin/src/pages/S3Detail.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { api } from "../api/client"; +import { useAuth } from "../auth"; +import { Modal } from "../components/Modal"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function S3DetailPage() { + const { name = "" } = useParams<{ name: string }>(); + const { session } = useAuth(); + const detail = useApiQuery((signal) => api.describeBucket(name, signal), [name]); + const [aclBusy, setAclBusy] = useState(false); + const [aclError, setAclError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const navigate = useNavigate(); + const writeAllowed = session?.role === "full"; + + const setAcl = async (acl: "private" | "public-read") => { + setAclBusy(true); + setAclError(null); + try { + await api.putBucketAcl(name, acl); + detail.reload(); + } catch (err) { + setAclError(formatApiError(err)); + } finally { + setAclBusy(false); + } + }; + + const onDelete = async () => { + setDeleting(true); + setDeleteError(null); + try { + await api.deleteBucket(name); + navigate("/s3", { replace: true }); + } catch (err) { + setDeleteError(formatApiError(err)); + setDeleting(false); + } + }; + + const currentAcl = detail.data?.acl ?? "private"; + + return ( +
+
+ ← All buckets +

{name}

+ {writeAllowed && ( + + )} +
+ +
+ {detail.loading &&
Loading…
} + {detail.error?.status === 404 && ( +
+ Bucket missing or admin endpoint not yet wired (design phase P2). +
+ )} + {detail.error && detail.error.status !== 404 && ( +
{formatApiError(detail.error)}
+ )} + {detail.data && ( +
+
ACL
+
{currentAcl}
+
Created
+
+ {detail.data.created_at ? new Date(detail.data.created_at).toLocaleString() : "—"} +
+
+ )} +
+ + {writeAllowed && detail.data && ( +
+

ACL

+
+ + + {aclBusy && Updating…} +
+ {aclError &&
{aclError}
} +
+ )} + + !deleting && setConfirmDelete(false)} + busy={deleting} + > +

+ Permanently delete {name}? The bucket must be empty; + the request will fail otherwise. +

+ {deleteError &&
{deleteError}
} +
+ + +
+
+
+ ); +} diff --git a/web/admin/src/pages/S3List.tsx b/web/admin/src/pages/S3List.tsx new file mode 100644 index 000000000..6fddde056 --- /dev/null +++ b/web/admin/src/pages/S3List.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CreateBucketRequest } from "../api/client"; +import { useAuth } from "../auth"; +import { Modal } from "../components/Modal"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function S3ListPage() { + const { session } = useAuth(); + const buckets = useApiQuery((signal) => api.listBuckets(undefined, signal), []); + const [open, setOpen] = useState(false); + const writeAllowed = session?.role === "full"; + + return ( +
+
+
+

S3 buckets

+

+ Backed by the existing handlers in + adapter/s3.go; + create + ACL flow lands as a single Raft commit (Section 4.2). +

+
+
+ + {writeAllowed && ( + + )} +
+
+ +
+ {buckets.loading &&
Loading…
} + {buckets.error?.status === 404 && ( +
+ S3 admin endpoints not yet wired (design phase P2). +
+ )} + {buckets.error && buckets.error.status !== 404 && ( +
{formatApiError(buckets.error)}
+ )} + {buckets.data && buckets.data.buckets.length === 0 && ( +
No buckets yet.
+ )} + {buckets.data && buckets.data.buckets.length > 0 && ( + + + + + + + + + + {buckets.data.buckets.map((b) => ( + + + + + + + ))} + +
BucketACLCreated +
+ + {b.bucket_name} + + + {b.acl ?? "private"} + + {b.created_at ? new Date(b.created_at).toLocaleString() : "—"} + + + details → + +
+ )} +
+ + setOpen(false)}> + setOpen(false)} + onCreated={() => { + setOpen(false); + buckets.reload(); + }} + /> + +
+ ); +} + +function CreateBucketForm({ + onCancel, + onCreated, +}: { + onCancel: () => void; + onCreated: () => void; +}) { + const [name, setName] = useState(""); + const [acl, setAcl] = useState<"private" | "public-read">("private"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setBusy(true); + try { + const req: CreateBucketRequest = { bucket_name: name.trim(), acl }; + await api.createBucket(req); + onCreated(); + } catch (err) { + setError(formatApiError(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + setName(e.target.value)} + required + minLength={3} + maxLength={63} + pattern="[a-z0-9][a-z0-9.\-]+[a-z0-9]" + title="3–63 chars; lowercase letters, digits, '.', '-'." + /> +
+
+ + +
+ {error &&
{error}
} +
+ + +
+
+ ); +} diff --git a/web/admin/src/pages/SqsDetail.tsx b/web/admin/src/pages/SqsDetail.tsx new file mode 100644 index 000000000..1ef8b9419 --- /dev/null +++ b/web/admin/src/pages/SqsDetail.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { api } from "../api/client"; +import { useAuth } from "../auth"; +import { Modal } from "../components/Modal"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function SqsDetailPage() { + const { name = "" } = useParams<{ name: string }>(); + const { session } = useAuth(); + const detail = useApiQuery((signal) => api.describeQueue(name, signal), [name]); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const navigate = useNavigate(); + const writeAllowed = session?.role === "full"; + + const onDelete = async () => { + setDeleting(true); + setDeleteError(null); + try { + await api.deleteQueue(name); + navigate("/sqs", { replace: true }); + } catch (err) { + setDeleteError(formatApiError(err)); + setDeleting(false); + } + }; + + return ( +
+
+ ← All queues +

{name}

+ {detail.data && ( + + {detail.data.is_fifo ? "FIFO" : "Standard"} + + )} + {writeAllowed && detail.data && ( + + )} +
+ +
+ {detail.loading &&
Loading…
} + {detail.error?.status === 404 && ( +
+ Either the queue does not exist or the SQS admin endpoints are not + wired (no --sqsAddress). +
+ )} + {detail.error && detail.error.status !== 404 && ( +
{formatApiError(detail.error)}
+ )} + {detail.data && ( +
+
Generation
+
{detail.data.generation}
+
Created
+
+ {detail.data.created_at ? new Date(detail.data.created_at).toLocaleString() : "—"} +
+
+ )} +
+ + {detail.data && ( +
+
+

Approximate message counts

+ {detail.data.counters_truncated && ( + + truncated + + )} +
+
+ + + +
+
+ )} + + {detail.data?.attributes && Object.keys(detail.data.attributes).length > 0 && ( +
+

Configuration

+
+ {Object.entries(detail.data.attributes).map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ )} + + !deleting && setConfirmDelete(false)} + busy={deleting} + > +

+ Permanently delete {name}? All messages + will be removed and the queue cannot be recovered. +

+ {deleteError &&
{deleteError}
} +
+ + +
+
+
+ ); +} + +function CounterCard({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/admin/src/pages/SqsList.tsx b/web/admin/src/pages/SqsList.tsx new file mode 100644 index 000000000..3d42a658e --- /dev/null +++ b/web/admin/src/pages/SqsList.tsx @@ -0,0 +1,69 @@ +import { Link } from "react-router-dom"; +import { api } from "../api/client"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +export function SqsListPage() { + const queues = useApiQuery((signal) => api.listQueues(signal), []); + + return ( +
+
+
+

SQS queues

+

+ Backed by the SigV4-bypass admin entrypoints in + adapter/sqs_admin.go. + Detail pages show the new approximate counters from Phase 3.A. +

+
+ +
+ +
+ {queues.loading &&
Loading…
} + {queues.error?.status === 404 && ( +
+ SQS admin endpoints not wired on this build (the cluster was started + without --sqsAddress, so the + admin listener leaves /admin/api/v1/sqs/* + off the wire). +
+ )} + {queues.error && queues.error.status !== 404 && ( +
{formatApiError(queues.error)}
+ )} + {queues.data && queues.data.queues.length === 0 && ( +
No queues yet.
+ )} + {queues.data && queues.data.queues.length > 0 && ( + + + + + + + + {queues.data.queues.map((name) => ( + + + + + ))} + +
Queue +
+ + {name} + + + + details → + +
+ )} +
+
+ ); +} diff --git a/web/admin/src/styles.css b/web/admin/src/styles.css new file mode 100644 index 000000000..f22b2080d --- /dev/null +++ b/web/admin/src/styles.css @@ -0,0 +1,79 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + color-scheme: light dark; + --surface: 250 250 252; + --surface-2: 240 242 247; + --ink: 17 24 39; + --muted: 100 116 139; + --accent: 37 99 235; + --danger: 220 38 38; + --border: 226 232 240; + } + + @media (prefers-color-scheme: dark) { + :root { + --surface: 15 23 42; + --surface-2: 24 33 54; + --ink: 226 232 240; + --muted: 148 163 184; + --accent: 96 165 250; + --danger: 248 113 113; + --border: 51 65 85; + } + } + + html, body, #root { + height: 100%; + } + + body { + @apply bg-surface text-ink font-sans antialiased; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors; + } + .btn-primary { + @apply btn bg-accent text-white hover:opacity-90 disabled:opacity-50; + } + .btn-secondary { + @apply btn border border-border bg-surface-2 hover:bg-surface; + } + .btn-danger { + @apply btn border border-danger text-danger hover:bg-danger hover:text-white; + } + .input { + @apply w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm + focus:outline-none focus:ring-2 focus:ring-accent; + } + .label { + @apply block text-xs font-medium text-muted mb-1; + } + .card { + @apply rounded-lg border border-border bg-surface-2 p-4; + } + .table { + @apply w-full text-sm; + } + .table th { + @apply text-left text-xs font-medium uppercase tracking-wide text-muted py-2 px-3 border-b border-border; + } + .table td { + @apply py-2 px-3 border-b border-border; + } + .pill { + @apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium; + } + .pill-accent { + @apply pill bg-accent/10 text-accent; + } + .pill-muted { + @apply pill bg-surface text-muted border border-border; + } +} diff --git a/web/admin/src/vite-env.d.ts b/web/admin/src/vite-env.d.ts new file mode 100644 index 000000000..39fc679a3 --- /dev/null +++ b/web/admin/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare module "*.css"; diff --git a/web/admin/tailwind.config.js b/web/admin/tailwind.config.js new file mode 100644 index 000000000..49ca86458 --- /dev/null +++ b/web/admin/tailwind.config.js @@ -0,0 +1,25 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + // Minimal token palette. shadcn/ui-style design tokens kept + // light: only what the current pages actually consume. Add + // more as new components land. + surface: "rgb(var(--surface) / )", + "surface-2": "rgb(var(--surface-2) / )", + ink: "rgb(var(--ink) / )", + muted: "rgb(var(--muted) / )", + accent: "rgb(var(--accent) / )", + danger: "rgb(var(--danger) / )", + border: "rgb(var(--border) / )", + }, + fontFamily: { + sans: ["ui-sans-serif", "system-ui", "-apple-system", "sans-serif"], + mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"], + }, + }, + }, + plugins: [], +}; diff --git a/web/admin/tsconfig.app.json b/web/admin/tsconfig.app.json new file mode 100644 index 000000000..458b9230e --- /dev/null +++ b/web/admin/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/web/admin/tsconfig.json b/web/admin/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/web/admin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/admin/tsconfig.node.json b/web/admin/tsconfig.node.json new file mode 100644 index 000000000..b32b0f7b8 --- /dev/null +++ b/web/admin/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/web/admin/vite.config.ts b/web/admin/vite.config.ts new file mode 100644 index 000000000..981d7d99e --- /dev/null +++ b/web/admin/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath, URL } from "node:url"; + +// SPA is mounted at /admin by the Go router (internal/admin/router.go). +// Vite's `base` controls both the asset URLs in index.html and the dev +// server path; `/admin/` keeps both consistent with what the embed +// handler serves at runtime. +export default defineConfig({ + plugins: [react()], + base: "/admin/", + build: { + // Build output goes straight into the Go embed directory so a + // `npm run build` followed by `go build` produces a single binary + // with the latest SPA bundle. Using fileURLToPath instead of + // __dirname keeps the config valid under ESM without pulling in + // @types/node just for a string concat. + outDir: fileURLToPath(new URL("../../internal/admin/dist", import.meta.url)), + emptyOutDir: true, + sourcemap: false, + // Keep asset paths under dist/assets so they map onto the + // /admin/assets/* route the Go router exposes. + assetsDir: "assets", + }, + server: { + port: 5173, + proxy: { + // During `npm run dev`, forward API + auth calls to a local + // elastickv admin listener. Adjust the target if running on a + // different host/port. + "/admin/api": "http://127.0.0.1:8080", + "/admin/healthz": "http://127.0.0.1:8080", + }, + }, +});