From 7681c70c87992438aab6fad9b89d4df2a32d7b9a Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Fri, 1 May 2026 13:07:52 +0100 Subject: [PATCH 1/3] feat: add datum-ui and change user processing --- internal/activityprocessor/policycache.go | 21 +- internal/activityprocessor/processor.go | 10 +- internal/activityprocessor/userresolver.go | 101 ++ internal/processor/activity.go | 116 +- internal/processor/classifier.go | 18 + internal/processor/enrichment_test.go | 232 +++ internal/processor/evaluate.go | 38 +- internal/processor/userlookup.go | 177 ++ internal/processor/userlookup_test.go | 185 ++ pkg/apis/activity/v1alpha1/types_activity.go | 26 + ui/example/app/routes/resource-history.tsx | 162 +- ui/package.json | 11 +- ui/pnpm-lock.yaml | 1490 +++++++++-------- ui/postcss.config.js | 6 - ui/rollup.config.mjs | 19 +- ui/src/components/ActionMultiSelect.tsx | 2 +- ui/src/components/ActionToggle.tsx | 2 +- ui/src/components/ActivityExpandedDetails.tsx | 330 ++-- ui/src/components/ActivityFeed.tsx | 608 ++++--- ui/src/components/ActivityFeedFilters.tsx | 250 +-- ui/src/components/ActivityFeedItem.tsx | 393 +++-- .../components/ActivityFeedItemSkeleton.tsx | 4 +- ui/src/components/ActivityFeedSummary.tsx | 100 +- ui/src/components/ActivityLayout.tsx | 2 +- ui/src/components/ApiErrorAlert.tsx | 2 +- ui/src/components/AuditEventViewer.tsx | 276 ++- ui/src/components/AuditLogExpandedDetails.tsx | 410 ++--- ui/src/components/AuditLogFeedItem.tsx | 168 +- ui/src/components/AuditLogFilters.tsx | 209 ++- ui/src/components/AuditLogQueryComponent.tsx | 255 +-- ui/src/components/ChangeSourceToggle.tsx | 47 +- ui/src/components/DateTimeRangePicker.tsx | 6 +- ui/src/components/EventExpandedDetails.tsx | 338 ++-- ui/src/components/EventFeedItem.tsx | 278 ++- ui/src/components/EventFeedItemSkeleton.tsx | 47 - ui/src/components/EventTypeToggle.tsx | 2 +- ui/src/components/EventsFeed.tsx | 301 ++-- ui/src/components/EventsFeedFilters.tsx | 20 +- ui/src/components/FilterBuilder.tsx | 10 +- .../FilterBuilderWithAutocomplete.tsx | 8 +- .../components/PolicyActivityViewSkeleton.tsx | 4 +- ui/src/components/PolicyDetailView.tsx | 8 +- ui/src/components/PolicyEditView.tsx | 10 +- ui/src/components/PolicyEditor.tsx | 12 +- ui/src/components/PolicyList.tsx | 10 +- ui/src/components/PolicyPreviewPanel.tsx | 4 +- ui/src/components/PolicyPreviewResult.tsx | 4 +- ui/src/components/PolicyResourceForm.tsx | 6 +- ui/src/components/PolicyRuleEditor.tsx | 10 +- ui/src/components/PolicyRuleEditorDialog.tsx | 8 +- ui/src/components/PolicyRuleList.tsx | 10 +- ui/src/components/ReindexJobCreate.tsx | 2 +- ui/src/components/ReindexJobDetailView.tsx | 4 +- ui/src/components/ReindexJobDialog.tsx | 2 +- ui/src/components/ReindexJobList.tsx | 10 +- ui/src/components/ResourceHistoryView.tsx | 2 +- ui/src/components/RulePreviewPanel.tsx | 4 +- ui/src/components/SampleInputTemplates.tsx | 2 +- ui/src/components/SimpleQueryBuilder.tsx | 10 +- ui/src/components/TenantBadge.tsx | 116 +- ui/src/components/Timestamp.tsx | 148 ++ ui/src/components/VerbToggle.tsx | 85 - ui/src/components/details.tsx | 191 +++ ui/src/components/ui/alert.tsx | 63 - ui/src/components/ui/badge.tsx | 73 +- ui/src/components/ui/button.tsx | 130 +- ui/src/components/ui/card.tsx | 79 - ui/src/components/ui/checkbox.tsx | 28 - ui/src/components/ui/dialog.tsx | 172 +- ui/src/components/ui/filter-chip.tsx | 2 +- ui/src/components/ui/input.tsx | 25 - ui/src/components/ui/label.tsx | 21 - ui/src/components/ui/select.tsx | 158 -- ui/src/components/ui/separator.tsx | 29 - ui/src/components/ui/sheet.tsx | 129 -- ui/src/components/ui/skeleton.tsx | 12 - ui/src/components/ui/tabs.tsx | 53 - ui/src/components/ui/textarea.tsx | 24 - ui/src/components/ui/time-range-dropdown.tsx | 4 +- ui/src/components/ui/tooltip.tsx | 61 +- ui/src/hooks/useActivityFeed.ts | 35 +- ui/src/hooks/useEventsFeed.ts | 32 +- ui/src/index.ts | 35 +- ui/src/styles.css | 13 - ui/src/types/activity.ts | 17 + ui/tailwind.config.js | 59 - ui/tsconfig.json | 2 +- 87 files changed, 4844 insertions(+), 3754 deletions(-) create mode 100644 internal/activityprocessor/userresolver.go create mode 100644 internal/processor/enrichment_test.go create mode 100644 internal/processor/userlookup.go create mode 100644 internal/processor/userlookup_test.go delete mode 100644 ui/postcss.config.js delete mode 100644 ui/src/components/EventFeedItemSkeleton.tsx create mode 100644 ui/src/components/Timestamp.tsx delete mode 100644 ui/src/components/VerbToggle.tsx create mode 100644 ui/src/components/details.tsx delete mode 100644 ui/src/components/ui/alert.tsx delete mode 100644 ui/src/components/ui/card.tsx delete mode 100644 ui/src/components/ui/checkbox.tsx delete mode 100644 ui/src/components/ui/input.tsx delete mode 100644 ui/src/components/ui/label.tsx delete mode 100644 ui/src/components/ui/select.tsx delete mode 100644 ui/src/components/ui/separator.tsx delete mode 100644 ui/src/components/ui/sheet.tsx delete mode 100644 ui/src/components/ui/skeleton.tsx delete mode 100644 ui/src/components/ui/tabs.tsx delete mode 100644 ui/src/components/ui/textarea.tsx delete mode 100644 ui/src/styles.css delete mode 100644 ui/tailwind.config.js diff --git a/internal/activityprocessor/policycache.go b/internal/activityprocessor/policycache.go index 8de25fd1..67b9b208 100644 --- a/internal/activityprocessor/policycache.go +++ b/internal/activityprocessor/policycache.go @@ -464,11 +464,27 @@ func (r *CompiledRule) EvaluateEventMatch(eventMap map[string]any) (bool, error) // EvaluateCompiledAuditRules evaluates pre-compiled audit rules against an audit event. // Returns the generated Activity, the matching rule index, and any error. // Returns (nil, -1, nil) if no rule matched. +// +// Use EvaluateCompiledAuditRulesWithResolver to also enrich the resulting +// activity with user display names; this convenience wrapper passes nil. func EvaluateCompiledAuditRules( policy *CompiledPolicy, auditMap map[string]any, audit *auditv1.Event, resolveKind processor.KindResolver, +) (*v1alpha1.Activity, int, error) { + return EvaluateCompiledAuditRulesWithResolver(policy, auditMap, audit, resolveKind, nil) +} + +// EvaluateCompiledAuditRulesWithResolver is like EvaluateCompiledAuditRules +// but enriches the activity actor and any User-typed link targets with +// display names looked up via resolver. +func EvaluateCompiledAuditRulesWithResolver( + policy *CompiledPolicy, + auditMap map[string]any, + audit *auditv1.Event, + resolveKind processor.KindResolver, + resolver processor.UserResolver, ) (*v1alpha1.Activity, int, error) { for i := range policy.AuditRules { rule := &policy.AuditRules[i] @@ -491,8 +507,9 @@ func EvaluateCompiledAuditRules( } builder := &processor.ActivityBuilder{ - APIGroup: policy.APIGroup, - Kind: policy.Kind, + APIGroup: policy.APIGroup, + Kind: policy.Kind, + UserResolver: resolver, } activity, err := builder.BuildFromAudit(audit, summary, links, resolveKind) if err != nil { diff --git a/internal/activityprocessor/processor.go b/internal/activityprocessor/processor.go index 22a3a154..59ee1ee8 100644 --- a/internal/activityprocessor/processor.go +++ b/internal/activityprocessor/processor.go @@ -294,6 +294,10 @@ type Processor struct { // dlqRetryController handles automatic retry of DLQ events. dlqRetryController *DLQRetryController + // userResolver enriches activities with iam User display names. nil + // disables enrichment; activities are emitted with raw emails/IDs. + userResolver processor.UserResolver + wg sync.WaitGroup ctx context.Context cancel context.CancelFunc @@ -393,6 +397,10 @@ func (p *Processor) Start(ctx context.Context) error { // Create event emitter for health reporting p.eventEmitter = NewEventEmitter(k8sClient, recorder) + // Wire a cached iam User resolver so activities are enriched with + // human-readable display names. Failures fall back to email/UID. + p.userResolver = processor.NewCachedUserResolver(NewIAMUserResolver(k8sClient), 0, 0) + // Build NATS connection options natsOpts := []nats.Option{ nats.Name("activity-processor"), @@ -1034,7 +1042,7 @@ func (p *Processor) processMessage(msg *nats.Msg) error { // evaluateCompiledAuditRules evaluates audit rules using pre-compiled CEL programs. func (p *Processor) evaluateCompiledAuditRules(policy *CompiledPolicy, auditMap map[string]any, audit *auditv1.Event) (*v1alpha1.Activity, int, error) { - return EvaluateCompiledAuditRules(policy, auditMap, audit, p.resourceToKind) + return EvaluateCompiledAuditRulesWithResolver(policy, auditMap, audit, p.resourceToKind, p.userResolver) } // auditToMap converts an audit event to a map for CEL evaluation. diff --git a/internal/activityprocessor/userresolver.go b/internal/activityprocessor/userresolver.go new file mode 100644 index 00000000..e1980730 --- /dev/null +++ b/internal/activityprocessor/userresolver.go @@ -0,0 +1,101 @@ +package activityprocessor + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.miloapis.com/activity/internal/processor" +) + +// userGVK identifies the iam User custom resource we resolve display names +// from. The activity processor never serves these objects, so we query them +// as Unstructured to avoid pulling in the milo iam Go types. +var userGVK = schema.GroupVersionKind{ + Group: "iam.miloapis.com", + Version: "v1alpha1", + Kind: "User", +} + +// IAMUserResolver implements processor.UserResolver against an iam User CR +// store reached through a controller-runtime client. It is safe for +// concurrent use; wrap with processor.NewCachedUserResolver to add caching +// and per-key single-flight. +type IAMUserResolver struct { + Client client.Client +} + +// NewIAMUserResolver returns a resolver that fetches iam Users via c. +func NewIAMUserResolver(c client.Client) *IAMUserResolver { + return &IAMUserResolver{Client: c} +} + +// LookupByEmail finds the first iam User whose spec.email matches the given +// address. Returns ok=false when no match is found or email is empty. +func (r *IAMUserResolver) LookupByEmail(ctx context.Context, email string) (processor.UserInfo, bool, error) { + if email == "" || r == nil || r.Client == nil { + return processor.UserInfo{}, false, nil + } + + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: userGVK.Group, + Version: userGVK.Version, + Kind: userGVK.Kind + "List", + }) + + // Most clusters do not index spec.email server-side; list all and filter + // in process. The cached wrapper amortizes this across calls; if scale + // becomes a concern, register a field indexer for spec.email in the + // manager's cache. + if err := r.Client.List(ctx, list); err != nil { + return processor.UserInfo{}, false, fmt.Errorf("list iam users: %w", err) + } + + for i := range list.Items { + item := &list.Items[i] + got, _, _ := unstructured.NestedString(item.Object, "spec", "email") + if got == email { + return userInfoFromUnstructured(item), true, nil + } + } + + return processor.UserInfo{}, false, nil +} + +// LookupByName fetches an iam User by metadata.name and returns its display +// fields. Returns ok=false on NotFound. +func (r *IAMUserResolver) LookupByName(ctx context.Context, name string) (processor.UserInfo, bool, error) { + if name == "" || r == nil || r.Client == nil { + return processor.UserInfo{}, false, nil + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(userGVK) + + if err := r.Client.Get(ctx, client.ObjectKey{Name: name}, obj); err != nil { + if apierrors.IsNotFound(err) { + return processor.UserInfo{}, false, nil + } + return processor.UserInfo{}, false, fmt.Errorf("get iam user %q: %w", name, err) + } + + return userInfoFromUnstructured(obj), true, nil +} + +func userInfoFromUnstructured(obj *unstructured.Unstructured) processor.UserInfo { + given, _, _ := unstructured.NestedString(obj.Object, "spec", "givenName") + family, _, _ := unstructured.NestedString(obj.Object, "spec", "familyName") + email, _, _ := unstructured.NestedString(obj.Object, "spec", "email") + return processor.UserInfo{ + Name: obj.GetName(), + GivenName: given, + FamilyName: family, + Email: email, + UID: string(obj.GetUID()), + } +} diff --git a/internal/processor/activity.go b/internal/processor/activity.go index 84ded593..48b1073c 100644 --- a/internal/processor/activity.go +++ b/internal/processor/activity.go @@ -1,10 +1,12 @@ package processor import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" + "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -15,6 +17,10 @@ import ( "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" ) +// iamGroup is the API group for Milo IAM resources we enrich with user +// display names. +const iamGroup = "iam.miloapis.com" + // activityName generates a deterministic activity name from the origin event // identifier and the policy's resource target. The same input always produces // the same name, enabling NATS message deduplication on retries. @@ -35,6 +41,10 @@ type ActivityBuilder struct { // Resource information from the policy APIGroup string Kind string + + // UserResolver is consulted (when non-nil) to enrich the actor and any + // User-typed link targets with human-readable display names. + UserResolver UserResolver } // BuildFromAudit constructs an Activity from an audit event. @@ -64,8 +74,9 @@ func (b *ActivityBuilder) BuildFromAudit( resourceUID := extractResponseUID(audit.ResponseObject) // Classify change source and resolve actor + ctx := context.Background() changeSource := ClassifyChangeSource(audit.User) - actor := ResolveActor(audit.User) + actor := ResolveActorWithResolver(ctx, audit.User, b.UserResolver) tenant := ExtractTenant(audit.User) // Generate activity name @@ -77,6 +88,10 @@ func (b *ActivityBuilder) BuildFromAudit( return nil, fmt.Errorf("%w: %v", ErrActivityBuild, err) } + // Enrich: replace actor email with display name in summary, attach actor + // link, and hydrate any User-typed link targets with display names. + summary, activityLinks = enrichSummaryWithDisplayNames(ctx, summary, actor, activityLinks, b.UserResolver) + return &v1alpha1.Activity{ TypeMeta: metav1.TypeMeta{ APIVersion: v1alpha1.SchemeGroupVersion.String(), @@ -115,6 +130,101 @@ func (b *ActivityBuilder) BuildFromAudit( }, nil } +// enrichSummaryWithDisplayNames rewrites the summary to use human-readable +// display names for the actor and any User-typed link targets, and appends +// link metadata so the UI can render an email/UID tooltip. +// +// Behavior: +// - When the actor has a DisplayName, the first occurrence of the actor's +// Name (typically an email) in the summary is replaced with the +// DisplayName, and a synthetic actor link is appended carrying the +// DisplayName, Email, and UID. +// - For each existing link whose resource is an iam User, the resolver is +// queried by the link's resource name; on hit, the link's Marker is +// replaced in the summary with the user's DisplayName and the link's +// DisplayName/Email fields are populated. +// +// Returns the rewritten summary and links. When resolver is nil or no +// matches occur, the inputs are returned unchanged. +func enrichSummaryWithDisplayNames( + ctx context.Context, + summary string, + actor v1alpha1.ActivityActor, + links []v1alpha1.ActivityLink, + resolver UserResolver, +) (string, []v1alpha1.ActivityLink) { + // Actor: if we have a display name distinct from the name, swap it into + // the summary. If the policy template already wrapped the actor with + // link() (so a link entry exists with marker == actor.Name), upgrade + // that entry in place; otherwise append a synthetic actor link so the + // UI can render the hover tooltip. + if actor.DisplayName != "" && actor.DisplayName != actor.Name && actor.Name != "" { + summaryHadActor := strings.Contains(summary, actor.Name) + if summaryHadActor { + summary = strings.Replace(summary, actor.Name, actor.DisplayName, 1) + } + + upgraded := false + for i := range links { + if links[i].Marker == actor.Name { + links[i].Marker = actor.DisplayName + links[i].DisplayName = actor.DisplayName + if links[i].Email == "" { + links[i].Email = actor.Email + } + upgraded = true + break + } + } + if !upgraded && summaryHadActor { + links = append(links, v1alpha1.ActivityLink{ + Marker: actor.DisplayName, + Resource: v1alpha1.ActivityResource{ + APIGroup: iamGroup, + Kind: "User", + UID: actor.UID, + }, + DisplayName: actor.DisplayName, + Email: actor.Email, + }) + } + } + + // User-typed link targets: hydrate via resolver and rewrite the summary. + if resolver != nil { + for i := range links { + link := &links[i] + if !isUserLink(link.Resource) { + continue + } + if link.Resource.Name == "" || link.DisplayName != "" { + continue + } + info, ok, err := resolver.LookupByName(ctx, link.Resource.Name) + if err != nil || !ok { + continue + } + displayName := info.DisplayName() + if displayName == "" { + continue + } + if link.Marker != "" && link.Marker != displayName { + summary = strings.Replace(summary, link.Marker, displayName, 1) + link.Marker = displayName + } + link.DisplayName = displayName + link.Email = info.Email + } + } + + return summary, links +} + +// isUserLink reports whether the resource targets an iam User CR. +func isUserLink(r v1alpha1.ActivityResource) bool { + return r.APIGroup == iamGroup && r.Kind == "User" +} + // extractResponseUID extracts the UID from an audit response object's metadata. func extractResponseUID(responseObject *runtime.Unknown) string { if responseObject == nil || len(responseObject.Raw) == 0 { @@ -202,6 +312,10 @@ func (b *ActivityBuilder) BuildFromEvent( return nil, fmt.Errorf("%w: %v", ErrActivityBuild, err) } + // Hydrate User-typed links with display names (event actors are system + // components, so no actor enrichment is needed). + summary, activityLinks = enrichSummaryWithDisplayNames(context.Background(), summary, actor, activityLinks, b.UserResolver) + return &v1alpha1.Activity{ TypeMeta: metav1.TypeMeta{ APIVersion: v1alpha1.SchemeGroupVersion.String(), diff --git a/internal/processor/classifier.go b/internal/processor/classifier.go index 84e01ac1..14109d2b 100644 --- a/internal/processor/classifier.go +++ b/internal/processor/classifier.go @@ -1,6 +1,7 @@ package processor import ( + "context" "strings" authnv1 "k8s.io/api/authentication/v1" @@ -38,6 +39,14 @@ const ( // - user: Human users authenticated via OIDC or other providers // - system: Kubernetes controllers, service accounts, and other system components func ResolveActor(user authnv1.UserInfo) v1alpha1.ActivityActor { + return ResolveActorWithResolver(context.Background(), user, nil) +} + +// ResolveActorWithResolver behaves like ResolveActor but additionally +// populates ActivityActor.DisplayName for human users when resolver is non-nil +// and a matching User record is found. Resolver errors are silently ignored: +// the activity is still emitted with whatever data is available. +func ResolveActorWithResolver(ctx context.Context, user authnv1.UserInfo, resolver UserResolver) v1alpha1.ActivityActor { actor := v1alpha1.ActivityActor{ UID: user.UID, } @@ -62,6 +71,15 @@ func ResolveActor(user authnv1.UserInfo) v1alpha1.ActivityActor { actor.Name = "unknown" } + // Enrich with display name for human users when a resolver is available. + if resolver != nil && actor.Type == ActorTypeUser && actor.Email != "" { + if info, ok, err := resolver.LookupByEmail(ctx, actor.Email); err == nil && ok { + if dn := info.DisplayName(); dn != "" { + actor.DisplayName = dn + } + } + } + return actor } diff --git a/internal/processor/enrichment_test.go b/internal/processor/enrichment_test.go new file mode 100644 index 00000000..dd5422ee --- /dev/null +++ b/internal/processor/enrichment_test.go @@ -0,0 +1,232 @@ +package processor + +import ( + "context" + "testing" + + authnv1 "k8s.io/api/authentication/v1" + + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// userInfoFixture builds a minimal authentication.UserInfo for tests. +func userInfoFixture(username, uid string) authnv1.UserInfo { + return authnv1.UserInfo{Username: username, UID: uid} +} + +func TestEnrichSummaryWithDisplayNames_ActorReplacement(t *testing.T) { + cases := []struct { + name string + summary string + actor v1alpha1.ActivityActor + links []v1alpha1.ActivityLink + wantSummary string + wantLinks int + wantMarker string + }{ + { + name: "no display name leaves summary untouched", + summary: "smith@datum.net created machine account ma-1", + actor: v1alpha1.ActivityActor{ + Type: ActorTypeUser, + Name: "smith@datum.net", + Email: "smith@datum.net", + UID: "uid-1", + }, + wantSummary: "smith@datum.net created machine account ma-1", + wantLinks: 0, + }, + { + name: "display name replaces email and synthetic link is appended", + summary: "smith@datum.net created machine account ma-1", + actor: v1alpha1.ActivityActor{ + Type: ActorTypeUser, + Name: "smith@datum.net", + Email: "smith@datum.net", + UID: "uid-1", + DisplayName: "Smith Nelson", + }, + wantSummary: "Smith Nelson created machine account ma-1", + wantLinks: 1, + wantMarker: "Smith Nelson", + }, + { + name: "existing actor link is upgraded in place", + summary: "smith@datum.net created machine account ma-1", + actor: v1alpha1.ActivityActor{ + Type: ActorTypeUser, + Name: "smith@datum.net", + Email: "smith@datum.net", + UID: "uid-1", + DisplayName: "Smith Nelson", + }, + links: []v1alpha1.ActivityLink{ + { + Marker: "smith@datum.net", + Resource: v1alpha1.ActivityResource{ + APIGroup: iamGroup, + Kind: "User", + Name: "smith", + }, + }, + }, + wantSummary: "Smith Nelson created machine account ma-1", + wantLinks: 1, + wantMarker: "Smith Nelson", + }, + { + name: "actor not in summary leaves summary alone and skips link", + summary: "system updated machine account ma-1", + actor: v1alpha1.ActivityActor{ + Type: ActorTypeUser, + Name: "smith@datum.net", + Email: "smith@datum.net", + UID: "uid-1", + DisplayName: "Smith Nelson", + }, + wantSummary: "system updated machine account ma-1", + wantLinks: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSummary, gotLinks := enrichSummaryWithDisplayNames( + context.Background(), tc.summary, tc.actor, tc.links, nil, + ) + if gotSummary != tc.wantSummary { + t.Fatalf("summary = %q, want %q", gotSummary, tc.wantSummary) + } + if len(gotLinks) != tc.wantLinks { + t.Fatalf("links = %d, want %d (links=%+v)", len(gotLinks), tc.wantLinks, gotLinks) + } + if tc.wantMarker != "" { + found := false + for _, l := range gotLinks { + if l.Marker == tc.wantMarker { + found = true + if l.DisplayName != tc.actor.DisplayName { + t.Errorf("link.DisplayName = %q, want %q", l.DisplayName, tc.actor.DisplayName) + } + if l.Email != tc.actor.Email { + t.Errorf("link.Email = %q, want %q", l.Email, tc.actor.Email) + } + } + } + if !found { + t.Errorf("no link with marker %q", tc.wantMarker) + } + } + }) + } +} + +func TestEnrichSummaryWithDisplayNames_UserLinkHydration(t *testing.T) { + resolver := &fakeResolver{ + names: map[string]UserInfo{ + "340583683847098197": { + Name: "340583683847098197", + GivenName: "Dean", + FamilyName: "Gaghan", + Email: "dgaghan@datum.net", + }, + }, + } + + summary := "Smith Nelson updated user 340583683847098197" + links := []v1alpha1.ActivityLink{ + { + Marker: "340583683847098197", + Resource: v1alpha1.ActivityResource{ + APIGroup: iamGroup, + Kind: "User", + Name: "340583683847098197", + }, + }, + } + actor := v1alpha1.ActivityActor{Type: ActorTypeUser, Name: "Smith Nelson", DisplayName: "Smith Nelson"} + + gotSummary, gotLinks := enrichSummaryWithDisplayNames(context.Background(), summary, actor, links, resolver) + + wantSummary := "Smith Nelson updated user Dean Gaghan" + if gotSummary != wantSummary { + t.Fatalf("summary = %q, want %q", gotSummary, wantSummary) + } + if len(gotLinks) != 1 { + t.Fatalf("links = %d, want 1", len(gotLinks)) + } + got := gotLinks[0] + if got.Marker != "Dean Gaghan" { + t.Errorf("Marker = %q, want %q", got.Marker, "Dean Gaghan") + } + if got.DisplayName != "Dean Gaghan" { + t.Errorf("DisplayName = %q, want %q", got.DisplayName, "Dean Gaghan") + } + if got.Email != "dgaghan@datum.net" { + t.Errorf("Email = %q", got.Email) + } +} + +func TestEnrichSummaryWithDisplayNames_NonUserLinkUntouched(t *testing.T) { + resolver := &fakeResolver{names: map[string]UserInfo{ + "some-resource": {GivenName: "Should not", FamilyName: "Be Used"}, + }} + + summary := "Smith Nelson created machine account ma-1" + links := []v1alpha1.ActivityLink{ + { + Marker: "ma-1", + Resource: v1alpha1.ActivityResource{ + APIGroup: iamGroup, + Kind: "MachineAccount", + Name: "ma-1", + }, + }, + } + actor := v1alpha1.ActivityActor{Type: ActorTypeUser, Name: "Smith Nelson"} + + gotSummary, gotLinks := enrichSummaryWithDisplayNames(context.Background(), summary, actor, links, resolver) + if gotSummary != summary { + t.Fatalf("summary changed: %q", gotSummary) + } + if gotLinks[0].DisplayName != "" || gotLinks[0].Email != "" { + t.Fatalf("MachineAccount link should not be hydrated: %+v", gotLinks[0]) + } + if resolver.nameCalls.Load() != 0 { + t.Fatalf("resolver should not be called for non-User links, got %d calls", resolver.nameCalls.Load()) + } +} + +func TestResolveActorWithResolver_PopulatesDisplayName(t *testing.T) { + resolver := &fakeResolver{ + emails: map[string]UserInfo{ + "smith@datum.net": {GivenName: "Smith", FamilyName: "Nelson", Email: "smith@datum.net"}, + }, + } + + actor := ResolveActorWithResolver(context.Background(), userInfoFixture("smith@datum.net", "uid-1"), resolver) + + if actor.DisplayName != "Smith Nelson" { + t.Fatalf("DisplayName = %q, want %q", actor.DisplayName, "Smith Nelson") + } + if actor.Email != "smith@datum.net" { + t.Errorf("Email = %q", actor.Email) + } +} + +func TestResolveActorWithResolver_NilResolverNoDisplayName(t *testing.T) { + actor := ResolveActorWithResolver(context.Background(), userInfoFixture("smith@datum.net", "uid-1"), nil) + if actor.DisplayName != "" { + t.Fatalf("DisplayName should be empty without resolver, got %q", actor.DisplayName) + } +} + +func TestResolveActorWithResolver_SystemActorSkipsLookup(t *testing.T) { + resolver := &fakeResolver{} + actor := ResolveActorWithResolver(context.Background(), userInfoFixture("system:admin", "uid-system"), resolver) + if actor.Type != ActorTypeSystem { + t.Fatalf("Type = %q, want %q", actor.Type, ActorTypeSystem) + } + if resolver.emailCalls.Load() != 0 { + t.Fatalf("resolver should not be called for system actors, got %d calls", resolver.emailCalls.Load()) + } +} diff --git a/internal/processor/evaluate.go b/internal/processor/evaluate.go index 28ae8f26..815faa94 100644 --- a/internal/processor/evaluate.go +++ b/internal/processor/evaluate.go @@ -28,10 +28,24 @@ type EvaluationResult struct { // EvaluateAuditRules evaluates audit rules against an audit log input. // Returns the generated Activity if a rule matches, or nil if no rule matched. // If resolveKind is provided, it will be used to resolve resource names to Kind in links. +// +// Use EvaluateAuditRulesWithResolver to also enrich activities with user +// display names; this convenience wrapper forwards a nil resolver. func EvaluateAuditRules( spec *v1alpha1.ActivityPolicySpec, audit *auditv1.Event, resolveKind KindResolver, +) (*EvaluationResult, error) { + return EvaluateAuditRulesWithResolver(spec, audit, resolveKind, nil) +} + +// EvaluateAuditRulesWithResolver is like EvaluateAuditRules but additionally +// enriches the resulting Activity with display names looked up via resolver. +func EvaluateAuditRulesWithResolver( + spec *v1alpha1.ActivityPolicySpec, + audit *auditv1.Event, + resolveKind KindResolver, + resolver UserResolver, ) (*EvaluationResult, error) { // Convert to map for CEL evaluation auditMap, err := toMap(audit) @@ -41,8 +55,9 @@ func EvaluateAuditRules( // Create activity builder builder := &ActivityBuilder{ - APIGroup: spec.Resource.APIGroup, - Kind: spec.Resource.Kind, + APIGroup: spec.Resource.APIGroup, + Kind: spec.Resource.Kind, + UserResolver: resolver, } // Try each audit rule in order @@ -83,10 +98,24 @@ func EvaluateAuditRules( // EvaluateEventRules evaluates event rules against a Kubernetes event input. // Returns the generated Activity if a rule matches, or nil if no rule matched. // If resolveKind is provided, it will be used to resolve resource names to Kind in links. +// +// Use EvaluateEventRulesWithResolver to also enrich activities with user +// display names; this convenience wrapper forwards a nil resolver. func EvaluateEventRules( spec *v1alpha1.ActivityPolicySpec, eventData interface{}, resolveKind KindResolver, +) (*EvaluationResult, error) { + return EvaluateEventRulesWithResolver(spec, eventData, resolveKind, nil) +} + +// EvaluateEventRulesWithResolver is like EvaluateEventRules but additionally +// enriches User-typed link targets with display names looked up via resolver. +func EvaluateEventRulesWithResolver( + spec *v1alpha1.ActivityPolicySpec, + eventData interface{}, + resolveKind KindResolver, + resolver UserResolver, ) (*EvaluationResult, error) { // Convert event data to map if needed eventMap, err := toMap(eventData) @@ -96,8 +125,9 @@ func EvaluateEventRules( // Create activity builder builder := &ActivityBuilder{ - APIGroup: spec.Resource.APIGroup, - Kind: spec.Resource.Kind, + APIGroup: spec.Resource.APIGroup, + Kind: spec.Resource.Kind, + UserResolver: resolver, } // Try each event rule in order diff --git a/internal/processor/userlookup.go b/internal/processor/userlookup.go new file mode 100644 index 00000000..a4f3242a --- /dev/null +++ b/internal/processor/userlookup.go @@ -0,0 +1,177 @@ +package processor + +import ( + "context" + "sync" + "time" +) + +// UserInfo is the subset of iam User fields needed to enrich activities with +// human-readable display names. Resolvers MUST populate at least one of +// GivenName/FamilyName/Email when returning ok=true; a fully-empty result +// should return ok=false so callers can treat it as a miss. +type UserInfo struct { + // Name is the User CR's metadata.name. Used to construct a + // resource reference in the activity Link. + Name string + GivenName string + FamilyName string + Email string + UID string +} + +// DisplayName returns a human-readable name from given/family, falling back +// to whichever component is populated, then to the email. Returns "" when +// nothing is available. +func (u UserInfo) DisplayName() string { + switch { + case u.GivenName != "" && u.FamilyName != "": + return u.GivenName + " " + u.FamilyName + case u.GivenName != "": + return u.GivenName + case u.FamilyName != "": + return u.FamilyName + default: + return u.Email + } +} + +// UserResolver looks up User records to enrich activities with display names. +// +// Implementations MUST be safe for concurrent use. When the resolver cannot +// find a matching user (or hits a transient error), it SHOULD return +// (UserInfo{}, false, nil). A non-nil error indicates a non-cacheable failure +// the caller should log; the activity is still emitted without enrichment. +type UserResolver interface { + // LookupByEmail resolves a user by their email address (typically the + // audit username for OIDC-authenticated requests). + LookupByEmail(ctx context.Context, email string) (UserInfo, bool, error) + + // LookupByName resolves a user by the User CR's metadata.name. Used to + // hydrate user-typed link targets, where audit.objectRef.name is the User + // resource name. + LookupByName(ctx context.Context, name string) (UserInfo, bool, error) +} + +// NoopUserResolver is a UserResolver that always returns a miss. Used as the +// default when no real resolver is wired (e.g., in unit tests). +type NoopUserResolver struct{} + +func (NoopUserResolver) LookupByEmail(context.Context, string) (UserInfo, bool, error) { + return UserInfo{}, false, nil +} + +func (NoopUserResolver) LookupByName(context.Context, string) (UserInfo, bool, error) { + return UserInfo{}, false, nil +} + +// CachedUserResolver wraps an underlying resolver with a TTL cache and +// per-key single-flight to collapse concurrent lookups for the same user. +// Negative results are cached briefly so a missing user doesn't translate to +// a request storm. +type CachedUserResolver struct { + inner UserResolver + posTTL time.Duration + negTTL time.Duration + now func() time.Time + mu sync.Mutex + emailCache map[string]cachedUser + nameCache map[string]cachedUser + emailFlight map[string]*flight + nameFlight map[string]*flight +} + +type cachedUser struct { + info UserInfo + ok bool + expires time.Time +} + +type flight struct { + done chan struct{} + info UserInfo + ok bool + err error +} + +// NewCachedUserResolver wraps inner with a TTL cache. posTTL applies to hits; +// negTTL to misses (kept short so newly-created users become resolvable +// quickly). When inner is nil, lookups always miss. +func NewCachedUserResolver(inner UserResolver, posTTL, negTTL time.Duration) *CachedUserResolver { + if inner == nil { + inner = NoopUserResolver{} + } + if posTTL <= 0 { + posTTL = 5 * time.Minute + } + if negTTL <= 0 { + negTTL = 30 * time.Second + } + return &CachedUserResolver{ + inner: inner, + posTTL: posTTL, + negTTL: negTTL, + now: time.Now, + emailCache: make(map[string]cachedUser), + nameCache: make(map[string]cachedUser), + emailFlight: make(map[string]*flight), + nameFlight: make(map[string]*flight), + } +} + +func (c *CachedUserResolver) LookupByEmail(ctx context.Context, email string) (UserInfo, bool, error) { + if email == "" { + return UserInfo{}, false, nil + } + return c.lookup(ctx, email, c.emailCache, c.emailFlight, c.inner.LookupByEmail) +} + +func (c *CachedUserResolver) LookupByName(ctx context.Context, name string) (UserInfo, bool, error) { + if name == "" { + return UserInfo{}, false, nil + } + return c.lookup(ctx, name, c.nameCache, c.nameFlight, c.inner.LookupByName) +} + +type lookupFn func(context.Context, string) (UserInfo, bool, error) + +func (c *CachedUserResolver) lookup( + ctx context.Context, + key string, + cache map[string]cachedUser, + flights map[string]*flight, + fetch lookupFn, +) (UserInfo, bool, error) { + c.mu.Lock() + if entry, found := cache[key]; found && c.now().Before(entry.expires) { + c.mu.Unlock() + return entry.info, entry.ok, nil + } + + if f, inflight := flights[key]; inflight { + c.mu.Unlock() + <-f.done + return f.info, f.ok, f.err + } + + f := &flight{done: make(chan struct{})} + flights[key] = f + c.mu.Unlock() + + info, ok, err := fetch(ctx, key) + + c.mu.Lock() + delete(flights, key) + if err == nil { + ttl := c.negTTL + if ok { + ttl = c.posTTL + } + cache[key] = cachedUser{info: info, ok: ok, expires: c.now().Add(ttl)} + } + c.mu.Unlock() + + f.info, f.ok, f.err = info, ok, err + close(f.done) + return info, ok, err +} diff --git a/internal/processor/userlookup_test.go b/internal/processor/userlookup_test.go new file mode 100644 index 00000000..d058e4dc --- /dev/null +++ b/internal/processor/userlookup_test.go @@ -0,0 +1,185 @@ +package processor + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeResolver is a UserResolver whose responses are configured per-test. +type fakeResolver struct { + emailCalls atomic.Int64 + nameCalls atomic.Int64 + emails map[string]UserInfo + names map[string]UserInfo + wait chan struct{} + emailErr error + nameErr error +} + +func (f *fakeResolver) LookupByEmail(ctx context.Context, email string) (UserInfo, bool, error) { + f.emailCalls.Add(1) + if f.wait != nil { + <-f.wait + } + if f.emailErr != nil { + return UserInfo{}, false, f.emailErr + } + info, ok := f.emails[email] + return info, ok, nil +} + +func (f *fakeResolver) LookupByName(ctx context.Context, name string) (UserInfo, bool, error) { + f.nameCalls.Add(1) + if f.wait != nil { + <-f.wait + } + if f.nameErr != nil { + return UserInfo{}, false, f.nameErr + } + info, ok := f.names[name] + return info, ok, nil +} + +func TestUserInfo_DisplayName(t *testing.T) { + cases := []struct { + name string + in UserInfo + want string + }{ + {"both", UserInfo{GivenName: "Smith", FamilyName: "Nelson"}, "Smith Nelson"}, + {"given only", UserInfo{GivenName: "Smith"}, "Smith"}, + {"family only", UserInfo{FamilyName: "Nelson"}, "Nelson"}, + {"email fallback", UserInfo{Email: "smith@datum.net"}, "smith@datum.net"}, + {"empty", UserInfo{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.in.DisplayName(); got != tc.want { + t.Fatalf("DisplayName() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCachedUserResolver_PositiveHitCached(t *testing.T) { + inner := &fakeResolver{emails: map[string]UserInfo{ + "smith@datum.net": {GivenName: "Smith", FamilyName: "Nelson", Email: "smith@datum.net"}, + }} + c := NewCachedUserResolver(inner, time.Minute, time.Minute) + + for i := 0; i < 5; i++ { + info, ok, err := c.LookupByEmail(context.Background(), "smith@datum.net") + if err != nil || !ok || info.DisplayName() != "Smith Nelson" { + t.Fatalf("iteration %d: got info=%+v ok=%v err=%v", i, info, ok, err) + } + } + if got := inner.emailCalls.Load(); got != 1 { + t.Fatalf("inner LookupByEmail called %d times, want 1", got) + } +} + +func TestCachedUserResolver_NegativeHitCached(t *testing.T) { + inner := &fakeResolver{emails: map[string]UserInfo{}} + c := NewCachedUserResolver(inner, time.Minute, time.Minute) + + for i := 0; i < 3; i++ { + _, ok, err := c.LookupByEmail(context.Background(), "missing@datum.net") + if err != nil || ok { + t.Fatalf("iteration %d: ok=%v err=%v", i, ok, err) + } + } + if got := inner.emailCalls.Load(); got != 1 { + t.Fatalf("inner LookupByEmail called %d times, want 1 (negative cache miss)", got) + } +} + +func TestCachedUserResolver_TTLExpiry(t *testing.T) { + inner := &fakeResolver{emails: map[string]UserInfo{ + "smith@datum.net": {GivenName: "Smith", Email: "smith@datum.net"}, + }} + c := NewCachedUserResolver(inner, 50*time.Millisecond, 50*time.Millisecond) + + if _, ok, _ := c.LookupByEmail(context.Background(), "smith@datum.net"); !ok { + t.Fatal("first lookup must hit") + } + now := time.Now() + c.now = func() time.Time { return now.Add(time.Hour) } + if _, ok, _ := c.LookupByEmail(context.Background(), "smith@datum.net"); !ok { + t.Fatal("post-TTL lookup must still resolve via inner") + } + if got := inner.emailCalls.Load(); got != 2 { + t.Fatalf("inner LookupByEmail called %d times, want 2", got) + } +} + +func TestCachedUserResolver_SingleFlight(t *testing.T) { + inner := &fakeResolver{ + emails: map[string]UserInfo{"smith@datum.net": {GivenName: "Smith", Email: "smith@datum.net"}}, + wait: make(chan struct{}), + } + c := NewCachedUserResolver(inner, time.Minute, time.Minute) + + const concurrent = 20 + var wg sync.WaitGroup + for i := 0; i < concurrent; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _, _ = c.LookupByEmail(context.Background(), "smith@datum.net") + }() + } + + // Give all goroutines a moment to enqueue behind the in-flight lookup. + time.Sleep(20 * time.Millisecond) + close(inner.wait) + wg.Wait() + + if got := inner.emailCalls.Load(); got != 1 { + t.Fatalf("inner LookupByEmail called %d times, want 1 (single-flight)", got) + } +} + +func TestCachedUserResolver_ErrorNotCached(t *testing.T) { + sentinel := errors.New("boom") + inner := &fakeResolver{emailErr: sentinel} + c := NewCachedUserResolver(inner, time.Minute, time.Minute) + + for i := 0; i < 3; i++ { + _, _, err := c.LookupByEmail(context.Background(), "smith@datum.net") + if !errors.Is(err, sentinel) { + t.Fatalf("iteration %d: err=%v, want %v", i, err, sentinel) + } + } + if got := inner.emailCalls.Load(); got != 3 { + t.Fatalf("inner LookupByEmail called %d times, want 3 (errors are not cached)", got) + } +} + +func TestCachedUserResolver_EmptyKeysShortCircuit(t *testing.T) { + inner := &fakeResolver{} + c := NewCachedUserResolver(inner, time.Minute, time.Minute) + + if _, ok, err := c.LookupByEmail(context.Background(), ""); ok || err != nil { + t.Fatalf("empty email should miss without err; ok=%v err=%v", ok, err) + } + if _, ok, err := c.LookupByName(context.Background(), ""); ok || err != nil { + t.Fatalf("empty name should miss without err; ok=%v err=%v", ok, err) + } + if got := inner.emailCalls.Load() + inner.nameCalls.Load(); got != 0 { + t.Fatalf("inner called %d times, want 0", got) + } +} + +func TestNoopUserResolver(t *testing.T) { + r := NoopUserResolver{} + if _, ok, err := r.LookupByEmail(context.Background(), "x"); ok || err != nil { + t.Fatalf("noop email lookup ok=%v err=%v", ok, err) + } + if _, ok, err := r.LookupByName(context.Background(), "x"); ok || err != nil { + t.Fatalf("noop name lookup ok=%v err=%v", ok, err) + } +} diff --git a/pkg/apis/activity/v1alpha1/types_activity.go b/pkg/apis/activity/v1alpha1/types_activity.go index df15deed..cca25898 100644 --- a/pkg/apis/activity/v1alpha1/types_activity.go +++ b/pkg/apis/activity/v1alpha1/types_activity.go @@ -128,6 +128,17 @@ type ActivityActor struct { // // +optional Email string `json:"email,omitempty"` + + // DisplayName is the actor's human-readable name (e.g., "Smith Nelson"). + // For user actors, populated from the iam User's spec.givenName and + // spec.familyName when available. Empty if no User record is found or the + // actor is not a human user. + // + // UIs SHOULD prefer DisplayName for visible text and use Name/Email/UID + // only when DisplayName is empty. + // + // +optional + DisplayName string `json:"displayName,omitempty"` } // ActivityResource identifies the Kubernetes resource affected by an activity. @@ -179,6 +190,21 @@ type ActivityLink struct { // // +required Resource ActivityResource `json:"resource"` + + // DisplayName is the human-readable name for the linked entity, when one + // is available. Populated server-side for User-typed links from the iam + // User's givenName + familyName so the UI can render names instead of + // raw UIDs in the summary. + // + // +optional + DisplayName string `json:"displayName,omitempty"` + + // Email is the email address for the linked entity, when one is + // available. Populated server-side for User-typed links so the UI can + // surface the email on hover. + // + // +optional + Email string `json:"email,omitempty"` } // ActivityTenant identifies the scope for multi-tenant isolation. diff --git a/ui/example/app/routes/resource-history.tsx b/ui/example/app/routes/resource-history.tsx index ed45c213..bfea3251 100644 --- a/ui/example/app/routes/resource-history.tsx +++ b/ui/example/app/routes/resource-history.tsx @@ -33,7 +33,9 @@ import { EventDetailModal } from "~/components/EventDetailModal"; export default function ResourceHistoryPage() { const [searchParams, setSearchParams] = useSearchParams(); const [client, setClient] = useState(null); - const [selectedActivity, setSelectedActivity] = useState(null); + const [selectedActivity, setSelectedActivity] = useState( + null, + ); // Read initial values from URL search params const initialApiGroup = searchParams.get("apiGroup") || ""; @@ -66,7 +68,9 @@ export default function ResourceHistoryPage() { }, [initialApiGroup, initialKind, initialNamespace, initialName, initialUid]); // Submitted filter - initialized from URL params - const [submittedFilter, setSubmittedFilter] = useState(filterFromParams); + const [submittedFilter, setSubmittedFilter] = useState( + filterFromParams, + ); // Sync submitted filter when URL params change (e.g., browser back/forward) useEffect(() => { @@ -77,7 +81,14 @@ export default function ResourceHistoryPage() { setNamespace(initialNamespace); setName(initialName); setUid(initialUid); - }, [filterFromParams, initialApiGroup, initialKind, initialNamespace, initialName, initialUid]); + }, [ + filterFromParams, + initialApiGroup, + initialKind, + initialNamespace, + initialName, + initialUid, + ]); useEffect(() => { // Check if in production environment @@ -95,7 +106,7 @@ export default function ResourceHistoryPage() { new ActivityApiClient({ baseUrl: apiUrl || "", token, - }) + }), ); } }, []); @@ -119,76 +130,82 @@ export default function ResourceHistoryPage() { } = useFacets( client!, { start: "now-30d" }, - currentFilters // Pass current selections to filter facet results + currentFilters, // Pass current selections to filter facet results ); // Convert facets to combobox options - const apiGroupOptions: ComboboxOption[] = useMemo(() => - apiGroups - .filter((f) => f.value) - .map((f) => ({ - value: f.value, - label: f.value, - count: f.count, - })), - [apiGroups] + const apiGroupOptions: ComboboxOption[] = useMemo( + () => + apiGroups + .filter((f) => f.value) + .map((f) => ({ + value: f.value, + label: f.value, + count: f.count, + })), + [apiGroups], ); - const kindOptions: ComboboxOption[] = useMemo(() => - resourceKinds - .filter((f) => f.value) - .map((f) => ({ - value: f.value, - label: f.value, - count: f.count, - })), - [resourceKinds] + const kindOptions: ComboboxOption[] = useMemo( + () => + resourceKinds + .filter((f) => f.value) + .map((f) => ({ + value: f.value, + label: f.value, + count: f.count, + })), + [resourceKinds], ); - const namespaceOptions: ComboboxOption[] = useMemo(() => - resourceNamespaces - .filter((f) => f.value) - .map((f) => ({ - value: f.value, - label: f.value, - count: f.count, - })), - [resourceNamespaces] + const namespaceOptions: ComboboxOption[] = useMemo( + () => + resourceNamespaces + .filter((f) => f.value) + .map((f) => ({ + value: f.value, + label: f.value, + count: f.count, + })), + [resourceNamespaces], ); - const handleSubmit = useCallback((e: React.FormEvent) => { - e.preventDefault(); - const filter: ResourceFilter = {}; - const params = new URLSearchParams(); + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const filter: ResourceFilter = {}; + const params = new URLSearchParams(); - if (uid.trim()) { - filter.uid = uid.trim(); - params.set("uid", uid.trim()); - } else { - if (apiGroup) { - filter.apiGroup = apiGroup; - params.set("apiGroup", apiGroup); - } - if (kind) { - filter.kind = kind; - params.set("kind", kind); - } - if (namespace) { - filter.namespace = namespace; - params.set("namespace", namespace); + if (uid.trim()) { + filter.uid = uid.trim(); + params.set("uid", uid.trim()); + } else { + if (apiGroup) { + filter.apiGroup = apiGroup; + params.set("apiGroup", apiGroup); + } + if (kind) { + filter.kind = kind; + params.set("kind", kind); + } + if (namespace) { + filter.namespace = namespace; + params.set("namespace", namespace); + } + if (name.trim()) { + filter.name = name.trim(); + params.set("name", name.trim()); + } } - if (name.trim()) { - filter.name = name.trim(); - params.set("name", name.trim()); - } - } - // Only submit if we have at least one filter - if (Object.keys(filter).length > 0) { - setSubmittedFilter(filter); - setSearchParams(params, { replace: false }); - } - }, [uid, apiGroup, kind, namespace, name, setSearchParams]); + // Only submit if we have at least one filter + if (Object.keys(filter).length > 0) { + setSubmittedFilter(filter); + setSearchParams(params, { replace: false }); + } + }, + [uid, apiGroup, kind, namespace, name, setSearchParams], + ); const handleActivityClick = useCallback((activity: Activity) => { setSelectedActivity(activity); @@ -320,7 +337,8 @@ export default function ResourceHistoryPage() { disabled={isAttributeMode} />

- UID provides exact match. When specified, other filters are ignored. + UID provides exact match. When specified, other filters are + ignored.

@@ -336,18 +354,22 @@ export default function ResourceHistoryPage() {
  • - Dropdowns filter automatically based on other selections + Dropdowns filter automatically based on other + selections
  • - Name supports partial matching (e.g., "api" matches "api-gateway") + Name supports partial matching (e.g., "api" + matches "api-gateway")
  • - Combine filters to narrow down results (e.g., Kind + Namespace) + Combine filters to narrow down results (e.g., Kind + + Namespace)
  • Find a resource's UID with:{" "} - kubectl get <kind> <name> -o jsonpath='{"{.metadata.uid}"}' + kubectl get <kind> <name> -o jsonpath=' + {"{.metadata.uid}"}'
@@ -369,8 +391,10 @@ export default function ResourceHistoryPage() { {[ submittedFilter.kind, submittedFilter.name, - submittedFilter.namespace && `in ${submittedFilter.namespace}`, - submittedFilter.apiGroup && `(${submittedFilter.apiGroup})`, + submittedFilter.namespace && + `in ${submittedFilter.namespace}`, + submittedFilter.apiGroup && + `(${submittedFilter.apiGroup})`, ] .filter(Boolean) .join(" ")} diff --git a/ui/package.json b/ui/package.json index 3a7533d4..6d9d70a7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -42,6 +42,7 @@ "directory": "ui" }, "peerDependencies": { + "@datum-cloud/datum-ui": "^0.8.0", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-checkbox": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0", @@ -56,17 +57,16 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@datum-cloud/datum-ui": "^0.8.0", "@monaco-editor/react": "^4.6.0", "@playwright/test": "^1.48.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", - "@tailwindcss/postcss": "^4.2.1", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", - "autoprefixer": "^10.4.27", "eslint": "^8.56.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", @@ -75,18 +75,15 @@ "react-dom": "^19.0.0", "rollup": "^4.9.1", "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-postcss": "^4.0.2", - "tailwindcss": "^4.2.1", "tslib": "^2.6.2", "typescript": "^5.3.3" }, "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^3.0.6", + "date-fns": "^4.1.0", "js-yaml": "^4.1.1", "lucide-react": "^0.577.0", - "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" + "tailwind-merge": "^3.4.0" } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index a93910e1..1dd16db4 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: ^1.0.0 version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) date-fns: - specifier: ^3.0.6 - version: 3.6.0 + specifier: ^4.1.0 + version: 4.1.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -50,10 +50,10 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.5.0 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@4.2.2) devDependencies: + '@datum-cloud/datum-ui': + specifier: ^0.8.0 + version: 0.8.1(@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(date-fns@4.1.0)(js-yaml@4.1.1)(lucide-react@0.577.0(react@19.2.4))(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@monaco-editor/react': specifier: ^4.6.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -69,9 +69,6 @@ importers: '@rollup/plugin-typescript': specifier: ^11.1.6 version: 11.1.6(rollup@4.60.0)(tslib@2.8.1)(typescript@5.9.3) - '@tailwindcss/postcss': - specifier: ^4.2.1 - version: 4.2.2 '@types/react': specifier: ^18.0.0 version: 18.3.28 @@ -84,9 +81,6 @@ importers: '@typescript-eslint/parser': specifier: ^6.15.0 version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) - autoprefixer: - specifier: ^10.4.27 - version: 10.4.27(postcss@8.5.8) eslint: specifier: ^8.56.0 version: 8.57.1 @@ -111,12 +105,6 @@ importers: rollup-plugin-peer-deps-external: specifier: ^2.2.4 version: 2.2.4(rollup@4.60.0) - rollup-plugin-postcss: - specifier: ^4.0.2 - version: 4.0.2(postcss@8.5.8) - tailwindcss: - specifier: ^4.2.1 - version: 4.2.2 tslib: specifier: ^2.6.2 version: 2.8.1 @@ -218,6 +206,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -357,6 +360,164 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@datum-cloud/datum-ui@0.8.1': + resolution: {integrity: sha512-3wHvcTqFWNexixiEoaAUxQfDSmqGuw2eLfbA3xUugzywVW3gz8R4BNAm22NBAO8JhcX4xc3KlfghfFuQmQlVaA==} + peerDependencies: + '@conform-to/react': '>=1' + '@conform-to/zod': '>=1' + '@dnd-kit/core': '>=6' + '@dnd-kit/sortable': '>=8' + '@hookform/resolvers': '>=5.2.2' + '@monaco-editor/react': ^4.7.0 + '@stepperize/react': '>=4' + '@tanstack/react-table': '>=8' + '@tanstack/react-virtual': '>=3' + '@tiptap/extension-character-count': '>=3' + '@tiptap/extension-link': '>=3' + '@tiptap/extension-placeholder': '>=3' + '@tiptap/extension-underline': '>=3' + '@tiptap/react': '>=3' + '@tiptap/starter-kit': '>=3' + date-fns: '>=4.1.0' + date-fns-tz: '>=3' + js-yaml: ^4.1.0 + leaflet: '>=1.9' + leaflet-draw: '>=1' + leaflet.fullscreen: '>=5' + leaflet.markercluster: '>=1.5' + lucide-react: '>=0.400' + monaco-editor: '>=0.44.0' + motion: '>=11' + nprogress: '>=0.2' + nuqs: '>=2' + react: '>=19' + react-day-picker: '>=9' + react-dom: '>=19' + react-dropzone: '>=14' + react-hook-form: '>=7.55' + react-leaflet: '>=5' + react-leaflet-markercluster: '>=5.0.0-rc.0' + react-number-format: '>=5' + recharts: '>=2' + sonner: '>=2' + zod: '>=4' + peerDependenciesMeta: + '@conform-to/react': + optional: true + '@conform-to/zod': + optional: true + '@dnd-kit/core': + optional: true + '@dnd-kit/sortable': + optional: true + '@hookform/resolvers': + optional: true + '@monaco-editor/react': + optional: true + '@stepperize/react': + optional: true + '@tanstack/react-table': + optional: true + '@tanstack/react-virtual': + optional: true + '@tiptap/extension-character-count': + optional: true + '@tiptap/extension-link': + optional: true + '@tiptap/extension-placeholder': + optional: true + '@tiptap/extension-underline': + optional: true + '@tiptap/react': + optional: true + '@tiptap/starter-kit': + optional: true + date-fns: + optional: true + date-fns-tz: + optional: true + js-yaml: + optional: true + leaflet: + optional: true + leaflet-draw: + optional: true + leaflet.fullscreen: + optional: true + leaflet.markercluster: + optional: true + monaco-editor: + optional: true + motion: + optional: true + nprogress: + optional: true + nuqs: + optional: true + react-day-picker: + optional: true + react-dropzone: + optional: true + react-hook-form: + optional: true + react-leaflet: + optional: true + react-leaflet-markercluster: + optional: true + react-number-format: + optional: true + recharts: + optional: true + sonner: + optional: true + zod: + optional: true + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} @@ -648,6 +809,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -768,6 +938,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -781,6 +964,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -812,6 +1008,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -847,6 +1052,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -869,6 +1087,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -878,6 +1109,32 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -956,6 +1213,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -1013,6 +1283,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.13': resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: @@ -1075,6 +1358,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1486,10 +1778,6 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} - '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -1755,6 +2043,9 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1766,9 +2057,6 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1821,9 +2109,6 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-api@3.0.0: - resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001774: resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} @@ -1893,16 +2178,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colord@2.9.3: - resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -1917,9 +2195,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-with-sourcemaps@1.1.0: - resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} - confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1955,18 +2230,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-declaration-sorter@6.4.1: - resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} - engines: {node: ^10 || ^12 || >=14} - peerDependencies: - postcss: ^8.0.9 - - css-select@4.3.0: - resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} - - css-tree@1.1.3: - resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} - engines: {node: '>=8.0.0'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} @@ -1977,28 +2243,6 @@ packages: engines: {node: '>=4'} hasBin: true - cssnano-preset-default@5.2.14: - resolution: {integrity: sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - cssnano-utils@3.1.0: - resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - cssnano@5.1.15: - resolution: {integrity: sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - csso@4.2.0: - resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} - engines: {node: '>=8.0.0'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2006,6 +2250,10 @@ packages: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} engines: {node: '>= 6'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2018,8 +2266,8 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - date-fns@3.6.0: - resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} @@ -2038,6 +2286,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2105,21 +2356,11 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} - dom-serializer@1.4.1: - resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@4.3.1: - resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} - engines: {node: '>= 4'} - dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} - domutils@2.8.0: - resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} @@ -2158,8 +2399,9 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -2290,9 +2532,6 @@ packages: estree-util-visit@1.2.1: resolution: {integrity: sha512-xbgqcrkIVbIG+lI/gzbvd9SGTJL4zqJKBFttUl5pP27KhAjtMKbX/mQXJ7qgyXpMgVy/zvpm0xoQQaGL8OloOw==} - estree-walker@0.6.1: - resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -2315,9 +2554,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2563,6 +2799,10 @@ packages: resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2575,9 +2815,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - icss-replace-symbols@1.1.0: - resolution: {integrity: sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==} - icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -2591,18 +2828,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - import-cwd@3.0.0: - resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} - engines: {node: '>=8'} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-from@3.0.0: - resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} - engines: {node: '>=8'} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2747,6 +2976,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -2810,6 +3042,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-dompurify@3.11.0: + resolution: {integrity: sha512-il0sNhLnfawc6vKoMqnZX1JXzEzw0pP3DZWg7mM7VJNJ8cq6DCxeAjEcfjTazyBF8dkUn+rBsBPS5NQs4ZaI3g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2831,6 +3067,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -2946,10 +3191,6 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2972,15 +3213,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.uniq@4.5.0: - resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -2998,6 +3233,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3059,8 +3298,8 @@ packages: mdast-util-to-string@3.2.0: resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==} - mdn-data@2.0.14: - resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} media-query-parser@2.0.2: resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} @@ -3303,10 +3542,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-url@6.1.0: - resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} - engines: {node: '>=10'} - npm-install-checks@6.3.0: resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3327,9 +3562,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3392,10 +3624,6 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - p-finally@1.0.0: - resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} - engines: {node: '>=4'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3408,14 +3636,6 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} - p-queue@6.6.2: - resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} - engines: {node: '>=8'} - - p-timeout@3.2.0: - resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} - engines: {node: '>=8'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3433,6 +3653,9 @@ packages: resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3491,10 +3714,6 @@ packages: engines: {node: '>=0.10'} hasBin: true - pify@5.0.0: - resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} - engines: {node: '>=10'} - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -3515,59 +3734,12 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-calc@8.2.4: - resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==} - peerDependencies: - postcss: ^8.2.2 - - postcss-colormin@5.3.1: - resolution: {integrity: sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-convert-values@5.1.3: - resolution: {integrity: sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-discard-comments@5.1.2: - resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - postcss-discard-duplicates@5.1.0: resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} engines: {node: ^10 || ^12 || >=14.0} peerDependencies: postcss: ^8.2.15 - postcss-discard-empty@5.1.1: - resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-discard-overridden@5.1.0: - resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-load-config@3.1.4: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - postcss-load-config@4.0.2: resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} @@ -3580,42 +3752,6 @@ packages: ts-node: optional: true - postcss-merge-longhand@5.1.7: - resolution: {integrity: sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-merge-rules@5.1.4: - resolution: {integrity: sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-font-values@5.1.0: - resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-gradients@5.1.1: - resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-params@5.1.4: - resolution: {integrity: sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-selectors@5.2.1: - resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - postcss-modules-extract-imports@3.1.0: resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} engines: {node: ^10 || ^12 || >= 14} @@ -3640,108 +3776,15 @@ packages: peerDependencies: postcss: ^8.1.0 - postcss-modules@4.3.1: - resolution: {integrity: sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==} - peerDependencies: - postcss: ^8.0.0 - postcss-modules@6.0.1: resolution: {integrity: sha512-zyo2sAkVvuZFFy0gc2+4O+xar5dYlaVy/ebO24KT0ftk/iJevSNyPyQellsBLlnccwh7f6V6Y4GvuKRYToNgpQ==} peerDependencies: postcss: ^8.0.0 - postcss-normalize-charset@5.1.0: - resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-display-values@5.1.0: - resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-positions@5.1.1: - resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-repeat-style@5.1.1: - resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-string@5.1.0: - resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-timing-functions@5.1.0: - resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-unicode@5.1.1: - resolution: {integrity: sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-url@5.1.0: - resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-whitespace@5.1.1: - resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-ordered-values@5.1.3: - resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-reduce-initial@5.1.2: - resolution: {integrity: sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-reduce-transforms@5.1.0: - resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss-svgo@5.1.0: - resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-unique-selectors@5.1.1: - resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -3781,10 +3824,6 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} - promise.series@0.2.0: - resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} - engines: {node: '>=0.12'} - prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3929,6 +3968,10 @@ packages: remark-rehype@10.1.0: resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} @@ -3936,10 +3979,6 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -3976,15 +4015,6 @@ packages: peerDependencies: rollup: '*' - rollup-plugin-postcss@4.0.2: - resolution: {integrity: sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==} - engines: {node: '>=10'} - peerDependencies: - postcss: 8.x - - rollup-pluginutils@2.8.2: - resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} - rollup@4.60.0: resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4007,9 +4037,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-identifier@0.4.2: - resolution: {integrity: sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==} - safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -4021,6 +4048,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4131,10 +4162,6 @@ packages: resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - stable@0.1.8: - resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} - deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' - state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -4211,18 +4238,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - style-inject@0.3.0: - resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} - style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} - stylehacks@5.1.1: - resolution: {integrity: sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4231,10 +4249,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svgo@2.8.0: - resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} - engines: {node: '>=10.13.0'} - hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -4269,6 +4285,13 @@ packages: through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + tldts-core@7.0.29: + resolution: {integrity: sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==} + + tldts@7.0.29: + resolution: {integrity: sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4280,6 +4303,14 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4302,6 +4333,9 @@ packages: turbo-stream@2.4.1: resolution: {integrity: sha512-v8kOJXpG3WoTN/+at8vK7erSzo6nW6CIaeOvNOkHQVDajfz1ZVeSxCbc6tOH4hrGZW7VUCV0TOXd8CPzYnYkrw==} + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4349,6 +4383,10 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unified@10.1.2: resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} @@ -4421,6 +4459,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4502,6 +4545,10 @@ packages: terser: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -4512,6 +4559,18 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4565,6 +4624,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4575,10 +4641,6 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -4595,6 +4657,26 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4788,6 +4870,78 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@datum-cloud/datum-ui@0.8.1(@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(date-fns@4.1.0)(js-yaml@4.1.1)(lucide-react@0.577.0(react@19.2.4))(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + '@radix-ui/react-avatar': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + isomorphic-dompurify: 3.11.0 + lucide-react: 0.577.0(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tailwind-merge: 3.5.0 + tw-animate-css: 1.4.0 + optionalDependencies: + '@monaco-editor/react': 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + date-fns: 4.1.0 + js-yaml: 4.1.1 + monaco-editor: 0.55.1 + transitivePeerDependencies: + - '@noble/hashes' + - '@types/react' + - '@types/react-dom' + - canvas + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@emotion/hash@0.9.2': {} '@esbuild/aix-ppc64@0.21.5': @@ -4948,6 +5102,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -5112,6 +5268,19 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5128,6 +5297,22 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) @@ -5152,6 +5337,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-context@1.1.3(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5193,6 +5384,21 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@19.2.4)': dependencies: react: 19.2.4 @@ -5210,6 +5416,23 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) @@ -5217,6 +5440,41 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5296,6 +5554,24 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5358,12 +5634,27 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@19.2.4)': + '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: + '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@19.2.4) react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) optionalDependencies: '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -5429,6 +5720,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@19.2.4)': dependencies: react: 19.2.4 @@ -5821,8 +6119,6 @@ snapshots: tailwindcss: 4.2.2 vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.32.0) - '@trysound/sax@0.2.0': {} - '@types/acorn@4.0.6': dependencies: '@types/estree': 1.0.8 @@ -6164,6 +6460,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bl@4.1.0: @@ -6189,8 +6489,6 @@ snapshots: transitivePeerDependencies: - supports-color - boolbase@1.0.0: {} - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6261,13 +6559,6 @@ snapshots: callsites@3.1.0: {} - caniuse-api@3.0.0: - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 - lodash.memoize: 4.1.2 - lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001774: {} ccount@2.0.1: {} @@ -6335,12 +6626,8 @@ snapshots: color-name@1.1.4: {} - colord@2.9.3: {} - comma-separated-tokens@2.0.3: {} - commander@7.2.0: {} - commondir@1.0.1: {} compressible@2.0.18: @@ -6361,10 +6648,6 @@ snapshots: concat-map@0.0.1: {} - concat-with-sourcemaps@1.1.0: - dependencies: - source-map: 0.6.1 - confbox@0.1.8: {} confbox@0.2.4: {} @@ -6391,79 +6674,26 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@6.4.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - css-select@4.3.0: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 4.3.1 - domutils: 2.8.0 - nth-check: 2.1.1 - - css-tree@1.1.3: + css-tree@3.2.1: dependencies: - mdn-data: 2.0.14 - source-map: 0.6.1 + mdn-data: 2.27.1 + source-map-js: 1.2.1 css-what@6.2.2: {} cssesc@3.0.0: {} - cssnano-preset-default@5.2.14(postcss@8.5.8): - dependencies: - css-declaration-sorter: 6.4.1(postcss@8.5.8) - cssnano-utils: 3.1.0(postcss@8.5.8) - postcss: 8.5.8 - postcss-calc: 8.2.4(postcss@8.5.8) - postcss-colormin: 5.3.1(postcss@8.5.8) - postcss-convert-values: 5.1.3(postcss@8.5.8) - postcss-discard-comments: 5.1.2(postcss@8.5.8) - postcss-discard-duplicates: 5.1.0(postcss@8.5.8) - postcss-discard-empty: 5.1.1(postcss@8.5.8) - postcss-discard-overridden: 5.1.0(postcss@8.5.8) - postcss-merge-longhand: 5.1.7(postcss@8.5.8) - postcss-merge-rules: 5.1.4(postcss@8.5.8) - postcss-minify-font-values: 5.1.0(postcss@8.5.8) - postcss-minify-gradients: 5.1.1(postcss@8.5.8) - postcss-minify-params: 5.1.4(postcss@8.5.8) - postcss-minify-selectors: 5.2.1(postcss@8.5.8) - postcss-normalize-charset: 5.1.0(postcss@8.5.8) - postcss-normalize-display-values: 5.1.0(postcss@8.5.8) - postcss-normalize-positions: 5.1.1(postcss@8.5.8) - postcss-normalize-repeat-style: 5.1.1(postcss@8.5.8) - postcss-normalize-string: 5.1.0(postcss@8.5.8) - postcss-normalize-timing-functions: 5.1.0(postcss@8.5.8) - postcss-normalize-unicode: 5.1.1(postcss@8.5.8) - postcss-normalize-url: 5.1.0(postcss@8.5.8) - postcss-normalize-whitespace: 5.1.1(postcss@8.5.8) - postcss-ordered-values: 5.1.3(postcss@8.5.8) - postcss-reduce-initial: 5.1.2(postcss@8.5.8) - postcss-reduce-transforms: 5.1.0(postcss@8.5.8) - postcss-svgo: 5.1.0(postcss@8.5.8) - postcss-unique-selectors: 5.1.1(postcss@8.5.8) - - cssnano-utils@3.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - cssnano@5.1.15(postcss@8.5.8): - dependencies: - cssnano-preset-default: 5.2.14(postcss@8.5.8) - lilconfig: 2.1.0 - postcss: 8.5.8 - yaml: 1.10.2 - - csso@4.2.0: - dependencies: - css-tree: 1.1.3 - csstype@3.2.3: {} data-uri-to-buffer@3.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6482,7 +6712,7 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns@3.6.0: {} + date-fns@4.1.0: {} debug@2.6.9: dependencies: @@ -6492,6 +6722,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6544,27 +6776,13 @@ snapshots: dependencies: esutils: 2.0.3 - dom-serializer@1.4.1: - dependencies: - domelementtype: 2.3.0 - domhandler: 4.3.1 - entities: 2.2.0 - - domelementtype@2.3.0: {} - - domhandler@4.3.1: - dependencies: - domelementtype: 2.3.0 - dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 - domutils@2.8.0: - dependencies: - dom-serializer: 1.4.1 - domelementtype: 2.3.0 - domhandler: 4.3.1 + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 dotenv@16.6.1: {} @@ -6602,7 +6820,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 - entities@2.2.0: {} + entities@8.0.0: {} err-code@2.0.3: {} @@ -6894,8 +7112,6 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 2.0.11 - estree-walker@0.6.1: {} - estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -6913,8 +7129,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter3@4.0.7: {} - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -7227,6 +7441,12 @@ snapshots: dependencies: lru-cache: 7.18.3 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -7241,8 +7461,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-replace-symbols@1.1.0: {} - icss-utils@5.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -7251,19 +7469,11 @@ snapshots: ignore@5.3.2: {} - import-cwd@3.0.0: - dependencies: - import-from: 3.0.0 - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 - import-from@3.0.0: - dependencies: - resolve-from: 5.0.0 - imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -7392,6 +7602,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -7451,6 +7663,14 @@ snapshots: isexe@2.0.0: {} + isomorphic-dompurify@3.11.0: + dependencies: + dompurify: 3.4.2 + jsdom: 29.1.1 + transitivePeerDependencies: + - '@noble/hashes' + - canvas + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -7476,6 +7696,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.0.2: {} json-buffer@3.0.1: {} @@ -7561,8 +7807,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lilconfig@2.1.0: {} - lilconfig@3.1.3: {} loader-utils@3.3.1: {} @@ -7581,12 +7825,8 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.memoize@4.1.2: {} - lodash.merge@4.6.2: {} - lodash.uniq@4.5.0: {} - lodash@4.17.23: {} log-symbols@4.1.0: @@ -7602,6 +7842,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7733,7 +7975,7 @@ snapshots: dependencies: '@types/mdast': 3.0.15 - mdn-data@2.0.14: {} + mdn-data@2.27.1: {} media-query-parser@2.0.2: dependencies: @@ -8080,8 +8322,6 @@ snapshots: normalize-path@3.0.0: {} - normalize-url@6.1.0: {} - npm-install-checks@6.3.0: dependencies: semver: 7.7.4 @@ -8106,10 +8346,6 @@ snapshots: dependencies: path-key: 3.1.1 - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8193,8 +8429,6 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - p-finally@1.0.0: {} - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -8207,15 +8441,6 @@ snapshots: dependencies: aggregate-error: 3.1.0 - p-queue@6.6.2: - dependencies: - eventemitter3: 4.0.7 - p-timeout: 3.2.0 - - p-timeout@3.2.0: - dependencies: - p-finally: 1.0.0 - package-json-from-dist@1.0.1: {} pako@0.2.9: {} @@ -8236,6 +8461,10 @@ snapshots: parse-ms@2.1.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -8279,8 +8508,6 @@ snapshots: pidtree@0.6.0: {} - pify@5.0.0: {} - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -8303,49 +8530,10 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-calc@8.2.4(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-selector-parser: 6.1.2 - postcss-value-parser: 4.2.0 - - postcss-colormin@5.3.1(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - caniuse-api: 3.0.0 - colord: 2.9.3 - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-convert-values@5.1.3(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-discard-comments@5.1.2(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-discard-duplicates@5.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 - postcss-discard-empty@5.1.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - postcss-discard-overridden@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - postcss-load-config@3.1.4(postcss@8.5.8): - dependencies: - lilconfig: 2.1.0 - yaml: 1.10.2 - optionalDependencies: - postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.8): dependencies: lilconfig: 3.1.3 @@ -8353,44 +8541,6 @@ snapshots: optionalDependencies: postcss: 8.5.8 - postcss-merge-longhand@5.1.7(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - stylehacks: 5.1.1(postcss@8.5.8) - - postcss-merge-rules@5.1.4(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - caniuse-api: 3.0.0 - cssnano-utils: 3.1.0(postcss@8.5.8) - postcss: 8.5.8 - postcss-selector-parser: 6.1.2 - - postcss-minify-font-values@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-minify-gradients@5.1.1(postcss@8.5.8): - dependencies: - colord: 2.9.3 - cssnano-utils: 3.1.0(postcss@8.5.8) - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-minify-params@5.1.4(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - cssnano-utils: 3.1.0(postcss@8.5.8) - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-minify-selectors@5.2.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-selector-parser: 6.1.2 - postcss-modules-extract-imports@3.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -8412,18 +8562,6 @@ snapshots: icss-utils: 5.1.0(postcss@8.5.8) postcss: 8.5.8 - postcss-modules@4.3.1(postcss@8.5.8): - dependencies: - generic-names: 4.0.0 - icss-replace-symbols: 1.1.0 - lodash.camelcase: 4.3.0 - postcss: 8.5.8 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.8) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.8) - postcss-modules-scope: 3.2.1(postcss@8.5.8) - postcss-modules-values: 4.0.0(postcss@8.5.8) - string-hash: 1.1.3 - postcss-modules@6.0.1(postcss@8.5.8): dependencies: generic-names: 4.0.0 @@ -8436,90 +8574,11 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.8) string-hash: 1.1.3 - postcss-normalize-charset@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - - postcss-normalize-display-values@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-positions@5.1.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-repeat-style@5.1.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-string@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-timing-functions@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-unicode@5.1.1(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-url@5.1.0(postcss@8.5.8): - dependencies: - normalize-url: 6.1.0 - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-normalize-whitespace@5.1.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-ordered-values@5.1.3(postcss@8.5.8): - dependencies: - cssnano-utils: 3.1.0(postcss@8.5.8) - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-reduce-initial@5.1.2(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - caniuse-api: 3.0.0 - postcss: 8.5.8 - - postcss-reduce-transforms@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-svgo@5.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - svgo: 2.8.0 - - postcss-unique-selectors@5.1.1(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-selector-parser: 6.1.2 - postcss-value-parser@4.2.0: {} postcss@8.5.8: @@ -8547,8 +8606,6 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 - promise.series@0.2.0: {} - prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -8733,12 +8790,12 @@ snapshots: mdast-util-to-hast: 12.3.0 unified: 10.1.2 + require-from-string@2.0.2: {} + require-like@0.1.2: {} resolve-from@4.0.0: {} - resolve-from@5.0.0: {} - resolve.exports@2.0.3: {} resolve@1.22.11: @@ -8773,29 +8830,6 @@ snapshots: dependencies: rollup: 4.60.0 - rollup-plugin-postcss@4.0.2(postcss@8.5.8): - dependencies: - chalk: 4.1.2 - concat-with-sourcemaps: 1.1.0 - cssnano: 5.1.15(postcss@8.5.8) - import-cwd: 3.0.0 - p-queue: 6.6.2 - pify: 5.0.0 - postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8) - postcss-modules: 4.3.1(postcss@8.5.8) - promise.series: 0.2.0 - resolve: 1.22.11 - rollup-pluginutils: 2.8.2 - safe-identifier: 0.4.2 - style-inject: 0.3.0 - transitivePeerDependencies: - - ts-node - - rollup-pluginutils@2.8.2: - dependencies: - estree-walker: 0.6.1 - rollup@4.60.0: dependencies: '@types/estree': 1.0.8 @@ -8847,8 +8881,6 @@ snapshots: safe-buffer@5.2.1: {} - safe-identifier@0.4.2: {} - safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -8862,6 +8894,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -8996,8 +9032,6 @@ snapshots: dependencies: minipass: 7.1.3 - stable@0.1.8: {} - state-local@1.0.7: {} statuses@2.0.2: {} @@ -9096,33 +9130,17 @@ snapshots: strip-json-comments@3.1.1: {} - style-inject@0.3.0: {} - style-to-object@0.4.4: dependencies: inline-style-parser: 0.1.1 - stylehacks@5.1.1(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - postcss: 8.5.8 - postcss-selector-parser: 6.1.2 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} - svgo@2.8.0: - dependencies: - '@trysound/sax': 0.2.0 - commander: 7.2.0 - css-select: 4.3.0 - css-tree: 1.1.3 - csso: 4.2.0 - picocolors: 1.1.1 - stable: 0.1.8 + symbol-tree@3.2.4: {} tailwind-merge@3.5.0: {} @@ -9165,6 +9183,12 @@ snapshots: readable-stream: 2.3.8 xtend: 4.0.2 + tldts-core@7.0.29: {} + + tldts@7.0.29: + dependencies: + tldts-core: 7.0.29 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9173,6 +9197,14 @@ snapshots: toml@3.0.0: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.29 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -9191,6 +9223,8 @@ snapshots: turbo-stream@2.4.1: {} + tw-animate-css@1.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -9250,6 +9284,8 @@ snapshots: undici@6.23.0: {} + undici@7.25.0: {} + unified@10.1.2: dependencies: '@types/unist': 2.0.11 @@ -9331,6 +9367,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util-deprecate@1.0.2: {} util@0.12.5: @@ -9421,6 +9461,10 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.32.0 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -9433,6 +9477,18 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -9500,14 +9556,16 @@ snapshots: ws@7.5.10: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} yallist@4.0.0: {} - yaml@1.10.2: {} - yaml@2.8.2: {} yocto-queue@0.1.0: {} diff --git a/ui/postcss.config.js b/ui/postcss.config.js deleted file mode 100644 index 51a6e4e6..00000000 --- a/ui/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -}; diff --git a/ui/rollup.config.mjs b/ui/rollup.config.mjs index 071bb9b7..f116569a 100644 --- a/ui/rollup.config.mjs +++ b/ui/rollup.config.mjs @@ -2,8 +2,10 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import typescript from '@rollup/plugin-typescript'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; -import postcss from 'rollup-plugin-postcss'; -import autoprefixer from 'autoprefixer'; + +// We don't ship CSS — the host app's Tailwind compiles utility classes +// directly from this package's dist via @source. No PostCSS pipeline +// needed. export default { input: 'src/index.ts', @@ -36,15 +38,6 @@ export default { declarationDir: 'dist', noEmitOnError: false, }), - postcss({ - // Don't extract CSS - host app provides Tailwind - // This avoids CSS layer conflicts with host applications - inject: false, - minimize: true, - plugins: [ - autoprefixer(), - ], - }), ], external: [ 'react', @@ -53,5 +46,9 @@ export default { /^react\//, /^react-dom\//, /^@radix-ui\//, + // Externalize @datum-cloud/datum-ui (and its subpath imports) so the + // consumer brings its own pinned copy. Avoids duplicating primitives + // and prevents CSS conflicts with the host's datum-ui styles. + /^@datum-cloud\/datum-ui($|\/)/, ], }; diff --git a/ui/src/components/ActionMultiSelect.tsx b/ui/src/components/ActionMultiSelect.tsx index fb9a41a2..94fad7b8 100644 --- a/ui/src/components/ActionMultiSelect.tsx +++ b/ui/src/components/ActionMultiSelect.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as Popover from '@radix-ui/react-popover'; import { ChevronDown } from 'lucide-react'; -import { Checkbox } from './ui/checkbox'; +import { Checkbox } from '@datum-cloud/datum-ui/checkbox'; import { cn } from '../lib/utils'; export interface ActionMultiSelectOption { diff --git a/ui/src/components/ActionToggle.tsx b/ui/src/components/ActionToggle.tsx index cc1eae1f..aa00253e 100644 --- a/ui/src/components/ActionToggle.tsx +++ b/ui/src/components/ActionToggle.tsx @@ -79,7 +79,7 @@ export function ActionToggle({ 'rounded-none px-2 h-7 text-xs font-medium transition-all duration-200', index < OPTIONS.length - 1 && 'border-r border-input', value === option.value - ? 'bg-[#BF9595] text-[#0C1D31] hover:bg-[#BF9595]/90' + ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-muted text-foreground hover:bg-muted/80' )} onClick={() => onChange(option.value)} diff --git a/ui/src/components/ActivityExpandedDetails.tsx b/ui/src/components/ActivityExpandedDetails.tsx index 14dc44ea..ff9cc074 100644 --- a/ui/src/components/ActivityExpandedDetails.tsx +++ b/ui/src/components/ActivityExpandedDetails.tsx @@ -1,13 +1,7 @@ -import { useState } from 'react'; -import { Copy, Check } from 'lucide-react'; import type { Activity, TenantLinkResolver } from '../types/activity'; -import { TenantBadge } from './TenantBadge'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from './ui/tooltip'; +import { TooltipProvider } from './ui/tooltip'; +import { Timestamp } from './Timestamp'; +import { DetailGrid, DetailPanelShell, Field, Section } from './details'; export interface ActivityExpandedDetailsProps { /** The activity to display details for */ @@ -19,206 +13,140 @@ export interface ActivityExpandedDetailsProps { } /** - * Format timestamp for display (in UTC) + * ActivityExpandedDetails renders the expanded details for an activity row. + * Layout: an optional "Changes" block on top, then four grouped sections — + * When / Actor / Resource / Origin — laid out as a responsive grid. */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; - } catch { - return timestamp; - } -} - -/** - * CopyButton component for copying field values to clipboard - */ -function CopyButton({ value, label }: { value: string; label: string }) { - const [isCopied, setIsCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(value); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - return ( - - - - - -

{isCopied ? 'Copied!' : `Copy ${label}`}

-
-
- ); -} - -/** - * ActivityExpandedDetails renders the expanded details section for an activity. - * Used by both feed and timeline variants of ActivityFeedItem for consistent UX. - * - * Section order (most to least relevant for investigation): - * 1. Changes - what changed (most actionable) - * 2. Timestamp - when it happened - * 3. Tenant - scope of the activity - * 4. Actor - who made the change - * 5. Resource - what resource was affected - * 6. Origin - correlation to audit logs - */ -export function ActivityExpandedDetails({ activity, tenantLinkResolver, compact = false }: ActivityExpandedDetailsProps) { +export function ActivityExpandedDetails({ + activity, + compact = false, +}: ActivityExpandedDetailsProps) { const { spec, metadata } = activity; - const { actor, resource, origin, changes, tenant } = spec; + const { actor, resource, origin, changes } = spec; const timestamp = metadata?.creationTimestamp; - return ( - -
- {/* Field Changes - Most actionable, shown first */} - {changes && changes.length > 0 && ( -
-

- Changes -

-
- {changes.map((change, index) => ( -
- - {change.field} + const actorDisplay = actor.displayName || actor.name; + const actorIsUser = actor.type === 'user'; + + const body = ( + <> + {changes && changes.length > 0 ? ( +
+

+ Changes +

+
+ {changes.map((change, index) => ( +
+ + {change.field} + + {change.old ? ( + + + {change.old} - {change.old && ( - - - {change.old} - - )} - {change.new && ( - - + - {change.new} - - )} -
- ))} -
-
- )} - - {/* CSS Grid layout with reduced min-width for more columns */} -
- {/* 1. Timestamp */} -
-
Timestamp:
-
- {formatTimestampFull(timestamp)} - -
-
- - {/* 2. Actor Type */} -
-
Actor Type:
-
- {actor.type} - -
-
- - {/* 3. Actor */} -
-
Actor:
-
- {actor.name} - -
-
- - {/* 4. API Group */} - {resource.apiGroup && ( -
-
API Group:
-
- {resource.apiGroup} - -
-
- )} - - {/* 5. Resource */} -
-
Resource:
-
- {resource.kind} - -
-
- - {/* 6. Resource Name */} -
-
Resource Name:
-
- {resource.name} - -
-
- - {/* 7. Namespace */} - {resource.namespace && ( -
-
Namespace:
-
- {resource.namespace} - -
-
- )} - - {/* 8. Resource UID */} - {resource.uid && ( -
-
Resource UID:
-
- {resource.uid} - -
+ ) : null} + {change.new ? ( + + + + {change.new} + + ) : null} +
+ ))}
- )} - - {/* 9. Origin */} -
-
Origin:
-
- {origin.type} - -
+ ) : null} + + +
+ } + copyValue={timestamp ?? ''} + copyLabel="timestamp" + /> +
+ +
+ + {actorDisplay} + {actor.type ? ( + ({actor.type}) + ) : null} + + } + copyValue={actorDisplay} + copyLabel="actor name" + /> + {actorIsUser && actor.email ? ( + + ) : null} + {actor.uid ? ( + + ) : null} +
+ +
+ + {resource.kind} + {resource.apiGroup ? ( + · {resource.apiGroup} + ) : null} + + } + copyValue={resource.kind} + copyLabel="resource kind" + /> + {resource.name ? ( + + ) : null} + {resource.namespace ? ( + + ) : null} + {resource.uid ? ( + + ) : null} +
+ +
+ + {origin.id ? ( + + ) : null} +
+
+ + ); - {/* 10. Origin ID */} -
-
Origin ID:
-
- {origin.id} - -
-
- -
+ return ( + + {compact ? ( + {body} + ) : ( +
{body}
+ )}
); } diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 61b3aee2..6d9e3fb1 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -1,19 +1,44 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; -import type { Activity, ResourceRef, ResourceLinkResolver, TenantLinkResolver, TenantRenderer, EffectiveTimeRangeCallback, ErrorFormatter } from '../types/activity'; +import { useEffect, useRef, useCallback, useState } from "react"; +import type { + Activity, + ResourceRef, + ResourceLinkResolver, + TenantLinkResolver, + TenantRenderer, + EffectiveTimeRangeCallback, + ErrorFormatter, +} from "../types/activity"; import type { ActivityFeedFilters as FilterState, TimeRange, -} from '../hooks/useActivityFeed'; -import { useActivityFeed } from '../hooks/useActivityFeed'; -import { ActivityFeedItem } from './ActivityFeedItem'; -import { ActivityFeedItemSkeleton } from './ActivityFeedItemSkeleton'; -import { ActivityFeedFilters } from './ActivityFeedFilters'; -import { ActivityApiClient } from '../api/client'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; -import { ApiErrorAlert } from './ApiErrorAlert'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; +} from "../hooks/useActivityFeed"; +import { useActivityFeed } from "../hooks/useActivityFeed"; +import { + ActivityFeedItem, + ACTIVITY_FEED_COLUMN_COUNT, +} from "./ActivityFeedItem"; +import { ActivityFeedItemSkeleton } from "./ActivityFeedItemSkeleton"; +import { Skeleton } from "@datum-cloud/datum-ui/skeleton"; +import { ActivityFeedFilters } from "./ActivityFeedFilters"; +import { ActivityApiClient } from "../api/client"; +import { Button } from "./ui/button"; +import { Card } from "@datum-cloud/datum-ui/card"; +import { Badge } from "./ui/badge"; +import { ApiErrorAlert } from "./ApiErrorAlert"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@datum-cloud/datum-ui/table"; export interface ActivityFeedProps { /** API client instance */ @@ -37,13 +62,20 @@ export interface ActivityFeedProps { /** Whether to show in compact mode (for resource detail tabs) */ compact?: boolean; /** Layout variant for activity items: 'feed' (default) or 'timeline' */ - variant?: 'feed' | 'timeline'; + variant?: "feed" | "timeline"; /** Filter to a specific resource UID */ resourceUid?: string; /** Whether to show filters */ showFilters?: boolean; /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'changeSource'>; + hiddenFilters?: Array< + | "resourceKinds" + | "actorNames" + | "apiGroups" + | "resourceNamespaces" + | "resourceName" + | "changeSource" + >; /** Additional CSS class */ className?: string; /** Enable infinite scroll (default: true) */ @@ -75,8 +107,8 @@ export interface ActivityFeedProps { */ export function ActivityFeed({ client, - initialFilters = { changeSource: 'human' }, - initialTimeRange = { start: 'now-7d' }, + initialFilters = { changeSource: "human" }, + initialTimeRange = { start: "now-7d" }, pageSize = 30, onResourceClick, resourceLinkResolver, @@ -84,11 +116,11 @@ export function ActivityFeed({ tenantRenderer, onActivityClick, compact = false, - variant = 'feed', + variant = "feed", resourceUid, showFilters = true, hiddenFilters = [], - className = '', + className = "", infiniteScroll = true, loadMoreThreshold = 200, onCreatePolicy, @@ -165,14 +197,31 @@ export function ActivityFeed({ loadMoreRef.current = loadMore; }, [loadMore]); + // Mirror isLoading into a ref so the IntersectionObserver callback can + // read the latest value without listing it as an effect dep. Listing + // isLoading caused the observer to tear down and rebuild on every + // isLoading toggle; the rebuilt observer fired immediately when the + // trigger element was in the viewport, which in turn called loadMore() + // and toggled isLoading again — a cycle that disabled the toolbar + // repeatedly until items filled the viewport. + // + // hasMore *is* a dep, because the trigger element is conditionally + // rendered (`{infiniteScroll && hasMore &&
}`). When + // hasMore flips false→true after the initial fetch we need the effect + // to re-run so the observer attaches to the now-mounted trigger. + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + // Infinite scroll using Intersection Observer useEffect(() => { - if (!infiniteScroll || !loadMoreTriggerRef.current) return; + if (!infiniteScroll || !hasMore || !loadMoreTriggerRef.current) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; - if (entry.isIntersecting && hasMore && !isLoading) { + if (entry.isIntersecting && !isLoadingRef.current) { // Call through the ref to always use the latest function loadMoreRef.current(); } @@ -181,7 +230,7 @@ export function ActivityFeed({ root: scrollContainerRef.current, rootMargin: `${loadMoreThreshold}px`, threshold: 0, - } + }, ); observer.observe(loadMoreTriggerRef.current); @@ -189,7 +238,7 @@ export function ActivityFeed({ return () => { observer.disconnect(); }; - }, [infiniteScroll, hasMore, isLoading, loadMoreThreshold]); + }, [infiniteScroll, loadMoreThreshold, hasMore]); // Handle filter changes - refresh is automatic via the hook const handleFiltersChange = useCallback( @@ -197,7 +246,7 @@ export function ActivityFeed({ setFilters(newFilters); onFiltersChangeProp?.(newFilters, timeRange); }, - [setFilters, onFiltersChangeProp, timeRange] + [setFilters, onFiltersChangeProp, timeRange], ); // Handle time range changes - refresh is automatic via the hook @@ -206,7 +255,7 @@ export function ActivityFeed({ setTimeRange(newTimeRange); onFiltersChangeProp?.(filters, newTimeRange); }, - [setTimeRange, onFiltersChangeProp, filters] + [setTimeRange, onFiltersChangeProp, filters], ); // Handle manual load more click @@ -224,207 +273,354 @@ export function ActivityFeed({ }, [isStreaming, startStreaming, stopStreaming]); // Handle actor click - filter by actor name - const handleActorClick = useCallback((actorName: string) => { - setFilters({ - ...filters, - actorNames: [actorName], - }); - }, [filters, setFilters]); + const handleActorClick = useCallback( + (actorName: string) => { + setFilters({ + ...filters, + actorNames: [actorName], + }); + }, + [filters, setFilters], + ); // Build container classes - use flex layout to properly fill available space // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling const containerClasses = compact - ? `flex-1 min-h-0 flex flex-col p-0 shadow-none border-none ${className}` - : `flex-1 min-h-0 flex flex-col p-3 ${className}`; + ? `flex-1 min-h-0 flex flex-col p-3 shadow-none border-none gap-0 ${className}` + : `flex-1 min-h-0 flex flex-col p-3 gap-0 ${className}`; // Build list classes - use flex-1 min-h-0 for flex-based scrolling // Parent containers must have proper height constraints (h-screen/h-full + overflow-hidden) - const effectiveMaxHeight = maxHeight === 'none' ? undefined : maxHeight; - const listClasses = 'flex-1 min-h-0 overflow-y-auto flex flex-col'; + const effectiveMaxHeight = maxHeight === "none" ? undefined : maxHeight; + const listClasses = "flex-1 min-h-0 overflow-y-auto flex flex-col"; return ( - - {/* Header with streaming status */} - {enableStreaming && ( -
-
- {isStreaming && !watchError && ( - - - -
- - - - - Streaming activity... -
-
- -

New activities will appear automatically

-
-
-
- )} - {watchError && ( - - - -
- - - - Connection error -
-
- -

Stream connection lost

-
-
-
- )} - {newActivitiesCount > 0 && !watchError && ( - - +{newActivitiesCount} new - - )} + + + {/* Header with streaming status */} + {enableStreaming && ( +
+
+ {isStreaming && !watchError && ( + + + +
+ + + + + + Streaming activity... + +
+
+ +

New activities will appear automatically

+
+
+
+ )} + {watchError && ( + + + +
+ + + + + Connection error + +
+
+ +

Stream connection lost

+
+
+
+ )} + {newActivitiesCount > 0 && !watchError && ( + + +{newActivitiesCount} new + + )} +
+
- -
- )} - - {/* Filters */} - {showFilters && ( - - )} - - {/* Query Error Display */} - + )} - {/* Watch Stream Error Display */} - + {/* Filters */} + {showFilters && ( + + )} - {/* No Policies Empty State */} - {!policiesLoading && hasPolicies === false && ( -
-
- - - - - - -
-

Get started with activity logging

-

- Activity policies define which resources to track and how to summarize changes. - Create your first policy to start seeing activity logs here. -

- {onCreatePolicy && ( - - )} -
- )} + {/* Query Error Display */} + - {/* Activity List */} -
- {/* Skeleton Loading State - show when loading and no items yet */} - {isLoading && activities.length === 0 && ( - <> - {Array.from({ length: 8 }).map((_, index) => ( - - ))} - - )} + {/* Watch Stream Error Display */} + - {/* Empty State - only show when not loading */} - {!isLoading && activities.length === 0 && hasPolicies !== false && ( -
-

No activities found

-

- Try adjusting your filters or time range + {/* No Policies Empty State */} + {!policiesLoading && hasPolicies === false && ( +

+
+ + + + + + +
+

+ Get started with activity logging +

+

+ Activity policies define which resources to track and how to + summarize changes. Create your first policy to start seeing + activity logs here.

+ {onCreatePolicy && ( + + )}
)} - {activities.map((activity, index) => ( - - ))} + {/* Activity List */} +
+ {/* Empty State - only show when not loading and we're using the + feed variant. Empty state for the table case is rendered as an + in-table message row below. */} + {!isLoading && + activities.length === 0 && + hasPolicies !== false && + variant === "timeline" && ( +
+

No activities found

+

+ Try adjusting your filters or time range +

+
+ )} - {/* Load More Trigger for Infinite Scroll */} - {infiniteScroll && hasMore && ( -
- )} + {variant === "timeline" ? ( + <> + {isLoading && activities.length === 0 && ( + <> + {Array.from({ length: 8 }).map((_, index) => ( + + ))} + + )} + {activities.map((activity, index) => ( + + ))} + + ) : ( + + + + + Summary + Tenant + When + + + + + {isLoading && activities.length === 0 && ( + <> + {Array.from({ length: 8 }).map((_, index) => ( + + {/* Standard per-cell skeleton row, matching the + shape of the real columns: avatar, summary, + tenant, when, expand. */} + + + + + + + + + + + + + + + + + ))} + + )} + {!isLoading && + activities.length === 0 && + hasPolicies !== false && ( + + +
No activities found
+
+ Try adjusting your filters or time range +
+
+
+ )} + {activities.map((activity, index) => ( + + ))} +
+
+ )} - {/* Load More Button (when infinite scroll is disabled) */} - {!infiniteScroll && hasMore && !isLoading && ( -
- -
- )} + {/* Load More Trigger for Infinite Scroll */} + {infiniteScroll && hasMore && ( +
+ )} - {/* End of Results */} - {!hasMore && activities.length > 0 && !isLoading && ( -
- No more activities to load -
- )} -
- + {/* Manual pagination footer: shown for the table variant when + infinite scroll is disabled. Mirrors the look of other + staff-portal data tables — count on the left, action on the + right. */} + {!infiniteScroll && + variant !== "timeline" && + activities.length > 0 ? ( +
+ + {activities.length}{" "} + {activities.length === 1 ? "activity" : "activities"} + {hasMore ? " so far" : ""} + + {hasMore ? ( + + ) : ( + End of results + )} +
+ ) : null} + + {/* Legacy end-of-results indicator for the timeline variant */} + {variant === "timeline" && + !hasMore && + activities.length > 0 && + !isLoading ? ( +
+ No more activities to load +
+ ) : null} +
+ + ); } diff --git a/ui/src/components/ActivityFeedFilters.tsx b/ui/src/components/ActivityFeedFilters.tsx index 2f8d1045..5d841a4e 100644 --- a/ui/src/components/ActivityFeedFilters.tsx +++ b/ui/src/components/ActivityFeedFilters.tsx @@ -1,16 +1,16 @@ -import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { formatISO, subDays } from 'date-fns'; -import { Search, X } from 'lucide-react'; - -import type { ActivityFeedFilters as FilterState } from '../hooks/useActivityFeed'; -import type { TimeRange } from '../hooks/useActivityFeed'; -import type { ActivityApiClient } from '../api/client'; -import { useFacets } from '../hooks/useFacets'; -import { ChangeSourceToggle, ChangeSourceOption } from './ChangeSourceToggle'; -import { TimeRangeDropdown } from './ui/time-range-dropdown'; -import { FilterChip } from './ui/filter-chip'; -import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; -import { Input } from './ui/input'; +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import { formatISO, subDays } from "date-fns"; +import { Search, X } from "lucide-react"; + +import type { ActivityFeedFilters as FilterState } from "../hooks/useActivityFeed"; +import type { TimeRange } from "../hooks/useActivityFeed"; +import type { ActivityApiClient } from "../api/client"; +import { useFacets } from "../hooks/useFacets"; +import { ChangeSourceToggle, ChangeSourceOption } from "./ChangeSourceToggle"; +import { TimeRangeDropdown } from "./ui/time-range-dropdown"; +import { FilterChip } from "./ui/filter-chip"; +import { AddFilterDropdown, type FilterOption } from "./ui/add-filter-dropdown"; +import { Input } from "@datum-cloud/datum-ui/input"; export interface ActivityFeedFiltersProps { /** API client instance for fetching facets */ @@ -26,7 +26,15 @@ export interface ActivityFeedFiltersProps { /** Whether the filters are disabled (e.g., during loading) */ disabled?: boolean; /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'actions' | 'changeSource'>; + hiddenFilters?: Array< + | "resourceKinds" + | "actorNames" + | "apiGroups" + | "resourceNamespaces" + | "resourceName" + | "actions" + | "changeSource" + >; /** Additional CSS class */ className?: string; } @@ -35,61 +43,67 @@ export interface ActivityFeedFiltersProps { * Preset time ranges */ const TIME_PRESETS = [ - { key: 'now-1h', label: 'Last hour' }, - { key: 'now-24h', label: 'Last 24 hours' }, - { key: 'now-7d', label: 'Last 7 days' }, - { key: 'now-30d', label: 'Last 30 days' }, + { key: "now-1h", label: "Last hour" }, + { key: "now-24h", label: "Last 24 hours" }, + { key: "now-7d", label: "Last 7 days" }, + { key: "now-30d", label: "Last 30 days" }, ]; /** * Filter configuration registry */ -type FilterId = 'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'actions'; +type FilterId = + | "resourceKinds" + | "actorNames" + | "apiGroups" + | "resourceNamespaces" + | "resourceName" + | "actions"; interface FilterConfig { id: FilterId; label: string; - inputMode: 'typeahead' | 'text'; + inputMode: "typeahead" | "text"; placeholder?: string; searchPlaceholder?: string; } const FILTER_CONFIGS: Record = { resourceKinds: { - id: 'resourceKinds', - label: 'Kind', - inputMode: 'typeahead', - searchPlaceholder: 'Search kinds...', + id: "resourceKinds", + label: "Kind", + inputMode: "typeahead", + searchPlaceholder: "Search kinds...", }, actorNames: { - id: 'actorNames', - label: 'Actor', - inputMode: 'typeahead', - searchPlaceholder: 'Search actors...', + id: "actorNames", + label: "Actor", + inputMode: "typeahead", + searchPlaceholder: "Search actors...", }, apiGroups: { - id: 'apiGroups', - label: 'API Group', - inputMode: 'typeahead', - searchPlaceholder: 'Search API groups...', + id: "apiGroups", + label: "API Group", + inputMode: "typeahead", + searchPlaceholder: "Search API groups...", }, resourceNamespaces: { - id: 'resourceNamespaces', - label: 'Namespace', - inputMode: 'typeahead', - searchPlaceholder: 'Search namespaces...', + id: "resourceNamespaces", + label: "Namespace", + inputMode: "typeahead", + searchPlaceholder: "Search namespaces...", }, resourceName: { - id: 'resourceName', - label: 'Resource Name', - inputMode: 'text', - placeholder: 'Enter resource name...', + id: "resourceName", + label: "Resource Name", + inputMode: "text", + placeholder: "Enter resource name...", }, actions: { - id: 'actions', - label: 'Action', - inputMode: 'typeahead', - searchPlaceholder: 'Search actions...', + id: "actions", + label: "Action", + inputMode: "typeahead", + searchPlaceholder: "Search actions...", }, }; @@ -99,10 +113,10 @@ const FILTER_CONFIGS: Record = { const formatDatetimeLocal = (isoString: string): string => { const date = new Date(isoString); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; }; @@ -111,7 +125,7 @@ const formatDatetimeLocal = (isoString: string): string => { */ const getSelectedPreset = (timeRange: TimeRange): string => { const preset = TIME_PRESETS.find((p) => timeRange.start === p.key); - return preset ? preset.key : 'custom'; + return preset ? preset.key : "custom"; }; /** @@ -125,13 +139,19 @@ export function ActivityFeedFilters({ onTimeRangeChange, disabled = false, hiddenFilters = [], - className = '', + className = "", }: ActivityFeedFiltersProps) { - const { resourceKinds, actorNames, apiGroups, resourceNamespaces, error: facetsError } = useFacets(client, timeRange, filters); + const { + resourceKinds, + actorNames, + apiGroups, + resourceNamespaces, + error: facetsError, + } = useFacets(client, timeRange, filters); // Log facets error for debugging if (facetsError) { - console.error('Failed to load facets:', facetsError); + console.error("Failed to load facets:", facetsError); } // Track which filter was just added to auto-open it @@ -140,13 +160,13 @@ export function ActivityFeedFilters({ // Custom time range state const selectedPreset = getSelectedPreset(timeRange); const [customStart, setCustomStart] = useState(() => { - if (selectedPreset === 'custom') { + if (selectedPreset === "custom") { return formatDatetimeLocal(timeRange.start); } return formatDatetimeLocal(formatISO(subDays(new Date(), 1))); }); const [customEnd, setCustomEnd] = useState(() => { - if (selectedPreset === 'custom' && timeRange.end) { + if (selectedPreset === "custom" && timeRange.end) { return formatDatetimeLocal(timeRange.end); } return formatDatetimeLocal(formatISO(new Date())); @@ -160,7 +180,7 @@ export function ActivityFeedFilters({ changeSource: value, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Handle time range preset selection @@ -171,7 +191,7 @@ export function ActivityFeedFilters({ end: undefined, }); }, - [onTimeRangeChange] + [onTimeRangeChange], ); // Handle custom time range apply @@ -184,37 +204,59 @@ export function ActivityFeedFilters({ end: new Date(end).toISOString(), }); }, - [onTimeRangeChange] + [onTimeRangeChange], ); // Get display label for time range const getTimeRangeLabel = () => { const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); if (preset) return preset.label; - if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { + if (selectedPreset === "custom" && timeRange.start && timeRange.end) { const start = new Date(timeRange.start); const end = new Date(timeRange.end); return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; } - return 'Select time range'; + return "Select time range"; }; // Determine which filters are currently active (have values) and not hidden const filtersWithValues = useMemo(() => { const result: FilterId[] = []; - if (filters.resourceKinds && filters.resourceKinds.length > 0 && !hiddenFilters.includes('resourceKinds')) result.push('resourceKinds'); - if (filters.actorNames && filters.actorNames.length > 0 && !hiddenFilters.includes('actorNames')) result.push('actorNames'); - if (filters.apiGroups && filters.apiGroups.length > 0 && !hiddenFilters.includes('apiGroups')) result.push('apiGroups'); - if (filters.resourceNamespaces && filters.resourceNamespaces.length > 0 && !hiddenFilters.includes('resourceNamespaces')) result.push('resourceNamespaces'); - if (filters.resourceName && !hiddenFilters.includes('resourceName')) result.push('resourceName'); - if (filters.actions && filters.actions.length > 0) result.push('actions'); + if ( + filters.resourceKinds && + filters.resourceKinds.length > 0 && + !hiddenFilters.includes("resourceKinds") + ) + result.push("resourceKinds"); + if ( + filters.actorNames && + filters.actorNames.length > 0 && + !hiddenFilters.includes("actorNames") + ) + result.push("actorNames"); + if ( + filters.apiGroups && + filters.apiGroups.length > 0 && + !hiddenFilters.includes("apiGroups") + ) + result.push("apiGroups"); + if ( + filters.resourceNamespaces && + filters.resourceNamespaces.length > 0 && + !hiddenFilters.includes("resourceNamespaces") + ) + result.push("resourceNamespaces"); + if (filters.resourceName && !hiddenFilters.includes("resourceName")) + result.push("resourceName"); + if (filters.actions && filters.actions.length > 0) result.push("actions"); return result; }, [filters, hiddenFilters]); // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters - const activeFilterIds: FilterId[] = pendingFilter && !filtersWithValues.includes(pendingFilter) - ? [...filtersWithValues, pendingFilter] - : filtersWithValues; + const activeFilterIds: FilterId[] = + pendingFilter && !filtersWithValues.includes(pendingFilter) + ? [...filtersWithValues, pendingFilter] + : filtersWithValues; // Clear pending filter when filter values change (user selected something) useEffect(() => { @@ -226,11 +268,11 @@ export function ActivityFeedFilters({ // Build available filters list (exclude hidden filters) const availableFilters: FilterOption[] = [ - { id: 'resourceKinds', label: 'Kind' }, - { id: 'actorNames', label: 'Actor' }, - { id: 'apiGroups', label: 'API Group' }, - { id: 'resourceNamespaces', label: 'Namespace' }, - { id: 'resourceName', label: 'Resource Name' }, + { id: "resourceKinds", label: "Kind" }, + { id: "actorNames", label: "Actor" }, + { id: "apiGroups", label: "API Group" }, + { id: "resourceNamespaces", label: "Namespace" }, + { id: "resourceName", label: "Resource Name" }, // 'actions' hidden until backend facet support is available ].filter((filter) => !hiddenFilters.includes(filter.id as FilterId)); @@ -245,7 +287,7 @@ export function ActivityFeedFilters({ if (pendingFilter === filterId) { const hasValues = (() => { const value = filters[filterId]; - if (filterId === 'resourceName') return !!value; + if (filterId === "resourceName") return !!value; return Array.isArray(value) && value.length > 0; })(); if (!hasValues) { @@ -253,7 +295,7 @@ export function ActivityFeedFilters({ } } }, - [pendingFilter, filters] + [pendingFilter, filters], ); // Handle filter value changes @@ -264,7 +306,7 @@ export function ActivityFeedFilters({ [filterId]: values.length > 0 ? values : undefined, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Handle filter clear @@ -275,13 +317,13 @@ export function ActivityFeedFilters({ [filterId]: undefined, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Get options for a specific filter const getFilterOptions = (filterId: FilterId) => { switch (filterId) { - case 'resourceKinds': + case "resourceKinds": return resourceKinds .filter((facet) => facet.value) .map((facet) => ({ @@ -289,7 +331,7 @@ export function ActivityFeedFilters({ label: facet.value, count: facet.count, })); - case 'actorNames': + case "actorNames": return actorNames .filter((facet) => facet.value) .map((facet) => ({ @@ -297,7 +339,7 @@ export function ActivityFeedFilters({ label: facet.value, count: facet.count, })); - case 'apiGroups': + case "apiGroups": return apiGroups .filter((facet) => facet.value) .map((facet) => ({ @@ -305,7 +347,7 @@ export function ActivityFeedFilters({ label: facet.value, count: facet.count, })); - case 'resourceNamespaces': + case "resourceNamespaces": return resourceNamespaces .filter((facet) => facet.value) .map((facet) => ({ @@ -313,7 +355,7 @@ export function ActivityFeedFilters({ label: facet.value, count: facet.count, })); - case 'actions': + case "actions": // TODO: Return action facets when backend supports it return []; default: @@ -324,17 +366,19 @@ export function ActivityFeedFilters({ // Get values for a specific filter const getFilterValues = (filterId: FilterId): string[] => { const value = filters[filterId]; - if (filterId === 'resourceName') { + if (filterId === "resourceName") { return value ? [value as string] : []; } - if (filterId === 'actions') { + if (filterId === "actions") { return (value as string[] | undefined) || []; } return (value as string[] | undefined) || []; }; // Local search value for debouncing — keeps input responsive while query runs - const [searchInputValue, setSearchInputValue] = useState(filters.search || ''); + const [searchInputValue, setSearchInputValue] = useState( + filters.search || "", + ); const searchDebounceRef = useRef | null>(null); // Use refs so the debounced callback never closes over stale values const filtersRef = useRef(filters); @@ -349,28 +393,34 @@ export function ActivityFeedFilters({ }; }, []); - const handleSearchChange = useCallback((event: React.ChangeEvent) => { - const value = event.target.value; - setSearchInputValue(value); - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => { - onFiltersChangeRef.current({ ...filtersRef.current, search: value || undefined }); - }, 400); - }, []); + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + setSearchInputValue(value); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + onFiltersChangeRef.current({ + ...filtersRef.current, + search: value || undefined, + }); + }, 400); + }, + [], + ); const handleSearchClear = useCallback(() => { - setSearchInputValue(''); + setSearchInputValue(""); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); onFiltersChangeRef.current({ ...filtersRef.current, search: undefined }); }, []); return ( -
+
{/* Change Source Toggle */} - {!hiddenFilters.includes('changeSource') && ( + {!hiddenFilters.includes("changeSource") && ( @@ -405,7 +455,11 @@ export function ActivityFeedFilters({ key={filterId} label={config.label} values={getFilterValues(filterId)} - options={config.inputMode === 'typeahead' ? getFilterOptions(filterId) : undefined} + options={ + config.inputMode === "typeahead" + ? getFilterOptions(filterId) + : undefined + } onValuesChange={(values) => handleFilterChange(filterId, values)} onClear={() => handleFilterClear(filterId)} onPopoverClose={() => handlePopoverClose(filterId)} diff --git a/ui/src/components/ActivityFeedItem.tsx b/ui/src/components/ActivityFeedItem.tsx index 397979f3..0e0d21ff 100644 --- a/ui/src/components/ActivityFeedItem.tsx +++ b/ui/src/components/ActivityFeedItem.tsx @@ -1,13 +1,26 @@ -import { useState } from 'react'; -import { formatDistanceToNow } from 'date-fns'; -import type { Activity, ResourceLinkResolver, TenantLinkResolver, TenantRenderer } from '../types/activity'; -import { ActivityFeedSummary, ResourceLinkClickHandler } from './ActivityFeedSummary'; -import { ActivityExpandedDetails } from './ActivityExpandedDetails'; -import { TenantBadge } from './TenantBadge'; -import { cn } from '../lib/utils'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Plus, Pencil, Trash2, Activity as ActivityIcon } from 'lucide-react'; +import { useState } from "react"; +import type { + Activity, + ResourceLinkResolver, + TenantLinkResolver, + TenantRenderer, +} from "../types/activity"; +import { + ActivityFeedSummary, + ResourceLinkClickHandler, +} from "./ActivityFeedSummary"; +import { ActivityExpandedDetails } from "./ActivityExpandedDetails"; +import { TenantBadge } from "./TenantBadge"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Plus, Pencil, Trash2, Activity as ActivityIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { TableCell, TableRow } from "@datum-cloud/datum-ui/table"; +import { Timestamp } from "./Timestamp"; + +// Number of columns rendered for the feed variant. Used as the colSpan on +// the expanded-detail row so it stretches across the full width. +export const ACTIVITY_FEED_COLUMN_COUNT = 5; export interface ActivityFeedItemProps { /** The activity to render */ @@ -33,45 +46,19 @@ export interface ActivityFeedItemProps { /** Whether this is a newly streamed activity */ isNew?: boolean; /** Layout variant: 'feed' (default) or 'timeline' */ - variant?: 'feed' | 'timeline'; + variant?: "feed" | "timeline"; /** Whether this is the last item in the list (hides bottom border, only used in timeline variant) */ isLast?: boolean; /** Whether the item starts expanded */ defaultExpanded?: boolean; } -/** - * Format timestamp for display - */ -function formatTimestamp(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return formatDistanceToNow(date, { addSuffix: true }); - } catch { - return timestamp; - } -} - -/** - * Format timestamp for tooltip (in UTC) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; - } catch { - return timestamp; - } -} - /** * Get avatar initials from actor name */ function getActorInitials(name: string): string { const parts = name.split(/[@\s.]+/).filter(Boolean); - if (parts.length === 0) return '?'; + if (parts.length === 0) return "?"; if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); } @@ -81,18 +68,18 @@ function getActorInitials(name: string): string { */ function getActorAvatarClasses(actorType: string, compact: boolean): string { const baseClasses = cn( - 'rounded-full flex items-center justify-center shrink-0 font-semibold', - compact ? 'w-5 h-5 text-xs' : 'w-6 h-6 text-xs' + "rounded-full flex items-center justify-center shrink-0 font-semibold", + compact ? "w-5 h-5 text-1xs" : "w-6 h-6 text-1xs", ); switch (actorType) { - case 'user': - return cn(baseClasses, 'bg-lime-200 text-slate-900 dark:bg-lime-800 dark:text-lime-100'); - case 'controller': - return cn(baseClasses, 'bg-rose-300 text-slate-900 dark:bg-rose-800 dark:text-rose-100'); - case 'machine account': - return cn(baseClasses, 'bg-muted text-muted-foreground'); + case "user": + return cn(baseClasses, "bg-primary text-primary-foreground"); + case "controller": + return cn(baseClasses, "bg-secondary text-secondary-foreground"); + case "machine account": + return cn(baseClasses, "bg-muted text-muted-foreground"); default: - return cn(baseClasses, 'bg-muted text-muted-foreground'); + return cn(baseClasses, "bg-muted text-muted-foreground"); } } @@ -104,34 +91,58 @@ function extractVerb(summary: string): string { if (words.length >= 2) { return words[1].toLowerCase(); } - return 'unknown'; + return "unknown"; } /** * Normalize verb to a canonical form for coloring */ -function normalizeVerb(verb: string): 'create' | 'update' | 'delete' | 'other' { +function normalizeVerb(verb: string): "create" | "update" | "delete" | "other" { const normalized = verb.toLowerCase(); - if (normalized.includes('create') || normalized.includes('add')) return 'create'; - if (normalized.includes('delete') || normalized.includes('remove')) return 'delete'; - if (normalized.includes('update') || normalized.includes('patch') || normalized.includes('modify') || normalized.includes('change') || normalized.includes('edit')) return 'update'; - return 'other'; + if (normalized.includes("create") || normalized.includes("add")) + return "create"; + if (normalized.includes("delete") || normalized.includes("remove")) + return "delete"; + if ( + normalized.includes("update") || + normalized.includes("patch") || + normalized.includes("modify") || + normalized.includes("change") || + normalized.includes("edit") + ) + return "update"; + return "other"; } /** * Get icon container + icon color classes based on verb */ -function getActionIconClasses(verb: string): { container: string; icon: string } { +function getActionIconClasses(verb: string): { + container: string; + icon: string; +} { const normalizedVerb = normalizeVerb(verb); switch (normalizedVerb) { - case 'create': - return { container: 'bg-blue-50 dark:bg-blue-950', icon: 'text-blue-500 dark:text-blue-400' }; - case 'update': - return { container: 'bg-green-50 dark:bg-green-950', icon: 'text-green-600 dark:text-green-400' }; - case 'delete': - return { container: 'bg-red-50 dark:bg-red-950', icon: 'text-red-500 dark:text-red-400' }; + case "create": + return { + container: "bg-blue-50 dark:bg-blue-950", + icon: "text-blue-500 dark:text-blue-400", + }; + case "update": + return { + container: "bg-green-50 dark:bg-green-950", + icon: "text-green-600 dark:text-green-400", + }; + case "delete": + return { + container: "bg-red-50 dark:bg-red-950", + icon: "text-red-500 dark:text-red-400", + }; default: - return { container: 'bg-slate-100 dark:bg-slate-800', icon: 'text-slate-500 dark:text-slate-400' }; + return { + container: "bg-slate-100 dark:bg-slate-800", + icon: "text-slate-500 dark:text-slate-400", + }; } } @@ -141,11 +152,11 @@ function getActionIconClasses(verb: string): { container: string; icon: string } function getTimelineIcon(verb: string): React.ElementType { const normalizedVerb = normalizeVerb(verb); switch (normalizedVerb) { - case 'create': + case "create": return Plus; - case 'update': + case "update": return Pencil; - case 'delete': + case "delete": return Trash2; default: return ActivityIcon; @@ -164,10 +175,10 @@ export function ActivityFeedItem({ onActorClick, onActivityClick, isSelected = false, - className = '', + className = "", compact = false, isNew = false, - variant = 'feed', + variant = "feed", isLast = false, defaultExpanded = false, }: ActivityFeedItemProps) { @@ -176,10 +187,6 @@ export function ActivityFeedItem({ const { spec, metadata } = activity; const { actor, summary, links, tenant } = spec; - const handleClick = () => { - onActivityClick?.(activity); - }; - const handleActorClick = (e: React.MouseEvent) => { e.stopPropagation(); if (onActorClick) { @@ -194,26 +201,32 @@ export function ActivityFeedItem({ const timestamp = metadata?.creationTimestamp; const verb = extractVerb(summary); - const isTimeline = variant === 'timeline'; + const isTimeline = variant === "timeline"; // Timeline variant — flat list row with bottom border if (isTimeline) { const { container: iconBg, icon: iconColor } = getActionIconClasses(verb); const Icon = getTimelineIcon(verb); return ( -
+
{/* Action icon square */}
@@ -233,16 +246,21 @@ export function ActivityFeedItem({ {/* Tenant badge */} {tenant && (
- {tenantRenderer ? tenantRenderer(tenant) : } + {tenantRenderer ? ( + tenantRenderer(tenant) + ) : ( + + )}
)} {/* Timestamp */} - - {formatTimestamp(timestamp)} + + {/* Expand toggle */} @@ -253,92 +271,163 @@ export function ActivityFeedItem({ onClick={toggleExpand} aria-expanded={isExpanded} > - {isExpanded ? '−' : '+'} + {isExpanded ? "−" : "+"}
{/* Expanded Details */} {isExpanded && ( - + )}
); } - // Feed variant (single-row layout) - return ( - - {/* Single row layout */} -
- {/* Actor Avatar */} -
- {actor.type === 'controller' ? ( - - ) : actor.type === 'machine account' ? ( - 🤖 - ) : ( - {getActorInitials(actor.name)} - )} -
- - {/* Summary - takes remaining space */} -
- -
+ {actor.type === "controller" ? ( + + ) : actor.type === "machine account" ? ( + 🤖 + ) : ( + + {getActorInitials(actorInitialsSource)} + + )} +
+ ); - {/* Tenant badge */} - {tenant && ( -
- {tenantRenderer ? tenantRenderer(tenant) : } -
- )} + // Actor column = avatar only. The display name / email / UID are + // surfaced via a hover tooltip on the avatar so the column stays narrow. + const actorTooltipBody = ( +
+ {actorVisible} + {actor.email && actor.email !== actorVisible ? ( + {actor.email} + ) : null} + {actor.uid ? ( + {actor.uid} + ) : null} +
+ ); - {/* Timestamp */} - - {formatTimestamp(timestamp)} - + const actorCell = ( + + {avatar} + {actorTooltipBody} + + ); - {/* Expand button */} - -
- - {/* Expanded Details */} - {isExpanded && } - + + +
+ +
+
+ + {summary} + +
+ + + {tenant ? ( + tenantRenderer ? ( + tenantRenderer(tenant) + ) : ( + + ) + ) : null} + + + + + + + + + {isExpanded ? ( + + + + + + ) : null} + ); } diff --git a/ui/src/components/ActivityFeedItemSkeleton.tsx b/ui/src/components/ActivityFeedItemSkeleton.tsx index dae319a7..5844bfc2 100644 --- a/ui/src/components/ActivityFeedItemSkeleton.tsx +++ b/ui/src/components/ActivityFeedItemSkeleton.tsx @@ -1,5 +1,5 @@ -import { Card } from './ui/card'; -import { Skeleton } from './ui/skeleton'; +import { Card } from '@datum-cloud/datum-ui/card'; +import { Skeleton } from '@datum-cloud/datum-ui/skeleton'; import { cn } from '../lib/utils'; export interface ActivityFeedItemSkeletonProps { diff --git a/ui/src/components/ActivityFeedSummary.tsx b/ui/src/components/ActivityFeedSummary.tsx index e9ec930c..bd3c822e 100644 --- a/ui/src/components/ActivityFeedSummary.tsx +++ b/ui/src/components/ActivityFeedSummary.tsx @@ -1,4 +1,36 @@ import type { ActivityLink, ResourceRef, ResourceLinkResolver, ResourceLinkContext } from '../types/activity'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; + +/** + * Returns the visible text for a link: prefer the server-provided + * displayName (e.g. "Smith Nelson") and fall back to the original marker + * (e.g. an email or UID baked into the summary by the policy template). + */ +function linkVisibleText(link: ActivityLink): string { + return link.displayName && link.displayName.length > 0 ? link.displayName : link.marker; +} + +/** + * Returns true when the link carries hover-worthy detail beyond the + * visible text (e.g. an email or UID that we want to surface but not + * show inline). + */ +function linkHasHoverDetail(link: ActivityLink): boolean { + if (link.email && link.email !== linkVisibleText(link)) return true; + const uid = link.resource?.uid; + if (uid && uid !== linkVisibleText(link)) return true; + return false; +} + +/** Renders the tooltip body shown when a link is hovered. */ +function LinkHoverBody({ link }: { link: ActivityLink }) { + return ( +
+ {link.email ? {link.email} : null} + {link.resource?.uid ? {link.resource.uid} : null} +
+ ); +} export interface ResourceLinkClickHandler { (resource: ResourceRef): void; @@ -33,8 +65,12 @@ function parseSummaryWithLinks( return [summary]; } - // Sort links by marker length (longest first) to avoid partial matches - const sortedLinks = [...links].sort((a, b) => b.marker.length - a.marker.length); + // Sort links by marker length (longest first) to avoid partial matches. + // Skip empty markers — indexOf('') clamps to summary.length and would + // produce an infinite loop below. + const sortedLinks = [...links] + .filter((l) => l.marker && l.marker.length > 0) + .sort((a, b) => b.marker.length - a.marker.length); // Track positions that have been replaced interface ReplacedRange { @@ -82,24 +118,55 @@ function parseSummaryWithLinks( result.push(summary.substring(lastEnd, range.start)); } + const visibleText = linkVisibleText(range.link); + const showHover = linkHasHoverDetail(range.link); + // If resourceLinkResolver is provided, render as tag if (resourceLinkResolver) { const url = resourceLinkResolver(range.link.resource, resourceLinkContext); if (url) { - result.push( + const anchor = ( e.stopPropagation()} > - {range.link.marker} + {visibleText} ); + + if (showHover) { + result.push( + + {anchor} + + + + + ); + } else { + result.push(anchor); + } + } else if (showHover) { + // Resolver opted out of linking but we still want to surface the + // hover detail (email/UID) for user-typed references. + result.push( + + + + {visibleText} + + + + + + + ); } else { - // Resolver returned undefined, render as plain text - result.push(range.link.marker); + // Resolver returned undefined and there's no hover detail, render plain text + result.push(visibleText); } } else { // Fallback to button with onResourceClick handler for backward compatibility @@ -111,17 +178,30 @@ function parseSummaryWithLinks( } : undefined; - result.push( + const button = ( ); + + if (showHover) { + result.push( + + {button} + + + + + ); + } else { + result.push(button); + } } lastEnd = range.end; diff --git a/ui/src/components/ActivityLayout.tsx b/ui/src/components/ActivityLayout.tsx index 9e11989a..7a508e6b 100644 --- a/ui/src/components/ActivityLayout.tsx +++ b/ui/src/components/ActivityLayout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; +import { Tabs, TabsList, TabsTrigger } from '@datum-cloud/datum-ui/tabs'; import { cn } from '../lib/utils'; export interface ActivityTab { diff --git a/ui/src/components/ApiErrorAlert.tsx b/ui/src/components/ApiErrorAlert.tsx index 1aae8ed9..d7fcfd14 100644 --- a/ui/src/components/ApiErrorAlert.tsx +++ b/ui/src/components/ApiErrorAlert.tsx @@ -1,5 +1,5 @@ import { AlertCircle, AlertTriangle, RotateCw } from 'lucide-react'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { Button } from './ui/button'; import { defaultErrorFormatter } from '../lib/errors'; import type { ErrorFormatter } from '../types/activity'; diff --git a/ui/src/components/AuditEventViewer.tsx b/ui/src/components/AuditEventViewer.tsx index 61ca6fea..46691a73 100644 --- a/ui/src/components/AuditEventViewer.tsx +++ b/ui/src/components/AuditEventViewer.tsx @@ -1,11 +1,11 @@ -import { useState } from 'react'; -import { format } from 'date-fns'; -import type { Event } from '../types'; -import type { Tenant, TenantLinkResolver, TenantType } from '../types/activity'; -import { TenantBadge } from './TenantBadge'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; +import { useState } from "react"; +import { format } from "date-fns"; +import type { Event } from "../types"; +import type { Tenant, TenantLinkResolver, TenantType } from "../types/activity"; +import { TenantBadge } from "./TenantBadge"; +import { Button } from "./ui/button"; +import { Card } from "@datum-cloud/datum-ui/card"; +import { Badge } from "./ui/badge"; export interface AuditEventViewerProps { events: Event[]; @@ -23,14 +23,19 @@ function extractTenantFromAnnotations(event: Event): Tenant | undefined { const annotations = event.annotations; if (!annotations) return undefined; - const tenantType = annotations['tenant.type']; - const tenantName = annotations['tenant.name']; + const tenantType = annotations["tenant.type"]; + const tenantName = annotations["tenant.name"]; if (tenantType && tenantName) { - const validTypes: TenantType[] = ['platform', 'organization', 'project', 'user']; + const validTypes: TenantType[] = [ + "platform", + "organization", + "project", + "user", + ]; if (validTypes.includes(tenantType as TenantType)) { return { - type: tenantType as Tenant['type'], + type: tenantType as Tenant["type"], name: tenantName, }; } @@ -44,7 +49,7 @@ function extractTenantFromAnnotations(event: Event): Tenant | undefined { */ export function AuditEventViewer({ events, - className = '', + className = "", onEventSelect, tenantLinkResolver, }: AuditEventViewerProps) { @@ -69,36 +74,46 @@ export function AuditEventViewer({ }; const formatTimestamp = (timestamp?: string) => { - if (!timestamp) return 'N/A'; + if (!timestamp) return "N/A"; try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss'); + return format(new Date(timestamp), "yyyy-MM-dd HH:mm:ss"); } catch { return timestamp; } }; - const getVerbBadgeVariant = (verb?: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' => { + const getVerbBadgeVariant = ( + verb?: string, + ): + | "default" + | "secondary" + | "destructive" + | "outline" + | "success" + | "warning" => { switch (verb?.toLowerCase()) { - case 'create': - return 'success'; - case 'update': - case 'patch': - return 'warning'; - case 'delete': - return 'destructive'; - case 'get': - case 'list': - case 'watch': - return 'default'; + case "create": + return "success"; + case "update": + case "patch": + return "warning"; + case "delete": + return "destructive"; + case "get": + case "list": + case "watch": + return "default"; default: - return 'secondary'; + return "secondary"; } }; if (events.length === 0) { return (
-
No events found
+
+ No events found +
); } @@ -107,7 +122,7 @@ export function AuditEventViewer({
{events.map((event) => { - const auditId = event.auditID || ''; + const auditId = event.auditID || ""; const isExpanded = expandedEvents.has(auditId); const tenant = extractTenantFromAnnotations(event); @@ -116,18 +131,21 @@ export function AuditEventViewer({ key={auditId} className={`p-5 mb-3 cursor-pointer transition-all hover:border-primary/50 hover:shadow-sm hover:-translate-y-px ${ selectedEvent?.auditID === auditId - ? 'border-primary bg-primary/5 shadow-md' - : '' + ? "border-primary bg-primary/5 shadow-md" + : "" }`} onClick={() => handleEventClick(event)} >
- - {event.verb?.toUpperCase() || 'UNKNOWN'} + + {event.verb?.toUpperCase() || "UNKNOWN"} - {event.objectRef?.resource || 'N/A'} + {event.objectRef?.resource || "N/A"} {event.objectRef?.namespace && ( @@ -135,14 +153,22 @@ export function AuditEventViewer({ )} {event.objectRef?.name && ( - {event.objectRef.name} + + {event.objectRef.name} + )} {tenant && ( - + )}
- {event.user?.username || 'N/A'} + + {event.user?.username || "N/A"} + {formatTimestamp(event.stageTimestamp)} @@ -155,7 +181,7 @@ export function AuditEventViewer({ }} className="text-primary" > - {isExpanded ? '▼' : '▶'} + {isExpanded ? "▼" : "▶"}
@@ -163,26 +189,52 @@ export function AuditEventViewer({ {isExpanded && (
-

Event Information

+

+ Event Information +

-
Audit ID:
-
{event.auditID || 'N/A'}
-
Stage:
-
{event.stage || 'N/A'}
-
Level:
-
{event.level || 'N/A'}
-
Request URI:
-
{event.requestURI || 'N/A'}
+
+ Audit ID: +
+
+ {event.auditID || "N/A"} +
+
+ Stage: +
+
+ {event.stage || "N/A"} +
+
+ Level: +
+
+ {event.level || "N/A"} +
+
+ Request URI: +
+
+ {event.requestURI || "N/A"} +
{event.userAgent && ( <> -
User Agent:
-
{event.userAgent}
+
+ User Agent: +
+
+ {event.userAgent} +
)} {event.sourceIPs && event.sourceIPs.length > 0 && ( <> -
Source IPs:
-
{event.sourceIPs.join(', ')}
+
+ Source IPs: +
+
+ {event.sourceIPs.join(", ")} +
)}
@@ -190,23 +242,42 @@ export function AuditEventViewer({ {tenant && (
-

Tenant

- +

+ Tenant +

+
)} {event.user && (
-

User Information

+

+ User Information +

-
Username:
-
{event.user.username || 'N/A'}
-
UID:
-
{event.user.uid || 'N/A'}
+
+ Username: +
+
+ {event.user.username || "N/A"} +
+
+ UID: +
+
+ {event.user.uid || "N/A"} +
{event.user.groups && event.user.groups.length > 0 && ( <> -
Groups:
-
{event.user.groups.join(', ')}
+
+ Groups: +
+
+ {event.user.groups.join(", ")} +
)}
@@ -215,49 +286,80 @@ export function AuditEventViewer({ {event.responseStatus && (
-

Response Status

+

+ Response Status +

-
Code:
-
{event.responseStatus.code || 'N/A'}
-
Status:
-
{event.responseStatus.status || 'N/A'}
+
+ Code: +
+
+ {event.responseStatus.code || "N/A"} +
+
+ Status: +
+
+ {event.responseStatus.status || "N/A"} +
{event.responseStatus.message && ( <> -
Message:
-
{event.responseStatus.message}
+
+ Message: +
+
+ {event.responseStatus.message} +
)}
)} - {event.annotations && Object.keys(event.annotations).length > 0 && ( -
-

Annotations

-
- {Object.entries(event.annotations).map(([key, value]) => ( -
-
{key}:
-
{value}
-
- ))} -
-
- )} + {event.annotations && + Object.keys(event.annotations).length > 0 && ( +
+

+ Annotations +

+
+ {Object.entries(event.annotations).map( + ([key, value]) => ( +
+
+ {key}: +
+
{value}
+
+ ), + )} +
+
+ )} - {(event.requestObject || event.responseObject) ? ( + {event.requestObject || event.responseObject ? (
-

Request/Response Data

+

+ Request/Response Data +

{event.requestObject ? (
- Request Object -
{JSON.stringify(event.requestObject, null, 2)}
+ + Request Object + +
+                            {JSON.stringify(event.requestObject, null, 2)}
+                          
) : null} {event.responseObject ? (
- Response Object -
{JSON.stringify(event.responseObject, null, 2)}
+ + Response Object + +
+                            {JSON.stringify(event.responseObject, null, 2)}
+                          
) : null}
diff --git a/ui/src/components/AuditLogExpandedDetails.tsx b/ui/src/components/AuditLogExpandedDetails.tsx index b2a2c944..129215d2 100644 --- a/ui/src/components/AuditLogExpandedDetails.tsx +++ b/ui/src/components/AuditLogExpandedDetails.tsx @@ -1,274 +1,202 @@ -import { format } from 'date-fns'; import type { Event } from '../types'; +import { TooltipProvider } from './ui/tooltip'; +import { Timestamp } from './Timestamp'; +import { DetailGrid, DetailPanelShell, Field, Section } from './details'; export interface AuditLogExpandedDetailsProps { /** The audit event to display details for */ event: Event; + /** When true, applies the in-table padded shell (default true) */ + compact?: boolean; } /** - * Format timestamp for display (with timezone) + * AuditLogExpandedDetails renders the expanded details for an audit event. + * Sections: Request / Response / When / User / Resource / Source. Advanced + * fields and raw request/response objects are tucked under collapsed + *
sections at the end. */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * AuditLogExpandedDetails renders the expanded details section for an audit log event. - * - * Section order (most to least relevant for investigation): - * 1. Request Summary (verb, URI) - * 2. Response Summary (status code with icon, message) - * 3. Timestamp (full) - * 4. User (username, UID, groups) - * 5. Resource (kind, name, namespace, API group) - * 6. Request Details (user agent, source IPs) - * 7. Advanced (collapsed) - audit ID, stage, level, annotations - * 8. Raw Objects (collapsed) - request/response JSON - */ -export function AuditLogExpandedDetails({ event }: AuditLogExpandedDetailsProps) { +export function AuditLogExpandedDetails({ event, compact = true }: AuditLogExpandedDetailsProps) { const timestamp = event.stageTimestamp || event.requestReceivedTimestamp; + const status = event.responseStatus; + const isOk = status?.code != null && status.code >= 200 && status.code < 300; - return ( -
- {/* Request Summary */} -
-

- Request Summary -

-
-
Verb:
-
{event.verb || 'Unknown'}
- {event.requestURI && ( - <> -
URI:
-
{event.requestURI}
- - )} -
-
+ const body = ( + <> + +
+ + {event.requestURI ? ( + + ) : null} +
- {/* Response Summary */} - {event.responseStatus && ( -
-

- Response Summary -

-
- {event.responseStatus.code !== undefined && ( - <> -
Status Code:
-
+ {status ? ( +
+ {status.code != null ? ( + = 200 && event.responseStatus.code < 300 + isOk ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' } > - {event.responseStatus.code >= 200 && event.responseStatus.code < 300 ? '✓ ' : '✗ '} - {event.responseStatus.code} + {isOk ? '✓ ' : '✗ '} + {status.code} -
- - )} - {event.responseStatus.status && ( - <> -
Status:
-
{event.responseStatus.status}
- - )} - {event.responseStatus.message && ( - <> -
Message:
-
{event.responseStatus.message}
- - )} - {event.responseStatus.reason && ( - <> -
Reason:
-
{event.responseStatus.reason}
- - )} -
-
- )} + } + copyValue={String(status.code)} + copyLabel="status code" + /> + ) : null} + {status.status ? : null} + {status.reason ? : null} + {status.message ? ( + + ) : null} + + ) : null} - {/* Timestamp */} -
-

- Timestamp -

-

- {formatTimestampFull(timestamp)} -

-
+
+ } + copyValue={timestamp || ''} + copyLabel="timestamp" + /> +
- {/* User Information */} - {event.user ? ( -
-

- User -

-
- {event.user.username && ( - <> -
Username:
-
{event.user.username}
- - )} - {event.user.uid && ( - <> -
UID:
-
{event.user.uid}
- - )} - {event.user.groups && event.user.groups.length > 0 && ( - <> -
Groups:
-
- {event.user.groups.join(', ')} -
- - )} -
-
- ) : null} + {event.user ? ( +
+ {event.user.username ? ( + + ) : null} + {event.user.uid ? ( + + ) : null} + {event.user.groups && event.user.groups.length > 0 ? ( + + ) : null} +
+ ) : null} - {/* Resource Information */} - {event.objectRef && ( -
-

- Resource -

-
- {event.objectRef.resource && ( - <> -
Kind:
-
{event.objectRef.resource}
- - )} - {event.objectRef.name && ( - <> -
Name:
-
{event.objectRef.name}
- - )} - {event.objectRef.namespace && ( - <> -
Namespace:
-
{event.objectRef.namespace}
- - )} - {event.objectRef.apiGroup && ( - <> -
API Group:
-
{event.objectRef.apiGroup}
- - )} - {event.objectRef.apiVersion && ( - <> -
API Version:
-
{event.objectRef.apiVersion}
- - )} - {event.objectRef.uid && ( - <> -
UID:
-
{event.objectRef.uid}
- - )} - {event.objectRef.subresource && ( - <> -
Subresource:
-
{event.objectRef.subresource}
- - )} -
-
- )} + {event.objectRef ? ( +
+ + {event.objectRef.resource || 'Unknown'} + {event.objectRef.apiGroup ? ( + · {event.objectRef.apiGroup} + ) : null} + + } + copyValue={event.objectRef.resource} + copyLabel="resource kind" + /> + {event.objectRef.name ? ( + + ) : null} + {event.objectRef.namespace ? ( + + ) : null} + {event.objectRef.apiVersion ? ( + + ) : null} + {event.objectRef.subresource ? ( + + ) : null} + {event.objectRef.uid ? ( + + ) : null} +
+ ) : null} - {/* Request Details */} - {(event.userAgent || (event.sourceIPs && event.sourceIPs.length > 0)) && ( -
-

- Request Details -

-
- {event.userAgent && ( - <> -
User Agent:
-
{event.userAgent}
- - )} - {event.sourceIPs && event.sourceIPs.length > 0 && ( - <> -
Source IPs:
-
{event.sourceIPs.join(', ')}
- - )} -
-
- )} + {event.userAgent || (event.sourceIPs && event.sourceIPs.length > 0) ? ( +
+ {event.sourceIPs && event.sourceIPs.length > 0 ? ( + + ) : null} + {event.userAgent ? ( + + ) : null} +
+ ) : null} +
- {/* Advanced Details (collapsed) */} - {(event.auditID || event.stage || event.level || (event.annotations && Object.keys(event.annotations).length > 0)) && ( -
+ {/* Advanced (collapsed) */} + {event.auditID || event.stage || event.level || + (event.annotations && Object.keys(event.annotations).length > 0) ? ( +
-

+

Advanced

-
-
- {event.auditID && ( - <> -
Audit ID:
-
{event.auditID}
- - )} - {event.stage && ( - <> -
Stage:
-
{event.stage}
- - )} - {event.level && ( - <> -
Level:
-
{event.level}
- - )} - {event.annotations && Object.entries(event.annotations).map(([key, value]) => ( -
-
{key}:
-
{value}
-
- ))} -
+
+ +
+ {event.auditID ? ( + + ) : null} + {event.stage ? : null} + {event.level ? : null} +
+ {event.annotations && Object.keys(event.annotations).length > 0 ? ( +
+ {Object.entries(event.annotations).map(([key, value]) => ( + + ))} +
+ ) : null} +
- )} + ) : null} - {/* Raw Objects (collapsed) */} - {(event.requestObject || event.responseObject) ? ( -
+ {/* Raw request/response (collapsed) */} + {event.requestObject || event.responseObject ? ( +
-

+

- Raw Objects + Raw objects

-
+
{event.requestObject ? (
-
Request Object
+
+ Request +
                   {JSON.stringify(event.requestObject, null, 2)}
                 
@@ -276,7 +204,9 @@ export function AuditLogExpandedDetails({ event }: AuditLogExpandedDetailsProps) ) : null} {event.responseObject ? (
-
Response Object
+
+ Response +
                   {JSON.stringify(event.responseObject, null, 2)}
                 
@@ -285,6 +215,16 @@ export function AuditLogExpandedDetails({ event }: AuditLogExpandedDetailsProps)
) : null} -
+ + ); + + return ( + + {compact ? ( + {body} + ) : ( +
{body}
+ )} +
); } diff --git a/ui/src/components/AuditLogFeedItem.tsx b/ui/src/components/AuditLogFeedItem.tsx index 5e1baec1..626f6946 100644 --- a/ui/src/components/AuditLogFeedItem.tsx +++ b/ui/src/components/AuditLogFeedItem.tsx @@ -1,11 +1,16 @@ import { useState } from 'react'; -import { format, formatDistanceToNow } from 'date-fns'; import type { Event } from '../types'; import { AuditLogExpandedDetails } from './AuditLogExpandedDetails'; import { cn } from '../lib/utils'; import { Button } from './ui/button'; -import { Card } from './ui/card'; import { Badge } from './ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; +import { TableCell, TableRow } from '@datum-cloud/datum-ui/table'; +import { Timestamp } from './Timestamp'; + +// Number of columns rendered for the audit log table. Used by the colSpan +// on the expanded-detail row so it spans the full width. +export const AUDIT_LOG_COLUMN_COUNT = 5; export interface AuditLogFeedItemProps { /** The audit event to render */ @@ -24,31 +29,6 @@ export interface AuditLogFeedItemProps { defaultExpanded?: boolean; } -/** - * Format timestamp for display - */ -function formatTimestamp(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return formatDistanceToNow(date, { addSuffix: true }); - } catch { - return timestamp; - } -} - -/** - * Format timestamp for tooltip (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - /** * Get Tailwind classes for verb badge */ @@ -133,64 +113,82 @@ export function AuditLogFeedItem({ const statusIndicator = getResponseStatusIndicator(event.responseStatus?.code); return ( - -
- {/* Main Content */} -
- {/* Single row layout: Summary + Metadata + Timestamp + Expand */} -
- {/* Summary - takes remaining space */} -
+ <> + { + toggleExpand(e); + handleClick(); + }} + aria-expanded={isExpanded} + > + + + {event.verb?.toUpperCase() || 'UNKNOWN'} + + + + + +
+ {summary} +
+
+ {summary} -
- - {/* Verb badge */} - - {event.verb?.toUpperCase() || 'UNKNOWN'} - - - {/* Response status */} - - {statusIndicator.icon} - {event.responseStatus?.code && ( - {event.responseStatus.code} - )} - - - {/* Timestamp */} - - {formatTimestamp(timestamp)} - - - {/* Expand button */} - -
-
-
- - {/* Expanded Details */} - {isExpanded && } -
+ + + + + + {statusIndicator.icon} + {event.responseStatus?.code ? {event.responseStatus.code} : null} + + + + + + + + + + {isExpanded ? ( + + + + + + ) : null} + ); } diff --git a/ui/src/components/AuditLogFilters.tsx b/ui/src/components/AuditLogFilters.tsx index bebe9115..d164439f 100644 --- a/ui/src/components/AuditLogFilters.tsx +++ b/ui/src/components/AuditLogFilters.tsx @@ -1,13 +1,16 @@ -import { useState, useCallback, useEffect } from 'react'; -import { formatISO, subDays } from 'date-fns'; - -import type { ActivityApiClient } from '../api/client'; -import { useAuditLogFacets, type AuditLogTimeRange } from '../hooks/useAuditLogFacets'; -import { TimeRangeDropdown } from './ui/time-range-dropdown'; -import { FilterChip } from './ui/filter-chip'; -import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; -import { ActionMultiSelect } from './ActionMultiSelect'; -import { UserSelect } from './UserSelect'; +import { useState, useCallback, useEffect } from "react"; +import { formatISO, subDays } from "date-fns"; + +import type { ActivityApiClient } from "../api/client"; +import { + useAuditLogFacets, + type AuditLogTimeRange, +} from "../hooks/useAuditLogFacets"; +import { TimeRangeDropdown } from "./ui/time-range-dropdown"; +import { FilterChip } from "./ui/filter-chip"; +import { AddFilterDropdown, type FilterOption } from "./ui/add-filter-dropdown"; +import { ActionMultiSelect } from "./ActionMultiSelect"; +import { UserSelect } from "./UserSelect"; /** * Filter state for audit logs @@ -56,12 +59,12 @@ export interface AuditLogFiltersProps { * Preset time ranges */ const TIME_PRESETS = [ - { key: 'last15min', label: 'Last 15 min' }, - { key: 'last1hour', label: 'Last hour' }, - { key: 'last6hours', label: 'Last 6 hours' }, - { key: 'last24hours', label: 'Last 24 hours' }, - { key: 'last7days', label: 'Last 7 days' }, - { key: 'last30days', label: 'Last 30 days' }, + { key: "last15min", label: "Last 15 min" }, + { key: "last1hour", label: "Last hour" }, + { key: "last6hours", label: "Last 6 hours" }, + { key: "last24hours", label: "Last 24 hours" }, + { key: "last7days", label: "Last 7 days" }, + { key: "last30days", label: "Last 30 days" }, ]; /** @@ -72,22 +75,22 @@ function presetToTimeRange(presetKey: string): AuditLogTimeRange { let start: Date; switch (presetKey) { - case 'last15min': + case "last15min": start = new Date(now.getTime() - 15 * 60 * 1000); break; - case 'last1hour': + case "last1hour": start = new Date(now.getTime() - 60 * 60 * 1000); break; - case 'last6hours': + case "last6hours": start = new Date(now.getTime() - 6 * 60 * 60 * 1000); break; - case 'last24hours': + case "last24hours": start = new Date(now.getTime() - 24 * 60 * 60 * 1000); break; - case 'last7days': + case "last7days": start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); break; - case 'last30days': + case "last30days": start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); break; default: @@ -103,46 +106,51 @@ function presetToTimeRange(presetKey: string): AuditLogTimeRange { /** * Filter configuration registry */ -type FilterId = 'verbs' | 'resourceTypes' | 'namespaces' | 'usernames' | 'resourceName'; +type FilterId = + | "verbs" + | "resourceTypes" + | "namespaces" + | "usernames" + | "resourceName"; interface FilterConfig { id: FilterId; label: string; - inputMode: 'typeahead' | 'text'; + inputMode: "typeahead" | "text"; placeholder?: string; searchPlaceholder?: string; } const FILTER_CONFIGS: Record = { verbs: { - id: 'verbs', - label: 'Action', - inputMode: 'typeahead', - searchPlaceholder: 'Search actions...', + id: "verbs", + label: "Action", + inputMode: "typeahead", + searchPlaceholder: "Search actions...", }, resourceTypes: { - id: 'resourceTypes', - label: 'Resource', - inputMode: 'typeahead', - searchPlaceholder: 'Search resources...', + id: "resourceTypes", + label: "Resource", + inputMode: "typeahead", + searchPlaceholder: "Search resources...", }, namespaces: { - id: 'namespaces', - label: 'Namespace', - inputMode: 'typeahead', - searchPlaceholder: 'Search namespaces...', + id: "namespaces", + label: "Namespace", + inputMode: "typeahead", + searchPlaceholder: "Search namespaces...", }, usernames: { - id: 'usernames', - label: 'User', - inputMode: 'typeahead', - searchPlaceholder: 'Search users...', + id: "usernames", + label: "User", + inputMode: "typeahead", + searchPlaceholder: "Search users...", }, resourceName: { - id: 'resourceName', - label: 'Name', - inputMode: 'text', - placeholder: 'Enter resource name...', + id: "resourceName", + label: "Name", + inputMode: "text", + placeholder: "Enter resource name...", }, }; @@ -152,14 +160,13 @@ const FILTER_CONFIGS: Record = { const formatDatetimeLocal = (isoString: string): string => { const date = new Date(isoString); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; }; - /** * Build CEL filter expression from filter state */ @@ -172,7 +179,7 @@ export function buildAuditLogCEL(filters: AuditLogFilterState): string { conditions.push(`verb == "${filters.verbs[0]}"`); } else { const verbConditions = filters.verbs.map((v) => `verb == "${v}"`); - conditions.push(`(${verbConditions.join(' || ')})`); + conditions.push(`(${verbConditions.join(" || ")})`); } } @@ -181,8 +188,10 @@ export function buildAuditLogCEL(filters: AuditLogFilterState): string { if (filters.resourceTypes.length === 1) { conditions.push(`objectRef.resource == "${filters.resourceTypes[0]}"`); } else { - const resConditions = filters.resourceTypes.map((r) => `objectRef.resource == "${r}"`); - conditions.push(`(${resConditions.join(' || ')})`); + const resConditions = filters.resourceTypes.map( + (r) => `objectRef.resource == "${r}"`, + ); + conditions.push(`(${resConditions.join(" || ")})`); } } @@ -191,8 +200,10 @@ export function buildAuditLogCEL(filters: AuditLogFilterState): string { if (filters.namespaces.length === 1) { conditions.push(`objectRef.namespace == "${filters.namespaces[0]}"`); } else { - const nsConditions = filters.namespaces.map((ns) => `objectRef.namespace == "${ns}"`); - conditions.push(`(${nsConditions.join(' || ')})`); + const nsConditions = filters.namespaces.map( + (ns) => `objectRef.namespace == "${ns}"`, + ); + conditions.push(`(${nsConditions.join(" || ")})`); } } @@ -201,8 +212,10 @@ export function buildAuditLogCEL(filters: AuditLogFilterState): string { if (filters.usernames.length === 1) { conditions.push(`user.username == "${filters.usernames[0]}"`); } else { - const userConditions = filters.usernames.map((u) => `user.username == "${u}"`); - conditions.push(`(${userConditions.join(' || ')})`); + const userConditions = filters.usernames.map( + (u) => `user.username == "${u}"`, + ); + conditions.push(`(${userConditions.join(" || ")})`); } } @@ -216,7 +229,7 @@ export function buildAuditLogCEL(filters: AuditLogFilterState): string { conditions.push(filters.customFilter); } - return conditions.join(' && '); + return conditions.join(" && "); } /** @@ -229,34 +242,38 @@ export function AuditLogFilters({ onFiltersChange, onTimeRangeChange, disabled = false, - className = '', + className = "", }: AuditLogFiltersProps) { // Convert timeRange to format expected by useAuditLogFacets - const [facetTimeRange, setFacetTimeRange] = useState(() => - presetToTimeRange('last24hours') - ); + const [facetTimeRange, setFacetTimeRange] = + useState(() => presetToTimeRange("last24hours")); - const { verbs, resources, namespaces, usernames, error: facetsError } = useAuditLogFacets( - client, - facetTimeRange - ); + const { + verbs, + resources, + namespaces, + usernames, + error: facetsError, + } = useAuditLogFacets(client, facetTimeRange); // Log facets error for debugging if (facetsError) { - console.error('Failed to load audit log facets:', facetsError); + console.error("Failed to load audit log facets:", facetsError); } // Track which filter was just added to auto-open it const [pendingFilter, setPendingFilter] = useState(null); // Track selected preset - const [selectedPreset, setSelectedPreset] = useState('last24hours'); + const [selectedPreset, setSelectedPreset] = useState("last24hours"); // Custom time range state const [customStart, setCustomStart] = useState(() => - formatDatetimeLocal(formatISO(subDays(new Date(), 1))) + formatDatetimeLocal(formatISO(subDays(new Date(), 1))), + ); + const [customEnd, setCustomEnd] = useState(() => + formatDatetimeLocal(formatISO(new Date())), ); - const [customEnd, setCustomEnd] = useState(() => formatDatetimeLocal(formatISO(new Date()))); // Handle time range preset selection const handleTimePresetSelect = useCallback( @@ -269,13 +286,13 @@ export function AuditLogFilters({ end: range.end, }); }, - [onTimeRangeChange] + [onTimeRangeChange], ); // Handle custom time range apply const handleCustomRangeApply = useCallback( (start: string, end: string) => { - setSelectedPreset('custom'); + setSelectedPreset("custom"); setCustomStart(start); setCustomEnd(end); const startIso = new Date(start).toISOString(); @@ -286,29 +303,31 @@ export function AuditLogFilters({ end: endIso, }); }, - [onTimeRangeChange] + [onTimeRangeChange], ); // Get display label for time range const getTimeRangeLabel = () => { const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); if (preset) return preset.label; - if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { + if (selectedPreset === "custom" && timeRange.start && timeRange.end) { const start = new Date(timeRange.start); const end = new Date(timeRange.end); return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; } - return 'Select time range'; + return "Select time range"; }; // Determine which filters are currently active (have values) // Note: We exclude verbs and usernames from filter chips since they're handled by quick filters const filtersWithValues: FilterId[] = []; // if (filters.verbs && filters.verbs.length > 0) filtersWithValues.push('verbs'); // Handled by ActionToggle - if (filters.resourceTypes && filters.resourceTypes.length > 0) filtersWithValues.push('resourceTypes'); - if (filters.namespaces && filters.namespaces.length > 0) filtersWithValues.push('namespaces'); + if (filters.resourceTypes && filters.resourceTypes.length > 0) + filtersWithValues.push("resourceTypes"); + if (filters.namespaces && filters.namespaces.length > 0) + filtersWithValues.push("namespaces"); // if (filters.usernames && filters.usernames.length > 0) filtersWithValues.push('usernames'); // Handled by UserSelect - if (filters.resourceName) filtersWithValues.push('resourceName'); + if (filters.resourceName) filtersWithValues.push("resourceName"); // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters const activeFilterIds: FilterId[] = @@ -327,9 +346,9 @@ export function AuditLogFilters({ // Build available filters list // Note: Action and User are now quick filters, so they're excluded from the dropdown const availableFilters: FilterOption[] = [ - { id: 'resourceTypes', label: 'Resource' }, - { id: 'namespaces', label: 'Namespace' }, - { id: 'resourceName', label: 'Name' }, + { id: "resourceTypes", label: "Resource" }, + { id: "namespaces", label: "Namespace" }, + { id: "resourceName", label: "Name" }, ]; // Handle adding a filter @@ -343,7 +362,7 @@ export function AuditLogFilters({ if (pendingFilter === filterId) { const hasValues = (() => { const value = filters[filterId]; - if (filterId === 'resourceName') return !!value; + if (filterId === "resourceName") return !!value; return Array.isArray(value) && value.length > 0; })(); if (!hasValues) { @@ -351,7 +370,7 @@ export function AuditLogFilters({ } } }, - [pendingFilter, filters] + [pendingFilter, filters], ); // Handle filter value changes @@ -362,7 +381,7 @@ export function AuditLogFilters({ [filterId]: values.length > 0 ? values : undefined, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Handle filter clear @@ -373,13 +392,13 @@ export function AuditLogFilters({ [filterId]: undefined, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Get options for a specific filter const getFilterOptions = (filterId: FilterId) => { switch (filterId) { - case 'verbs': + case "verbs": return verbs .filter((facet) => facet.value) .map((facet) => ({ @@ -387,7 +406,7 @@ export function AuditLogFilters({ label: facet.value, count: facet.count, })); - case 'resourceTypes': + case "resourceTypes": return resources .filter((facet) => facet.value) .map((facet) => ({ @@ -395,7 +414,7 @@ export function AuditLogFilters({ label: facet.value, count: facet.count, })); - case 'namespaces': + case "namespaces": return namespaces .filter((facet) => facet.value) .map((facet) => ({ @@ -403,7 +422,7 @@ export function AuditLogFilters({ label: facet.value, count: facet.count, })); - case 'usernames': + case "usernames": return usernames .filter((facet) => facet.value) .map((facet) => ({ @@ -419,7 +438,7 @@ export function AuditLogFilters({ // Get values for a specific filter const getFilterValues = (filterId: FilterId): string[] => { const value = filters[filterId]; - if (filterId === 'resourceName') { + if (filterId === "resourceName") { return value ? [value as string] : []; } return (value as string[] | undefined) || []; @@ -433,7 +452,7 @@ export function AuditLogFilters({ verbs: selectedVerbs.length > 0 ? selectedVerbs : undefined, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Get current action values for multi-select @@ -458,7 +477,7 @@ export function AuditLogFilters({ usernames: username ? [username] : undefined, }); }, - [filters, onFiltersChange] + [filters, onFiltersChange], ); // Get current user value for select (single selection for quick filter) @@ -478,7 +497,7 @@ export function AuditLogFilters({ })); return ( -
+
{/* Action Multi-Select */} handleFilterChange(filterId, values)} onClear={() => handleFilterClear(filterId)} onPopoverClose={() => handlePopoverClose(filterId)} diff --git a/ui/src/components/AuditLogQueryComponent.tsx b/ui/src/components/AuditLogQueryComponent.tsx index 82574b7e..d7e5a0a1 100644 --- a/ui/src/components/AuditLogQueryComponent.tsx +++ b/ui/src/components/AuditLogQueryComponent.tsx @@ -1,13 +1,29 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { formatISO, subDays } from 'date-fns'; -import { AuditLogFilters, buildAuditLogCEL, type AuditLogFilterState, type TimeRange } from './AuditLogFilters'; -import { AuditLogFeedItem } from './AuditLogFeedItem'; -import { useAuditLogQuery } from '../hooks/useAuditLogQuery'; -import type { AuditLogQuerySpec, Event } from '../types'; -import type { ActivityApiClient } from '../api/client'; -import type { ErrorFormatter } from '../types/activity'; -import { Card } from './ui/card'; -import { ApiErrorAlert } from './ApiErrorAlert'; +import { useState, useEffect, useRef, useCallback } from "react"; +import { formatISO, subDays } from "date-fns"; +import { + AuditLogFilters, + buildAuditLogCEL, + type AuditLogFilterState, + type TimeRange, +} from "./AuditLogFilters"; +import { AuditLogFeedItem, AUDIT_LOG_COLUMN_COUNT } from "./AuditLogFeedItem"; +import { useAuditLogQuery } from "../hooks/useAuditLogQuery"; +import type { AuditLogQuerySpec, Event } from "../types"; +import type { ActivityApiClient } from "../api/client"; +import type { ErrorFormatter } from "../types/activity"; +import { Card } from "@datum-cloud/datum-ui/card"; +import { Button } from "./ui/button"; +import { Skeleton } from "@datum-cloud/datum-ui/skeleton"; +import { ApiErrorAlert } from "./ApiErrorAlert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@datum-cloud/datum-ui/table"; +import { TooltipProvider } from "./ui/tooltip"; // Debounce delay for filter changes (ms) const FILTER_DEBOUNCE_MS = 300; @@ -30,7 +46,7 @@ export interface AuditLogQueryComponentProps { */ export function AuditLogQueryComponent({ client, - className = '', + className = "", onEventSelect, initialFilters = {}, initialTimeRange = { @@ -45,7 +61,6 @@ export function AuditLogQueryComponent({ const { events, isLoading, error, hasMore, executeQuery, loadMore } = useAuditLogQuery({ client }); - const loadMoreTriggerRef = useRef(null); const scrollContainerRef = useRef(null); // Store the latest loadMore function in a ref to avoid observer re-subscription const loadMoreRef = useRef(loadMore); @@ -55,7 +70,7 @@ export function AuditLogQueryComponent({ // Build query spec from current filter state const buildQuerySpec = useCallback((): AuditLogQuerySpec => { const spec: AuditLogQuerySpec = { - filter: buildAuditLogCEL(filters) || '', + filter: buildAuditLogCEL(filters) || "", startTime: timeRange.start, endTime: timeRange.end, limit: DEFAULT_PAGE_SIZE, @@ -71,40 +86,34 @@ export function AuditLogQueryComponent({ }, [buildQuerySpec, executeQuery]); // Handle filter changes with debounced auto-refresh - const handleFiltersChange = useCallback( - (newFilters: AuditLogFilterState) => { - setFilters(newFilters); + const handleFiltersChange = useCallback((newFilters: AuditLogFilterState) => { + setFilters(newFilters); - // Cancel any pending debounced refresh - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - } + // Cancel any pending debounced refresh + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + } - // Debounce the refresh to avoid excessive API calls - filterDebounceRef.current = setTimeout(() => { - filterDebounceRef.current = null; - }, FILTER_DEBOUNCE_MS); - }, - [] - ); + // Debounce the refresh to avoid excessive API calls + filterDebounceRef.current = setTimeout(() => { + filterDebounceRef.current = null; + }, FILTER_DEBOUNCE_MS); + }, []); // Handle time range changes with debounced auto-refresh - const handleTimeRangeChange = useCallback( - (newTimeRange: TimeRange) => { - setTimeRange(newTimeRange); + const handleTimeRangeChange = useCallback((newTimeRange: TimeRange) => { + setTimeRange(newTimeRange); - // Cancel any pending debounced refresh - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - } + // Cancel any pending debounced refresh + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + } - // Debounce the refresh - filterDebounceRef.current = setTimeout(() => { - filterDebounceRef.current = null; - }, FILTER_DEBOUNCE_MS); - }, - [] - ); + // Debounce the refresh + filterDebounceRef.current = setTimeout(() => { + filterDebounceRef.current = null; + }, FILTER_DEBOUNCE_MS); + }, []); // Auto-refresh when filters or time range change (debounced) useEffect(() => { @@ -142,32 +151,9 @@ export function AuditLogQueryComponent({ loadMoreRef.current = loadMore; }, [loadMore]); - // Infinite scroll using Intersection Observer - useEffect(() => { - if (!loadMoreTriggerRef.current) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && hasMore && !isLoading) { - console.log('[AuditLogQueryComponent] Intersection triggered, loading more...'); - // Call through the ref to always use the latest function - loadMoreRef.current(); - } - }, - { - root: scrollContainerRef.current, - rootMargin: '200px', - threshold: 0, - } - ); - - observer.observe(loadMoreTriggerRef.current); - - return () => { - observer.disconnect(); - }; - }, [hasMore, isLoading]); + // Audit log results use manual "Load more" pagination for consistency + // with the activity feed; the previous IntersectionObserver-driven + // infinite scroll caused observer rebuild loops on isLoading toggles. // Cleanup on unmount useEffect(() => { @@ -179,7 +165,7 @@ export function AuditLogQueryComponent({ }, []); return ( - + {/* Filters */} {/* Error Display */} - + {/* Event List with Infinite Scroll */} -
- {/* Loading State (initial load) */} - {isLoading && events.length === 0 && ( -
-
- Searching audit logs... -
- )} - - {/* Empty State */} - {!isLoading && events.length === 0 && !error && ( -
-

No audit events found

-

- Try adjusting your filters or time range -

-
- )} - - {/* Event List */} - {events.map((event, index) => ( - - ))} - - {/* Load More Trigger for Infinite Scroll */} - {hasMore &&
} - - {/* Loading Indicator (pagination) */} - {isLoading && events.length > 0 && ( -
-
- Loading more events... -
- )} - - {/* End of Results */} - {!hasMore && events.length > 0 && !isLoading && ( -
- End of results +
+ + + + + Verb + Summary + Status + When + + + + + {isLoading && events.length === 0 + ? Array.from({ length: 8 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + )) + : null} + {!isLoading && events.length === 0 && !error ? ( + + +
No audit events found
+
+ Try adjusting your filters or time range +
+
+
+ ) : null} + {events.map((event, index) => ( + + ))} +
+
+
+ + {/* Manual pagination footer */} + {events.length > 0 ? ( +
+ + {events.length} {events.length === 1 ? "event" : "events"} + {hasMore ? " so far" : ""} + + {hasMore ? ( + + ) : ( + End of results + )}
- )} + ) : null}
); diff --git a/ui/src/components/ChangeSourceToggle.tsx b/ui/src/components/ChangeSourceToggle.tsx index 909f136c..4bc969e7 100644 --- a/ui/src/components/ChangeSourceToggle.tsx +++ b/ui/src/components/ChangeSourceToggle.tsx @@ -1,8 +1,8 @@ -import type { ChangeSource } from '../types/activity'; -import { Button } from './ui/button'; -import { cn } from '../lib/utils'; +import type { ChangeSource } from "../types/activity"; +import { Button } from "./ui/button"; +import { cn } from "../lib/utils"; -export type ChangeSourceOption = ChangeSource | 'all'; +export type ChangeSourceOption = ChangeSource | "all"; export interface ChangeSourceToggleProps { /** Current selected value */ @@ -18,21 +18,25 @@ export interface ChangeSourceToggleProps { /** * Options for the change source toggle */ -const OPTIONS: { value: ChangeSourceOption; label: string; description: string }[] = [ +const OPTIONS: { + value: ChangeSourceOption; + label: string; + description: string; +}[] = [ { - value: 'all', - label: 'All', - description: 'Show all activities', + value: "all", + label: "All", + description: "Show all activities", }, { - value: 'human', - label: 'Human', - description: 'Show only human-initiated activities', + value: "human", + label: "Human", + description: "Show only human-initiated activities", }, { - value: 'system', - label: 'System', - description: 'Show only system-initiated activities', + value: "system", + label: "System", + description: "Show only system-initiated activities", }, ]; @@ -42,12 +46,15 @@ const OPTIONS: { value: ChangeSourceOption; label: string; description: string } export function ChangeSourceToggle({ value, onChange, - className = '', + className = "", disabled = false, }: ChangeSourceToggleProps) { return (
@@ -57,11 +64,11 @@ export function ChangeSourceToggle({ type="button" variant="ghost" className={cn( - 'rounded-none px-2 h-7 text-xs font-medium transition-all duration-200', - index < OPTIONS.length - 1 && 'border-r border-input', + "rounded-none px-2 h-7 text-xs font-medium transition-all duration-200", + index < OPTIONS.length - 1 && "border-r border-input", value === option.value - ? 'bg-[#BF9595] text-[#0C1D31] hover:bg-[#BF9595]/90' - : 'bg-muted text-foreground hover:bg-muted/80' + ? "bg-primary text-primary-foreground hover:bg-primary/90" + : "bg-muted text-foreground hover:bg-muted/80", )} onClick={() => onChange(option.value)} disabled={disabled} diff --git a/ui/src/components/DateTimeRangePicker.tsx b/ui/src/components/DateTimeRangePicker.tsx index 10392f96..2a63bf47 100644 --- a/ui/src/components/DateTimeRangePicker.tsx +++ b/ui/src/components/DateTimeRangePicker.tsx @@ -9,9 +9,9 @@ import { formatISO } from 'date-fns'; import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; -import { Card, CardContent } from './ui/card'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Label } from '@datum-cloud/datum-ui/label'; +import { Card, CardContent } from '@datum-cloud/datum-ui/card'; export interface DateTimeRange { start: string; // ISO 8601 timestamp diff --git a/ui/src/components/EventExpandedDetails.tsx b/ui/src/components/EventExpandedDetails.tsx index 1e6007ca..3d07db75 100644 --- a/ui/src/components/EventExpandedDetails.tsx +++ b/ui/src/components/EventExpandedDetails.tsx @@ -1,228 +1,180 @@ -import { format } from 'date-fns'; import type { K8sEvent } from '../types/k8s-event'; +import { TooltipProvider } from './ui/tooltip'; +import { Timestamp } from './Timestamp'; +import { DetailGrid, DetailPanelShell, Field, Section } from './details'; export interface EventExpandedDetailsProps { /** The event to display details for */ event: K8sEvent; + /** When true, applies the in-table padded shell (default true) */ + compact?: boolean; } -/** - * Format timestamp for display (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * Get the regarding object (handling both new and deprecated field names) - */ function getRegarding(event: K8sEvent) { return event.regarding || event.involvedObject || {}; } - -/** - * Get the reporting controller (handling both new and deprecated field names) - */ function getReportingController(event: K8sEvent): string | undefined { return event.reportingController || event.reportingComponent || event.source?.component; } - -/** - * Get the reporting instance (handling both new and deprecated field names) - */ function getReportingInstance(event: K8sEvent): string | undefined { return event.reportingInstance || event.source?.host; } /** - * EventExpandedDetails renders the expanded details section for an event. - * - * Section order (most to least relevant for investigation): - * 1. Regarding Object - what resource is affected (was involvedObject in core/v1) - * 2. Timestamps - when it happened - * 3. Reporting Controller - what component generated the event (was source in core/v1) - * 4. Action - what action was taken/failed - * 5. Metadata - event UIDs and versions + * EventExpandedDetails renders the expanded details for a Kubernetes + * event row. Sections: Object / When / Reporter / Action / Related / + * Metadata. Action is only shown if present. */ -export function EventExpandedDetails({ event }: EventExpandedDetailsProps) { +export function EventExpandedDetails({ event, compact = true }: EventExpandedDetailsProps) { const regarding = getRegarding(event); const reportingController = getReportingController(event); const reportingInstance = getReportingInstance(event); const { eventTime, action, metadata, related } = event; - // For backward compatibility, also check deprecated fields - // Note: events.k8s.io/v1 uses "deprecatedFirstTimestamp" and "deprecatedLastTimestamp" const firstTimestamp = event.firstTimestamp || event.deprecatedFirstTimestamp; - const lastTimestamp = event.lastTimestamp || event.deprecatedLastTimestamp || event.series?.lastObservedTime; + const lastTimestamp = + event.lastTimestamp || + event.deprecatedLastTimestamp || + event.series?.lastObservedTime; const count = event.series?.count || event.count || event.deprecatedCount; - return ( -
- {/* Regarding Object - Most actionable, shown first (was involvedObject in core/v1) */} -
-

- Regarding Object -

-
-
Kind:
-
{regarding.kind || 'Unknown'}
-
Name:
-
{regarding.name || 'Unknown'}
- {regarding.namespace && ( - <> -
Namespace:
-
{regarding.namespace}
- - )} - {regarding.apiVersion && ( - <> -
API Version:
-
{regarding.apiVersion}
- - )} - {regarding.uid && ( - <> -
UID:
-
{regarding.uid}
- - )} - {regarding.fieldPath && ( - <> -
Field Path:
-
{regarding.fieldPath}
- - )} -
-
+ const body = ( + +
+ + {regarding.kind || 'Unknown'} + {regarding.apiVersion ? ( + · {regarding.apiVersion} + ) : null} + + } + copyValue={regarding.kind} + copyLabel="object kind" + /> + + {regarding.namespace ? ( + + ) : null} + {regarding.uid ? ( + + ) : null} + {regarding.fieldPath ? ( + + ) : null} +
- {/* Timestamps */} -
-

- Timestamps -

-
- {eventTime && ( - <> -
Event Time:
-
{formatTimestampFull(eventTime)}
- - )} - {firstTimestamp && ( - <> -
First Seen:
-
{formatTimestampFull(firstTimestamp)}
- - )} - {lastTimestamp && ( - <> -
Last Seen:
-
{formatTimestampFull(lastTimestamp)}
- - )} - {count && count > 1 && ( - <> -
Count:
-
{count} times
- - )} -
-
+
+ {eventTime ? ( + } + copyValue={eventTime} + copyLabel="event time" + /> + ) : null} + {firstTimestamp ? ( + } + copyValue={firstTimestamp} + copyLabel="first seen" + /> + ) : null} + {lastTimestamp ? ( + } + copyValue={lastTimestamp} + copyLabel="last seen" + /> + ) : null} + {count && count > 1 ? ( + + ) : null} +
- {/* Reporting Controller (was Source in core/v1) */} - {(reportingController || reportingInstance) && ( -
-

- Reporting Controller -

-
- {reportingController && ( - <> -
Controller:
-
{reportingController}
- - )} - {reportingInstance && ( - <> -
Instance:
-
{reportingInstance}
- - )} -
-
- )} + {reportingController || reportingInstance ? ( +
+ {reportingController ? ( + + ) : null} + {reportingInstance ? ( + + ) : null} +
+ ) : null} - {/* Action */} - {action && ( -
-

- Action -

-

{action}

-
- )} + {action ? ( +
+ +
+ ) : null} - {/* Related Object */} - {related && ( -
-

- Related Object -

-
- {related.kind && ( - <> -
Kind:
-
{related.kind}
- - )} - {related.name && ( - <> -
Name:
-
{related.name}
- - )} - {related.namespace && ( - <> -
Namespace:
-
{related.namespace}
- - )} -
-
- )} + {related ? ( +
+ {related.kind ? : null} + {related.name ? ( + + ) : null} + {related.namespace ? ( + + ) : null} +
+ ) : null} + + {metadata ? ( +
+ {metadata.name ? ( + + ) : null} + {metadata.uid ? ( + + ) : null} + {metadata.resourceVersion ? ( + + ) : null} +
+ ) : null} +
+ ); - {/* Metadata */} - {metadata && ( -
-

- Metadata -

-
- {metadata.name && ( - <> -
Name:
-
{metadata.name}
- - )} - {metadata.uid && ( - <> -
UID:
-
{metadata.uid}
- - )} - {metadata.resourceVersion && ( - <> -
Resource Version:
-
{metadata.resourceVersion}
- - )} -
-
+ return ( + + {compact ? ( + {body} + ) : ( +
{body}
)} -
+ ); } diff --git a/ui/src/components/EventFeedItem.tsx b/ui/src/components/EventFeedItem.tsx index c90af15e..e02e0997 100644 --- a/ui/src/components/EventFeedItem.tsx +++ b/ui/src/components/EventFeedItem.tsx @@ -1,17 +1,20 @@ import { useState } from 'react'; -import { format, formatDistanceToNow } from 'date-fns'; import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react'; import type { K8sEvent } from '../types/k8s-event'; import { EventExpandedDetails } from './EventExpandedDetails'; import { cn } from '../lib/utils'; import { Button } from './ui/button'; -import { Card } from './ui/card'; import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from './ui/tooltip'; +import { TableCell, TableRow } from '@datum-cloud/datum-ui/table'; +import { Timestamp } from './Timestamp'; + +// Number of columns rendered for the events table. Used by the colSpan +// on the expanded-detail row so it spans the full width. +export const EVENT_COLUMN_COUNT = 6; export interface EventFeedItemProps { /** The event to render */ @@ -87,34 +90,7 @@ function getTimestamp(event: K8sEvent): string | undefined { } /** - * Format timestamp for display - */ -function formatTimestamp(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return formatDistanceToNow(date, { addSuffix: true }); - } catch { - return timestamp; - } -} - -/** - * Format timestamp for tooltip (human-friendly UTC format with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return format(date, "MMMM d, yyyy 'at' h:mm:ss a 'UTC'"); - } catch { - return timestamp; - } -} - - -/** - * EventFeedItem renders a single Kubernetes event in the feed + * EventFeedItem renders a single Kubernetes event as a table row. */ export function EventFeedItem({ event, @@ -172,140 +148,128 @@ export function EventFeedItem({ }; const isWarning = type === 'Warning'; + const noteWithCount = note + ? `${note}${count && count > 1 ? ` (x${count})` : ''}` + : ''; return ( - - + { + toggleExpand(e); + handleClick(); + }} + aria-expanded={isExpanded} > -
- {/* Main Content */} -
- {/* Header row: Type badge + Reason + Kind + Timestamp */} -
- {/* Type badge */} - - {type || 'Unknown'} - - - {/* Reason */} - {reason && ( - - {reason} - - )} - - {/* Involved Kind */} - {regarding.kind && ( - - {regarding.kind} + + + {type || 'Unknown'} + + + + {reason || ''} + + + {note ? ( + + +
+ {noteWithCount} +
+
+ + {noteWithCount} + +
+ ) : null} +
+ +
+ + + + {regarding.name || 'Unknown'} + + + {regarding.namespace + ? `${regarding.kind || 'Unknown'} · ${regarding.namespace}/${regarding.name}` + : `${regarding.kind || 'Unknown'} · ${regarding.name}`} + + +
- - {/* Content row: Message + Object + Timestamp + Expand */} -
- {/* Note with count - takes remaining space */} - {note && ( -

- {note}{count && count > 1 && (x{count})} -

- )} - - {/* Regarding Object with Tooltip and Copy Button */} -
- - - - {regarding.name || 'Unknown'} - - - -

- {regarding.namespace - ? `${regarding.kind || 'Unknown'} in namespace ${regarding.namespace}` - : regarding.kind || 'Unknown'} -

-
-
- - - - - -

Click to copy

-
-
-
- - {/* Expand button - larger and positioned at the end */} - -
+
-
- - {/* Expanded Details */} - {isExpanded && } - - + + + + + + + + + {isExpanded ? ( + + + + + + ) : null} + ); } diff --git a/ui/src/components/EventFeedItemSkeleton.tsx b/ui/src/components/EventFeedItemSkeleton.tsx deleted file mode 100644 index 667e88d0..00000000 --- a/ui/src/components/EventFeedItemSkeleton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Card } from './ui/card'; -import { Skeleton } from './ui/skeleton'; -import { cn } from '../lib/utils'; - -export interface EventFeedItemSkeletonProps { - /** Whether to show as compact (for resource detail tabs) */ - compact?: boolean; - /** Additional CSS class */ - className?: string; -} - -/** - * EventFeedItemSkeleton renders a loading placeholder that matches EventFeedItem layout - */ -export function EventFeedItemSkeleton({ - compact = false, - className = '', -}: EventFeedItemSkeletonProps) { - return ( - -
- {/* Main Content */} -
- {/* Single row layout: Message + Object + Timestamp + Expand */} -
- {/* Note skeleton - takes remaining space */} - - - {/* Regarding Object skeleton */} - - - {/* Timestamp skeleton */} - - - {/* Expand button skeleton */} - -
-
-
-
- ); -} diff --git a/ui/src/components/EventTypeToggle.tsx b/ui/src/components/EventTypeToggle.tsx index 0da790d5..9a133aed 100644 --- a/ui/src/components/EventTypeToggle.tsx +++ b/ui/src/components/EventTypeToggle.tsx @@ -60,7 +60,7 @@ export function EventTypeToggle({ 'rounded-none px-2 h-7 text-xs font-medium transition-all duration-200', index < OPTIONS.length - 1 && 'border-r border-input', value === option.value - ? 'bg-[#BF9595] text-[#0C1D31] hover:bg-[#BF9595]/90' + ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-muted text-foreground hover:bg-muted/80' )} onClick={() => onChange(option.value)} diff --git a/ui/src/components/EventsFeed.tsx b/ui/src/components/EventsFeed.tsx index 32f5249c..09385115 100644 --- a/ui/src/components/EventsFeed.tsx +++ b/ui/src/components/EventsFeed.tsx @@ -1,19 +1,36 @@ -import { useEffect, useRef, useCallback } from 'react'; -import type { K8sEvent } from '../types/k8s-event'; -import type { EffectiveTimeRangeCallback, ErrorFormatter } from '../types/activity'; +import { useEffect, useRef, useCallback } from "react"; +import type { K8sEvent } from "../types/k8s-event"; +import type { + EffectiveTimeRangeCallback, + ErrorFormatter, +} from "../types/activity"; import type { EventsFeedFilters as FilterState, TimeRange, -} from '../hooks/useEventsFeed'; -import { useEventsFeed } from '../hooks/useEventsFeed'; -import { EventFeedItem } from './EventFeedItem'; -import { EventFeedItemSkeleton } from './EventFeedItemSkeleton'; -import { EventsFeedFilters } from './EventsFeedFilters'; -import { ActivityApiClient } from '../api/client'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; -import { ApiErrorAlert } from './ApiErrorAlert'; +} from "../hooks/useEventsFeed"; +import { useEventsFeed } from "../hooks/useEventsFeed"; +import { EventFeedItem, EVENT_COLUMN_COUNT } from "./EventFeedItem"; +import { EventsFeedFilters } from "./EventsFeedFilters"; +import { ActivityApiClient } from "../api/client"; +import { Button } from "./ui/button"; +import { Card } from "@datum-cloud/datum-ui/card"; +import { Badge } from "./ui/badge"; +import { Skeleton } from "@datum-cloud/datum-ui/skeleton"; +import { ApiErrorAlert } from "./ApiErrorAlert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@datum-cloud/datum-ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; export interface EventsFeedProps { /** API client instance */ @@ -40,7 +57,14 @@ export interface EventsFeedProps { /** Whether to show filters */ showFilters?: boolean; /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName' | 'eventType'>; + hiddenFilters?: Array< + | "involvedKinds" + | "reasons" + | "namespaces" + | "sourceComponents" + | "involvedName" + | "eventType" + >; /** Additional CSS class */ className?: string; /** Enable infinite scroll (default: true) */ @@ -64,7 +88,7 @@ export interface EventsFeedProps { export function EventsFeed({ client, initialFilters = {}, - initialTimeRange = { start: 'now-24h' }, + initialTimeRange = { start: "now-24h" }, pageSize = 50, onEventClick, onResourceClick, @@ -72,11 +96,8 @@ export function EventsFeed({ namespace, showFilters = true, hiddenFilters = [], - className = '', - infiniteScroll = true, - loadMoreThreshold = 200, + className = "", enableStreaming = false, - onEffectiveTimeRangeChange, errorFormatter, onFiltersChange: onFiltersChangeProp, }: EventsFeedProps) { @@ -112,7 +133,7 @@ export function EventsFeed({ }); const scrollContainerRef = useRef(null); - const loadMoreTriggerRef = useRef(null); + // Store the latest loadMore function in a ref to avoid observer re-subscription const loadMoreRef = useRef(loadMore); @@ -126,31 +147,9 @@ export function EventsFeed({ loadMoreRef.current = loadMore; }, [loadMore]); - // Infinite scroll using Intersection Observer - useEffect(() => { - if (!infiniteScroll || !loadMoreTriggerRef.current) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && hasMore && !isLoading) { - // Call through the ref to always use the latest function - loadMoreRef.current(); - } - }, - { - root: scrollContainerRef.current, - rootMargin: `${loadMoreThreshold}px`, - threshold: 0, - } - ); - - observer.observe(loadMoreTriggerRef.current); - - return () => { - observer.disconnect(); - }; - }, [infiniteScroll, hasMore, isLoading, loadMoreThreshold]); + // EventsFeed uses manual "Load more" pagination for consistency with the + // activity feed; the previous IntersectionObserver-driven infinite + // scroll caused observer rebuild loops on isLoading toggles. // Handle filter changes - refresh is automatic via the hook const handleFiltersChange = useCallback( @@ -158,7 +157,7 @@ export function EventsFeed({ setFilters(newFilters); onFiltersChangeProp?.(newFilters, timeRange); }, - [setFilters, onFiltersChangeProp, timeRange] + [setFilters, onFiltersChangeProp, timeRange], ); // Handle time range changes - refresh is automatic via the hook @@ -167,7 +166,7 @@ export function EventsFeed({ setTimeRange(newTimeRange); onFiltersChangeProp?.(filters, newTimeRange); }, - [setTimeRange, onFiltersChangeProp, filters] + [setTimeRange, onFiltersChangeProp, filters], ); // Handle manual load more click @@ -187,42 +186,54 @@ export function EventsFeed({ // Build container classes - use flex layout to properly fill available space // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling const containerClasses = compact - ? `flex-1 min-h-0 flex flex-col p-2 shadow-none border-border ${className}` - : `flex-1 min-h-0 flex flex-col p-3 ${className}`; + ? `flex-1 min-h-0 flex flex-col p-2 shadow-none border-border gap-0 ${className}` + : `flex-1 min-h-0 flex flex-col p-3 gap-0 ${className}`; // Build list classes - use flex-1 min-h-0 for flex-based scrolling - const listClasses = 'flex-1 min-h-0 overflow-y-auto pr-2'; + const listClasses = "flex-1 min-h-0 overflow-y-auto pr-2"; return ( - {/* Header with streaming status */} + {/* Header with streaming status — matches ActivityFeed: no border, + tooltipped indicator, outlined Pause/Resume button. */} {enableStreaming && ( -
-
- {isStreaming && ( -
- - - - - Streaming events... -
- )} - {newEventsCount > 0 && ( - +
+
+ {isStreaming ? ( + + + +
+ + + + + + Streaming events... + +
+
+ +

New events will appear automatically

+
+
+
+ ) : null} + {newEventsCount > 0 ? ( + +{newEventsCount} new - )} + ) : null}
- -
- )} - - {/* End of Results */} - {!hasMore && events.length > 0 && !isLoading && ( -
- No more events to load + {/* Manual pagination footer */} + {events.length > 0 ? ( +
+ + {events.length} {events.length === 1 ? "event" : "events"} + {hasMore ? " so far" : ""} + + {hasMore ? ( + + ) : ( + End of results + )}
- )} + ) : null}
); diff --git a/ui/src/components/EventsFeedFilters.tsx b/ui/src/components/EventsFeedFilters.tsx index 724e0fd5..3facfc6b 100644 --- a/ui/src/components/EventsFeedFilters.tsx +++ b/ui/src/components/EventsFeedFilters.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import { formatISO, subDays } from 'date-fns'; -import { Search } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import type { EventsFeedFilters as FilterState } from '../hooks/useEventsFeed'; import type { TimeRange } from '../hooks/useEventsFeed'; @@ -10,7 +10,7 @@ import { EventTypeToggle, EventTypeOption } from './EventTypeToggle'; import { TimeRangeDropdown } from './ui/time-range-dropdown'; import { FilterChip } from './ui/filter-chip'; import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; -import { Input } from './ui/input'; +import { Input } from '@datum-cloud/datum-ui/input'; export interface EventsFeedFiltersProps { /** API client instance for fetching facets */ @@ -336,7 +336,7 @@ export function EventsFeedFilters({ ); return ( -
+
{/* Event Type Toggle */} {!hiddenFilters.includes('eventType') && ( @@ -347,7 +347,7 @@ export function EventsFeedFilters({ /> )} - {/* Search Input */} + {/* Search Input — matches ActivityFeed search styling. */}
+ {filters.search ? ( + + ) : null}
{/* Active Filter Chips */} diff --git a/ui/src/components/FilterBuilder.tsx b/ui/src/components/FilterBuilder.tsx index 8fb7136e..42505db9 100644 --- a/ui/src/components/FilterBuilder.tsx +++ b/ui/src/components/FilterBuilder.tsx @@ -2,11 +2,11 @@ import { useState } from 'react'; import type { AuditLogQuerySpec } from '../types'; import { FILTER_FIELDS } from '../types'; import { Button } from './ui/button'; -import { Card, CardContent, CardHeader } from './ui/card'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; -import { Separator } from './ui/separator'; -import { Textarea } from './ui/textarea'; +import { Card, CardContent, CardHeader } from '@datum-cloud/datum-ui/card'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Label } from '@datum-cloud/datum-ui/label'; +import { Separator } from '@datum-cloud/datum-ui/separator'; +import { Textarea } from '@datum-cloud/datum-ui/textarea'; export interface FilterBuilderProps { onFilterChange: (spec: AuditLogQuerySpec) => void; diff --git a/ui/src/components/FilterBuilderWithAutocomplete.tsx b/ui/src/components/FilterBuilderWithAutocomplete.tsx index b059c0cf..c1e7ca12 100644 --- a/ui/src/components/FilterBuilderWithAutocomplete.tsx +++ b/ui/src/components/FilterBuilderWithAutocomplete.tsx @@ -1,11 +1,11 @@ import { useState, useRef, useEffect } from 'react'; import type { AuditLogQuerySpec } from '../types'; import { FILTER_FIELDS } from '../types'; -import { Input } from './ui/input'; -import { Textarea } from './ui/textarea'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Textarea } from '@datum-cloud/datum-ui/textarea'; import { Button } from './ui/button'; -import { Label } from './ui/label'; -import { Card, CardHeader, CardContent } from './ui/card'; +import { Label } from '@datum-cloud/datum-ui/label'; +import { Card, CardHeader, CardContent } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; export interface FilterBuilderWithAutocompleteProps { diff --git a/ui/src/components/PolicyActivityViewSkeleton.tsx b/ui/src/components/PolicyActivityViewSkeleton.tsx index 48a4284c..795d471f 100644 --- a/ui/src/components/PolicyActivityViewSkeleton.tsx +++ b/ui/src/components/PolicyActivityViewSkeleton.tsx @@ -1,5 +1,5 @@ -import { Skeleton } from './ui/skeleton'; -import { Card } from './ui/card'; +import { Skeleton } from '@datum-cloud/datum-ui/skeleton'; +import { Card } from '@datum-cloud/datum-ui/card'; export interface PolicyActivityViewSkeletonProps { /** Additional CSS class */ diff --git a/ui/src/components/PolicyDetailView.tsx b/ui/src/components/PolicyDetailView.tsx index d2f7cdb0..91b1b4a2 100644 --- a/ui/src/components/PolicyDetailView.tsx +++ b/ui/src/components/PolicyDetailView.tsx @@ -5,12 +5,12 @@ import { ActivityApiClient } from '../api/client'; import { PolicyActivityView } from './PolicyActivityView'; import { PolicyActivityViewSkeleton } from './PolicyActivityViewSkeleton'; import { Button } from './ui/button'; -import { Card, CardHeader, CardContent } from './ui/card'; +import { Card, CardHeader, CardContent } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; import { ApiErrorAlert } from './ApiErrorAlert'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { AlertTriangle, AlertCircle, Copy, Check, Edit } from 'lucide-react'; -import { Skeleton } from './ui/skeleton'; +import { Skeleton } from '@datum-cloud/datum-ui/skeleton'; import { Tooltip, TooltipContent, @@ -205,7 +205,7 @@ export function PolicyDetailView({ type="button" size="sm" onClick={onEdit} - className="bg-[#BF9595] text-[#0C1D31] border-[#BF9595] hover:bg-[#A88080] hover:border-[#A88080] h-7 text-xs" + className="bg-primary text-primary-foreground border-primary hover:bg-primary/90 hover:border-primary/90 h-7 text-xs" > Edit Policy diff --git a/ui/src/components/PolicyEditView.tsx b/ui/src/components/PolicyEditView.tsx index b19cac26..87603474 100644 --- a/ui/src/components/PolicyEditView.tsx +++ b/ui/src/components/PolicyEditView.tsx @@ -5,13 +5,13 @@ import { ActivityApiClient } from '../api/client'; import { usePolicyEditor, type UsePolicyEditorResult } from '../hooks/usePolicyEditor'; import { PolicyResourceForm } from './PolicyResourceForm'; import { PolicyRuleList } from './PolicyRuleList'; -import { Input } from './ui/input'; +import { Input } from '@datum-cloud/datum-ui/input'; import { Button } from './ui/button'; -import { Card, CardHeader, CardContent } from './ui/card'; +import { Card, CardHeader, CardContent } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; -import { Label } from './ui/label'; +import { Label } from '@datum-cloud/datum-ui/label'; import { ApiErrorAlert } from './ApiErrorAlert'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { AlertTriangle, AlertCircle, Trash2, Copy, Check } from 'lucide-react'; import { Dialog, @@ -253,7 +253,7 @@ export function PolicyEditView({ size="sm" onClick={() => handleSave(false)} disabled={!canSave || editor.isSaving || !editor.isDirty} - className="bg-[#BF9595] text-[#0C1D31] border-[#BF9595] hover:bg-[#A88080] hover:border-[#A88080] h-7 text-xs" + className="bg-primary text-primary-foreground border-primary hover:bg-primary/90 hover:border-primary/90 h-7 text-xs" > {editor.isSaving ? ( <> diff --git a/ui/src/components/PolicyEditor.tsx b/ui/src/components/PolicyEditor.tsx index baf9aed5..b3061a83 100644 --- a/ui/src/components/PolicyEditor.tsx +++ b/ui/src/components/PolicyEditor.tsx @@ -6,14 +6,14 @@ import { usePolicyEditor, type UsePolicyEditorResult } from '../hooks/usePolicyE import { PolicyResourceForm } from './PolicyResourceForm'; import { PolicyRuleList } from './PolicyRuleList'; import { PolicyActivityView } from './PolicyActivityView'; -import { Input } from './ui/input'; +import { Input } from '@datum-cloud/datum-ui/input'; import { Button } from './ui/button'; -import { Card, CardHeader, CardContent } from './ui/card'; +import { Card, CardHeader, CardContent } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; -import { Label } from './ui/label'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; +import { Label } from '@datum-cloud/datum-ui/label'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@datum-cloud/datum-ui/tabs'; import { ApiErrorAlert } from './ApiErrorAlert'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { AlertTriangle, AlertCircle, Trash2, Copy, Check } from 'lucide-react'; import { Dialog, @@ -283,7 +283,7 @@ export function PolicyEditor({ size="sm" onClick={() => handleSave(false)} disabled={!canSave || editor.isSaving || !editor.isDirty} - className="bg-[#BF9595] text-[#0C1D31] border-[#BF9595] hover:bg-[#A88080] hover:border-[#A88080] h-7 text-xs" + className="bg-primary text-primary-foreground border-primary hover:bg-primary/90 hover:border-primary/90 h-7 text-xs" > {editor.isSaving ? ( <> diff --git a/ui/src/components/PolicyList.tsx b/ui/src/components/PolicyList.tsx index 54bcfe0d..f990b4a2 100644 --- a/ui/src/components/PolicyList.tsx +++ b/ui/src/components/PolicyList.tsx @@ -4,10 +4,10 @@ import type { ErrorFormatter } from '../types/activity'; import { ActivityApiClient } from '../api/client'; import { usePolicyList, type UsePolicyListResult } from '../hooks/usePolicyList'; import { Button } from './ui/button'; -import { Card, CardContent, CardHeader } from './ui/card'; +import { Card, CardContent, CardHeader } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; -import { Separator } from './ui/separator'; -import { Skeleton } from './ui/skeleton'; +import { Separator } from '@datum-cloud/datum-ui/separator'; +import { Skeleton } from '@datum-cloud/datum-ui/skeleton'; import { ApiErrorAlert } from './ApiErrorAlert'; import { AlertTriangle } from 'lucide-react'; @@ -190,7 +190,7 @@ export function PolicyList({ @@ -227,7 +227,7 @@ export function PolicyList({ diff --git a/ui/src/components/PolicyPreviewPanel.tsx b/ui/src/components/PolicyPreviewPanel.tsx index 95706e09..99b7d8ba 100644 --- a/ui/src/components/PolicyPreviewPanel.tsx +++ b/ui/src/components/PolicyPreviewPanel.tsx @@ -5,9 +5,9 @@ import type { import type { ResourceRef } from '../types/activity'; import { PolicyPreviewResult } from './PolicyPreviewResult'; import { cn } from '../lib/utils'; -import { Card, CardContent } from './ui/card'; +import { Card, CardContent } from '@datum-cloud/datum-ui/card'; import { Button } from './ui/button'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { AlertCircle, Loader2 } from 'lucide-react'; export interface PolicyPreviewPanelProps { diff --git a/ui/src/components/PolicyPreviewResult.tsx b/ui/src/components/PolicyPreviewResult.tsx index 8431507c..23a254db 100644 --- a/ui/src/components/PolicyPreviewResult.tsx +++ b/ui/src/components/PolicyPreviewResult.tsx @@ -6,9 +6,9 @@ import { ActivityFeedItem } from './ActivityFeedItem'; import { AuditLogFeedItem } from './AuditLogFeedItem'; import { EventFeedItem } from './EventFeedItem'; import { cn } from '../lib/utils'; -import { Card, CardContent } from './ui/card'; +import { Card, CardContent } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { AlertCircle, CheckCircle, XCircle } from 'lucide-react'; export interface PolicyPreviewResultProps { diff --git a/ui/src/components/PolicyResourceForm.tsx b/ui/src/components/PolicyResourceForm.tsx index d09eeab8..a41d6137 100644 --- a/ui/src/components/PolicyResourceForm.tsx +++ b/ui/src/components/PolicyResourceForm.tsx @@ -7,9 +7,9 @@ import { SelectItem, SelectTrigger, SelectValue, -} from './ui/select'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; +} from '@datum-cloud/datum-ui/select'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Label } from '@datum-cloud/datum-ui/label'; import { Button } from './ui/button'; export interface PolicyResourceFormProps { diff --git a/ui/src/components/PolicyRuleEditor.tsx b/ui/src/components/PolicyRuleEditor.tsx index e3d3ca01..37daba83 100644 --- a/ui/src/components/PolicyRuleEditor.tsx +++ b/ui/src/components/PolicyRuleEditor.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import type { ActivityPolicyRule } from '../types/policy'; import { Button } from './ui/button'; -import { Card, CardContent, CardHeader } from './ui/card'; -import { Label } from './ui/label'; -import { Textarea } from './ui/textarea'; +import { Card, CardContent, CardHeader } from '@datum-cloud/datum-ui/card'; +import { Label } from '@datum-cloud/datum-ui/label'; +import { Textarea } from '@datum-cloud/datum-ui/textarea'; export interface PolicyRuleEditorProps { /** The rule being edited */ @@ -191,7 +191,7 @@ export function PolicyRuleEditor({ @@ -211,7 +211,7 @@ export function ReindexJobList({ diff --git a/ui/src/components/ResourceHistoryView.tsx b/ui/src/components/ResourceHistoryView.tsx index dc15a0bb..394a4dc0 100644 --- a/ui/src/components/ResourceHistoryView.tsx +++ b/ui/src/components/ResourceHistoryView.tsx @@ -6,7 +6,7 @@ import { ActivityFeedItem } from './ActivityFeedItem'; import { ResourceLinkClickHandler } from './ActivityFeedSummary'; import { ActivityApiClient } from '../api/client'; import { Button } from './ui/button'; -import { Card, CardHeader, CardTitle, CardContent } from './ui/card'; +import { Card, CardHeader, CardTitle, CardContent } from '@datum-cloud/datum-ui/card'; import { cn } from '../lib/utils'; import { ApiErrorAlert } from './ApiErrorAlert'; diff --git a/ui/src/components/RulePreviewPanel.tsx b/ui/src/components/RulePreviewPanel.tsx index 9a00d383..52b0909f 100644 --- a/ui/src/components/RulePreviewPanel.tsx +++ b/ui/src/components/RulePreviewPanel.tsx @@ -3,9 +3,9 @@ import type { ActivityPolicyRule, PolicyPreviewInput } from '../types/policy'; import type { ActivityApiClient } from '../api/client'; import type { K8sEvent } from '../types/k8s-event'; import type { PolicyPreviewStatus } from '../types/policy'; -import { Card, CardContent } from './ui/card'; +import { Card, CardContent } from '@datum-cloud/datum-ui/card'; import { Badge } from './ui/badge'; -import { Alert, AlertDescription } from './ui/alert'; +import { Alert, AlertDescription } from '@datum-cloud/datum-ui/alert'; import { AlertCircle, CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { AuditLogFeedItem } from './AuditLogFeedItem'; import { EventFeedItem } from './EventFeedItem'; diff --git a/ui/src/components/SampleInputTemplates.tsx b/ui/src/components/SampleInputTemplates.tsx index 694af3cf..7bbd06c7 100644 --- a/ui/src/components/SampleInputTemplates.tsx +++ b/ui/src/components/SampleInputTemplates.tsx @@ -331,7 +331,7 @@ export function SampleInputTemplates({ - ))} -
- ); -} diff --git a/ui/src/components/details.tsx b/ui/src/components/details.tsx new file mode 100644 index 00000000..ee71d550 --- /dev/null +++ b/ui/src/components/details.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; + +/** + * Small inline copy button. Fades in on `.group:hover` of the enclosing + * Field so values look uncluttered until the user is interacting with + * them. Used by all three feed detail panels. + */ +export function CopyButton({ value, label }: { value: string; label: string }) { + const [isCopied, setIsCopied] = useState(false); + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(value); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 1500); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + return ( + + + + + +

{isCopied ? 'Copied!' : `Copy ${label}`}

+
+
+ ); +} + +/** + * A label / value row inside a Section. The value is truncated to one + * line and a hover tooltip surfaces the full text — keeps long UIDs, + * resource names, etc. from blowing out the section's grid track. + * + * `value` may be any ReactNode for visible rendering; pass `copyValue` + * (the plain string form) for both the copy button and the hover tooltip. + * If `copyValue` isn't provided and `value` is a string, it doubles as + * the tooltip body. + */ +export function Field({ + label, + value, + copyValue, + copyLabel, + mono = false, +}: { + label: string; + value: React.ReactNode; + copyValue?: string; + copyLabel?: string; + mono?: boolean; +}) { + // Tooltip body — copyValue if given, else the value itself when string. + const tooltipBody: React.ReactNode = + copyValue ?? (typeof value === 'string' ? value : null); + + // Inner block that actually does the single-line truncation. `display: + // block` + parent overflow:hidden + width:100% gives ellipsis a definite + // width to clip against, which works regardless of flex/grid quirks + // upstream. Tooltip wraps this inner block so hover reveals the full + // value. + const inner = ( + + {value} + + ); + + return ( +
+ + {label} + +
+ {/* The flex item is the wrapper div with overflow:hidden so the + inner block-level truncate span has a definite width. */} +
+ {tooltipBody ? ( + + {inner} + + + {tooltipBody} + + + + ) : ( + inner + )} +
+ {copyValue ? ( + + + + ) : null} +
+
+ ); +} + +/** + * A grouped section in a detail panel: small uppercase title above a + * vertical stack of Fields. `min-width: 0` is enforced inline so the + * section can shrink below its content's natural width (otherwise long + * unbroken values would force the parent grid track wider). + */ +export function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +/** + * The wrapper a detail panel renders into. Sections lay out in a + * responsive grid (each section claims at least 220px and at most an + * equal share of available width). `minmax(0, 1fr)` is layered onto each + * track so long content doesn't force tracks to expand past their share. + */ +export function DetailGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** + * Standard padding/background for a detail panel row inside a Table. + */ +export function DetailPanelShell({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/ui/src/components/ui/alert.tsx b/ui/src/components/ui/alert.tsx deleted file mode 100644 index 7926798c..00000000 --- a/ui/src/components/ui/alert.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '../../lib/utils'; - -const alertVariants = cva( - 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', - { - variants: { - variant: { - default: 'bg-background text-foreground', - destructive: - 'border-red-200 bg-red-50 text-red-900 [&>svg]:text-red-500 dark:border-red-800 dark:bg-red-950/50 dark:text-red-200 dark:[&>svg]:text-red-400', - warning: - 'border-amber-200 bg-amber-50 text-amber-900 [&>svg]:text-amber-600 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200 dark:[&>svg]:text-amber-400', - success: - 'border-green-200 bg-green-50 text-green-900 [&>svg]:text-green-600 dark:border-green-800 dark:bg-green-950/50 dark:text-green-200 dark:[&>svg]:text-green-400', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
-)); -Alert.displayName = 'Alert'; - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -AlertTitle.displayName = 'AlertTitle'; - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -AlertDescription.displayName = 'AlertDescription'; - -export { Alert, AlertTitle, AlertDescription }; diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index 55fa4435..28b4cd98 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -1,40 +1,49 @@ +// Adapter: maps the legacy shadcn-style `variant` prop used by activity-ui +// call sites onto datum-ui's `type` + `theme` API. import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; +import { Badge as DUIBadge, badgeVariants as duiBadgeVariants } from '@datum-cloud/datum-ui/badge'; +import type { BadgeProps as DUIBadgeProps } from '@datum-cloud/datum-ui/badge'; -import { cn } from '../../lib/utils'; +type LegacyVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'outline' + | 'success' + | 'warning'; -const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', - { - variants: { - variant: { - default: - 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', - secondary: - 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', - destructive: - 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', - outline: 'text-foreground', - success: - 'border-transparent bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300', - warning: - 'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); +export interface BadgeProps extends Omit, 'children'> { + variant?: LegacyVariant; + children?: React.ReactNode; +} -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} +function mapVariant(variant: LegacyVariant | undefined): { + type: DUIBadgeProps['type']; + theme: DUIBadgeProps['theme']; +} { + switch (variant) { + case 'destructive': + return { type: 'danger', theme: 'solid' }; + case 'secondary': + return { type: 'secondary', theme: 'solid' }; + case 'outline': + return { type: 'muted', theme: 'outline' }; + case 'success': + return { type: 'success', theme: 'light' }; + case 'warning': + return { type: 'warning', theme: 'light' }; + case 'default': + case undefined: + default: + return { type: 'primary', theme: 'solid' }; + } +} -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ); +function Badge({ variant, ...props }: BadgeProps) { + const { type, theme } = mapVariant(variant); + return ; } +const badgeVariants: typeof duiBadgeVariants = duiBadgeVariants; + export { Badge, badgeVariants }; diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index ba6b70a5..ff54c02b 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -1,54 +1,98 @@ +// Adapter: maps the legacy shadcn-style `variant`/`size` props used by +// activity-ui call sites onto datum-ui's `type`/`theme`/`size` API. +// +// Notes on the `type` collision: +// - shadcn Button leaves `type` as the native HTML button attribute +// ("button" | "submit" | "reset"). +// - datum-ui Button repurposes `type` for the visual variant (primary, +// secondary, danger, …) and exposes `htmlType` for the native attr. +// Existing call sites pass `type="button"` (native), so this shim accepts +// the native attribute and forwards it as `htmlType` to datum-ui. import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; +import { + Button as DUIButton, + buttonVariants as duiButtonVariants, +} from '@datum-cloud/datum-ui/button'; +import type { ButtonProps as DUIButtonProps } from '@datum-cloud/datum-ui/button'; -import { cn } from '../../lib/utils'; - -const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: - 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: - 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', - secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline', - }, - size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - } -); +type LegacyVariant = + | 'default' + | 'destructive' + | 'outline' + | 'secondary' + | 'ghost' + | 'link'; +type LegacySize = 'default' | 'sm' | 'lg' | 'icon'; export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends Omit, 'type'> { + variant?: LegacyVariant; + size?: LegacySize; + /** Native HTML button type — same semantics as on a plain