feat: Distributed Operational UI (#16097)

pull/16282/head
Cyril Tovena 10 months ago committed by GitHub
parent d1e0fa7597
commit dbf2befc1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 104
      .cursor/rules/frontend.mdc
  2. 5
      .gitignore
  3. 2
      Makefile
  4. 4
      cmd/loki/Dockerfile
  5. 36
      docs/sources/shared/configuration.md
  6. 5
      go.mod
  7. 7
      go.sum
  8. 32
      pkg/analytics/handler.go
  9. 5
      pkg/analytics/reporter.go
  10. 7
      pkg/compactor/compactor.go
  11. 13
      pkg/compactor/deletion/delete_request.go
  12. 16
      pkg/compactor/deletion/request_handler.go
  13. 5
      pkg/compactor/deletion/tenant_request_handler.go
  14. 3
      pkg/compactor/deletion/tenant_request_handler_test.go
  15. 114
      pkg/compactor/ui.go
  16. 68
      pkg/dataobj/explorer/dist/assets/index-CWzBrpZu.js
  17. 1
      pkg/dataobj/explorer/dist/assets/style-Dz5w-Rts.css
  18. 16
      pkg/dataobj/explorer/dist/index.html
  19. 36
      pkg/dataobj/explorer/service.go
  20. 9
      pkg/dataobj/explorer/ui/Makefile
  21. 3166
      pkg/dataobj/explorer/ui/package-lock.json
  22. 28
      pkg/dataobj/explorer/ui/package.json
  23. 13
      pkg/dataobj/explorer/ui/src/App.tsx
  24. 86
      pkg/dataobj/explorer/ui/src/components/common/DateWithHover.tsx
  25. 19
      pkg/dataobj/explorer/ui/src/components/common/ErrorContainer.tsx
  26. 17
      pkg/dataobj/explorer/ui/src/components/common/LoadingContainer.tsx
  27. 165
      pkg/dataobj/explorer/ui/src/components/explorer/FileList.tsx
  28. 28
      pkg/dataobj/explorer/ui/src/components/file-metadata/BackToList.tsx
  29. 410
      pkg/dataobj/explorer/ui/src/components/file-metadata/FileMetadata.tsx
  30. 49
      pkg/dataobj/explorer/ui/src/components/layout/DarkModeToggle.tsx
  31. 54
      pkg/dataobj/explorer/ui/src/components/layout/Layout.tsx
  32. 45
      pkg/dataobj/explorer/ui/src/components/layout/ScrollToTopButton.tsx
  33. 31
      pkg/dataobj/explorer/ui/src/contexts/BasenameContext.tsx
  34. 57
      pkg/dataobj/explorer/ui/src/hooks/useExplorerData.ts
  35. 46
      pkg/dataobj/explorer/ui/src/hooks/useFileMetadata.ts
  36. 3
      pkg/dataobj/explorer/ui/src/index.css
  37. 55
      pkg/dataobj/explorer/ui/src/main.tsx
  38. 40
      pkg/dataobj/explorer/ui/src/pages/ExplorerPage.tsx
  39. 40
      pkg/dataobj/explorer/ui/src/pages/FileMetadataPage.tsx
  40. 12
      pkg/dataobj/explorer/ui/src/types/explorer.ts
  41. 9
      pkg/dataobj/explorer/ui/src/utils/format.ts
  42. 12
      pkg/dataobj/explorer/ui/tailwind.config.js
  43. 20
      pkg/dataobj/explorer/ui/vite.config.ts
  44. 2
      pkg/kafka/partitionring/partition_ring.go
  45. 48
      pkg/loki/loki.go
  46. 26
      pkg/loki/modules.go
  47. 243
      pkg/ui/README.md
  48. 351
      pkg/ui/cluster.go
  49. 58
      pkg/ui/config.go
  50. 146
      pkg/ui/discovery.go
  51. 14
      pkg/ui/frontend/.depcheckrc.json
  52. 34
      pkg/ui/frontend/.eslintrc.json
  53. 18
      pkg/ui/frontend/Makefile
  54. 120
      pkg/ui/frontend/README.md
  55. 21
      pkg/ui/frontend/components.json
  56. 72
      pkg/ui/frontend/dist/assets/data-viz-BuFFX-vG.js
  57. 5
      pkg/ui/frontend/dist/assets/date-utils-B6syNIuD.js
  58. 1
      pkg/ui/frontend/dist/assets/form-libs-B6JBoFJD.js
  59. 63
      pkg/ui/frontend/dist/assets/index-DqJzRHuy.js
  60. 1
      pkg/ui/frontend/dist/assets/query-management-DbWM5GrR.js
  61. 49
      pkg/ui/frontend/dist/assets/radix-core-ByqQ8fsu.js
  62. 1
      pkg/ui/frontend/dist/assets/radix-inputs-D4_OLmm6.js
  63. 5
      pkg/ui/frontend/dist/assets/radix-layout-BqTpm3s4.js
  64. 1
      pkg/ui/frontend/dist/assets/radix-navigation-DYoR-lWZ.js
  65. 32
      pkg/ui/frontend/dist/assets/react-core-D_V7s-9r.js
  66. 29
      pkg/ui/frontend/dist/assets/react-router-Bj-soKrx.js
  67. 1
      pkg/ui/frontend/dist/assets/style-De_mcyPH.css
  68. 1
      pkg/ui/frontend/dist/assets/theme-utils-CNom64Sw.js
  69. 191
      pkg/ui/frontend/dist/assets/ui-icons-CFVjIJRk.js
  70. 1
      pkg/ui/frontend/dist/assets/ui-utils-BNSC_Jv-.js
  71. 29
      pkg/ui/frontend/dist/index.html
  72. 2
      pkg/ui/frontend/index.html
  73. 6954
      pkg/ui/frontend/package-lock.json
  74. 74
      pkg/ui/frontend/package.json
  75. 2
      pkg/ui/frontend/postcss.config.js
  76. 28
      pkg/ui/frontend/src/App.tsx
  77. 0
      pkg/ui/frontend/src/components/common/compression-ratio.tsx
  78. 43
      pkg/ui/frontend/src/components/common/copy-button.tsx
  79. 83
      pkg/ui/frontend/src/components/common/data-table-column-header.tsx
  80. 47
      pkg/ui/frontend/src/components/common/date-hover.tsx
  81. 1
      pkg/ui/frontend/src/components/common/index.ts
  82. 110
      pkg/ui/frontend/src/components/common/multi-select.tsx
  83. 66
      pkg/ui/frontend/src/components/common/refresh-loop.tsx
  84. 111
      pkg/ui/frontend/src/components/explorer/breadcrumb.tsx
  85. 144
      pkg/ui/frontend/src/components/explorer/file-list.tsx
  86. 549
      pkg/ui/frontend/src/components/explorer/file-metadata.tsx
  87. 5
      pkg/ui/frontend/src/components/index.ts
  88. 83
      pkg/ui/frontend/src/components/nodes/data-table-column-header.tsx
  89. 12
      pkg/ui/frontend/src/components/nodes/index.ts
  90. 88
      pkg/ui/frontend/src/components/nodes/log-level-select.tsx
  91. 92
      pkg/ui/frontend/src/components/nodes/node-filters.tsx
  92. 193
      pkg/ui/frontend/src/components/nodes/node-list.tsx
  93. 79
      pkg/ui/frontend/src/components/nodes/node-status-indicator.tsx
  94. 124
      pkg/ui/frontend/src/components/nodes/pprof-controls.tsx
  95. 41
      pkg/ui/frontend/src/components/nodes/readiness-indicator.tsx
  96. 109
      pkg/ui/frontend/src/components/nodes/service-state-distribution.tsx
  97. 64
      pkg/ui/frontend/src/components/nodes/service-table.tsx
  98. 109
      pkg/ui/frontend/src/components/nodes/status-badge.tsx
  99. 73
      pkg/ui/frontend/src/components/nodes/storage-type-indicator.tsx
  100. 90
      pkg/ui/frontend/src/components/nodes/target-distribution-chart.tsx
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,104 @@
---
description: building frontend code in react and typescript
globs: *.tsx,*.ts,*.css
---
You are an expert in TypeScript, Node.js, react-dom router, React, Shadcn UI, Radix UI and Tailwind.
# Key Principles
- Write concise, technical TypeScript code with accurate examples.
- Use functional and declarative programming patterns; avoid classes.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
- Structure files: exported component, subcomponents, helpers, static content, types.
# Component Composition
Break down complex UIs into smaller, more manageable parts, which can be easily reused across different parts of the application.
- Reusability: Components can be easily reused across different parts of the application, making it easier to maintain and update the UI.
- Modularity: Breaking the UI down into smaller, more manageable components makes it easier to understand and work with, particularly for larger and more complex applications.
- Separation of Concerns: By separating the UI into smaller components, each component can focus on its own specific functionality, making it easier to test and debug.
- Code Maintainability: Using smaller components that are easy to understand and maintain makes it easier to make changes and update the application over time.
Avoid large components with nested rendering functions
# State
- useState - for simpler states that are independent
- useReducer - for more complex states where on a single action you want to update several pieces of state
- context + hooks = good state management don't use other library
- prefix -context and -provider for context and respective provider.
# Naming Conventions
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
- Favor named exports for components.
# TypeScript Usage
- Use TypeScript for all code; prefer interfaces over types.
- Avoid enums; use maps instead.
- Use functional components with TypeScript interfaces.
# Syntax and Formatting
- Use the "function" keyword for pure functions.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use declarative JSX.
- Use arrow functions for improving code readability and reducing verbosity, especially for smaller functions like event handlers or callback functions.
- Avoid using inline styles
## Project Structure
Colocate things as close as possible to where it's being used
```
src/
├── components/ # React components
│ ├── ui/ # Shadcn UI components
│ │ ├── errors/ # Error handling components
│ │ └── breadcrumbs/ # Navigation breadcrumbs
│ ├── shared/ # Shared components used across pages
│ │ └── {pagename}/ # Page-specific components
│ ├── common/ # Truly reusable components
│ └── features/ # Complex feature modules
│ └── theme/ # Theme-related components and logic
├── pages/ # Page components and routes
├── layout/ # Layout components
├── hooks/ # Custom React hooks
├── contexts/ # React context providers
├── lib/ # Utility functions and constants
└── types/ # TypeScript type definitions
```
DON'T modify shadcn component directly they are stored in src/components/ui/*
# UI and Styling
- Use Shadcn UI, Radix, and Tailwind for components and styling.
- Implement responsive design with Tailwind CSS.
# Performance Optimization
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC).
- Wrap client components in Suspense with fallback.
- Use dynamic loading for non-critical components.
- Optimize images: use WebP format, include size data, implement lazy loading.
# Key Conventions
- Use 'nuqs' for URL search parameter state management.
- Optimize Web Vitals (LCP, CLS, FID).
- Limit 'use client':
# Unit Tests
Unit testing is done to test individual components of the React application involving testing the functionality of each component in isolation to ensure that it works as intended.
- Use Jest for unit testing.
- Write unit tests for each component.
- Use React Testing Library for testing components.
- Use React Testing Library for testing components.

5
.gitignore vendored

@ -29,6 +29,7 @@ dlv
rootfs/
dist
!pkg/dataobj/explorer/dist
!pkg/ui/frontend/dist
*coverage.txt
*test_results.txt
.DS_Store
@ -37,8 +38,8 @@ dist
pkg/loki/wal
tools/lambda-promtail/main
tools/dev/kafka/data/
pkg/dataobj/explorer/ui/node_modules/*
pkg/dataobj/explorer/ui/.vite/*
pkg/ui/frontend/node_modules/*
pkg/ui/frontend/.vite/*
# Submodule added by `act` CLI
_shared-workflows-dockerhub-login

@ -199,7 +199,7 @@ cmd/loki/loki-debug:
CGO_ENABLED=0 go build $(DEBUG_GO_FLAGS) -o $@ ./$(@D)
ui-assets:
make -C pkg/dataobj/explorer/ui build
make -C pkg/ui/frontend build
###############
# Loki-Canary #
###############

@ -5,13 +5,13 @@ FROM node:22-alpine AS ui-builder
RUN apk add --no-cache make
COPY . /src/loki
WORKDIR /src/loki
RUN make -C pkg/dataobj/explorer/ui build
RUN make -C pkg/ui/frontend build
# Go build stage
FROM golang:${GO_VERSION} AS build
ARG IMAGE_TAG
COPY . /src/loki
COPY --from=ui-builder /src/loki/pkg/dataobj/explorer/dist /src/loki/pkg/dataobj/explorer/dist
COPY --from=ui-builder /src/loki/pkg/ui/frontend/dist /src/loki/pkg/ui/frontend/dist
WORKDIR /src/loki
RUN make clean && make BUILD_IN_CONTAINER=false IMAGE_TAG=${IMAGE_TAG} loki

@ -109,6 +109,42 @@ Pass the `-config.expand-env` flag at the command line to enable this way of set
# Configures the server of the launched module(s).
[server: <server>]
ui:
# Name to use for this node in the cluster.
# CLI flag: -ui.node-name
[node_name: <string> | default = "<hostname>"]
# IP address to advertise in the cluster.
# CLI flag: -ui.advertise-addr
[advertise_addr: <string> | default = ""]
# Name of network interface to read address from.
# CLI flag: -ui.interface
[interface_names: <list of strings> | default = [<private network interfaces>]]
# How frequently to rejoin the cluster to address split brain issues.
# CLI flag: -ui.rejoin-interval
[rejoin_interval: <duration> | default = 15s]
# Number of initial peers to join from the discovered set.
# CLI flag: -ui.cluster-max-join-peers
[cluster_max_join_peers: <int> | default = 3]
# Name to prevent nodes without this identifier from joining the cluster.
# CLI flag: -ui.cluster-name
[cluster_name: <string> | default = ""]
# Enable using a IPv6 instance address.
# CLI flag: -ui.enable-ipv6
[enable_ipv6: <boolean> | default = false]
discovery:
# List of peers to join the cluster. Supports multiple values separated by
# commas. Each value can be a hostname, an IP address, or a DNS name (A/AAAA
# and SRV records).
# CLI flag: -ui.discovery.join-peers
[join_peers: <list of strings> | default = []]
# Configures the distributor.
[distributor: <distributor>]

@ -50,6 +50,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/grafana/ckit v0.0.0-20250109002736-4ca45886e452
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2
github.com/grafana/dskit v0.0.0-20241007172036-53283a0f6b41
github.com/grafana/go-gelf/v2 v2.0.1
@ -299,13 +300,13 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-msgpack v1.1.5 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/memberlist v0.5.1 // indirect
github.com/hashicorp/memberlist v0.5.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect

@ -614,6 +614,8 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/ckit v0.0.0-20250109002736-4ca45886e452 h1:d/pdVKdLSNUfHUlWsN39OqUI94XgWKOGJdi568yOXmc=
github.com/grafana/ckit v0.0.0-20250109002736-4ca45886e452/go.mod h1:x6HpYv0+NXPJRBbDYA40IcxWHvrrKwgrMe1Mue172wE=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
github.com/grafana/dskit v0.0.0-20241007172036-53283a0f6b41 h1:a4O59OU3FJZ+EJUVnlvvNTvdAc4uRN1P6EaGwqL9CnA=
@ -670,8 +672,8 @@ github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjh
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs=
github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4=
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
@ -1497,7 +1499,6 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=

@ -0,0 +1,32 @@
package analytics
import (
"encoding/json"
"net/http"
"sync"
"time"
)
var (
seed = &ClusterSeed{}
rw sync.RWMutex
)
func setSeed(s *ClusterSeed) {
rw.Lock()
defer rw.Unlock()
seed = s
}
func Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
rw.RLock()
defer rw.RUnlock()
report := buildReport(seed, time.Now())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(report); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

@ -295,6 +295,7 @@ func (rep *Reporter) running(ctx context.Context) error {
}
return nil
}
setSeed(rep.cluster)
rep.startCPUPercentCollection(ctx, time.Minute)
// check every minute if we should report.
ticker := time.NewTicker(reportCheckInterval)
@ -352,9 +353,7 @@ func (rep *Reporter) reportUsage(ctx context.Context, interval time.Time) error
const cpuUsageKey = "cpu_usage"
var (
cpuUsage = NewFloat(cpuUsageKey)
)
var cpuUsage = NewFloat(cpuUsageKey)
func (rep *Reporter) startCPUPercentCollection(ctx context.Context, cpuCollectionInterval time.Duration) {
proc, err := process.NewProcess(int32(os.Getpid()))

@ -4,7 +4,6 @@ import (
"context"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
@ -178,6 +177,7 @@ type Compactor struct {
indexCompactors map[string]IndexCompactor
schemaConfig config.SchemaConfig
tableLocker *tableLocker
limits Limits
// Ring used for running a single compactor
ringLifecycler *ring.BasicLifecycler
@ -219,6 +219,7 @@ func NewCompactor(cfg Config, objectStoreClients map[config.DayTime]client.Objec
indexCompactors: map[string]IndexCompactor{},
schemaConfig: schemaConfig,
tableLocker: newTableLocker(),
limits: limits,
}
ringStore, err := kv.NewClient(
@ -882,10 +883,6 @@ func (c *Compactor) OnRingInstanceStopping(_ *ring.BasicLifecycler)
func (c *Compactor) OnRingInstanceHeartbeat(_ *ring.BasicLifecycler, _ *ring.Desc, _ *ring.InstanceDesc) {
}
func (c *Compactor) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c.ring.ServeHTTP(w, req)
}
func SortTablesByRange(tables []string) {
tableRanges := make(map[string]model.Interval)
for _, table := range tables {

@ -1,6 +1,7 @@
package deletion
import (
"strings"
"time"
"github.com/go-kit/log/level"
@ -195,3 +196,15 @@ func intervalsOverlap(interval1, interval2 model.Interval) bool {
return true
}
// GetMatchers returns the string representation of the matchers
func (d *DeleteRequest) GetMatchers() string {
if len(d.matchers) == 0 {
return ""
}
var result []string
for _, m := range d.matchers {
result = append(result, m.String())
}
return strings.Join(result, ",")
}

@ -45,6 +45,10 @@ func NewDeleteRequestHandler(deleteStore DeleteRequestsStore, maxInterval, delet
// AddDeleteRequestHandler handles addition of a new delete request
func (dm *DeleteRequestHandler) AddDeleteRequestHandler(w http.ResponseWriter, r *http.Request) {
if dm == nil {
http.Error(w, "Retention is not enabled", http.StatusBadRequest)
return
}
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {
@ -125,6 +129,10 @@ func (dm *DeleteRequestHandler) interval(params url.Values, startTime, endTime m
// GetAllDeleteRequestsHandler handles get all delete requests
func (dm *DeleteRequestHandler) GetAllDeleteRequestsHandler(w http.ResponseWriter, r *http.Request) {
if dm == nil {
http.Error(w, "Retention is not enabled", http.StatusBadRequest)
return
}
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {
@ -219,6 +227,10 @@ func deleteRequestStatus(processed, total int) DeleteRequestStatus {
// CancelDeleteRequestHandler handles delete request cancellation
func (dm *DeleteRequestHandler) CancelDeleteRequestHandler(w http.ResponseWriter, r *http.Request) {
if dm == nil {
http.Error(w, "Retention is not enabled", http.StatusBadRequest)
return
}
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {
@ -271,6 +283,10 @@ func filterProcessed(reqs []DeleteRequest) []DeleteRequest {
// GetCacheGenerationNumberHandler handles requests for a user's cache generation number
func (dm *DeleteRequestHandler) GetCacheGenerationNumberHandler(w http.ResponseWriter, r *http.Request) {
if dm == nil {
http.Error(w, "Retention is not enabled", http.StatusBadRequest)
return
}
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {

@ -3,10 +3,12 @@ package deletion
import (
"net/http"
"github.com/grafana/dskit/middleware"
"github.com/grafana/dskit/tenant"
)
func TenantMiddleware(limits Limits, next http.Handler) http.Handler {
func TenantMiddleware(limits Limits) middleware.Interface {
return middleware.Func(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
@ -28,4 +30,5 @@ func TenantMiddleware(limits Limits, next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
})
}

@ -23,8 +23,7 @@ func TestDeleteRequestHandlerDeletionMiddleware(t *testing.T) {
}
// Setup handler
middle := TenantMiddleware(fl, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
middle := TenantMiddleware(fl).Wrap(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
// User that has deletion enabled
req := httptest.NewRequest(http.MethodGet, "http://www.your-domain.com", nil)
req = req.WithContext(user.InjectOrgID(req.Context(), "1"))

@ -0,0 +1,114 @@
package compactor
import (
"encoding/json"
"net/http"
"sort"
"github.com/grafana/dskit/middleware"
"github.com/grafana/loki/v3/pkg/compactor/deletion"
)
func (c *Compactor) Handler() (string, http.Handler) {
mux := http.NewServeMux()
mw := middleware.Merge(
middleware.AuthenticateUser,
deletion.TenantMiddleware(c.limits),
// Automatically parse form data
middleware.Func(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}),
)
// API endpoints
mux.Handle("/compactor/ring", c.ring)
// Custom UI endpoints for the compactor
mux.HandleFunc("/compactor/ui/api/v1/deletes", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
c.handleListDeleteRequests(w, r)
case http.MethodPost:
mw.Wrap(http.HandlerFunc(c.DeleteRequestsHandler.AddDeleteRequestHandler)).ServeHTTP(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
return "/compactor", mux
}
type DeleteRequestResponse struct {
RequestID string `json:"request_id"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
Query string `json:"query"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UserID string `json:"user_id"`
DeletedLines int32 `json:"deleted_lines"`
}
func (c *Compactor) handleListDeleteRequests(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
if status == "" {
status = string(deletion.StatusReceived)
}
ctx := r.Context()
if c.deleteRequestsStore == nil {
http.Error(w, "Retention is not enabled", http.StatusBadRequest)
return
}
requests, err := c.deleteRequestsStore.GetAllRequests(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Filter requests by status
filtered := requests[:0]
for _, req := range requests {
if req.Status == deletion.DeleteRequestStatus(status) {
filtered = append(filtered, req)
}
}
requests = filtered
// Sort by creation time descending
sort.Slice(requests, func(i, j int) bool {
return requests[i].CreatedAt > requests[j].CreatedAt
})
// Take only last 100
if len(requests) > 100 {
requests = requests[:100]
}
response := make([]DeleteRequestResponse, 0, len(requests))
for _, req := range requests {
response = append(response, DeleteRequestResponse{
RequestID: req.RequestID,
StartTime: int64(req.StartTime),
EndTime: int64(req.EndTime),
Query: req.Query,
Status: string(req.Status),
CreatedAt: int64(req.CreatedAt),
UserID: req.UserID,
DeletedLines: req.DeletedLines,
})
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DataObj Explorer</title>
<script type="module" crossorigin src="/dataobj/explorer/assets/index-CWzBrpZu.js"></script>
<link rel="stylesheet" crossorigin href="/dataobj/explorer/assets/style-Dz5w-Rts.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

@ -2,11 +2,8 @@ package explorer
import (
"context"
"embed"
"encoding/json"
"io/fs"
"net/http"
"strings"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
@ -14,30 +11,17 @@ import (
"github.com/thanos-io/objstore"
)
//go:embed dist
var uiFS embed.FS
type Service struct {
*services.BasicService
bucket objstore.Bucket
logger log.Logger
uiFS fs.FS
}
func New(bucket objstore.Bucket, logger log.Logger) (*Service, error) {
var ui fs.FS
var err error
ui, err = fs.Sub(uiFS, "dist")
if err != nil {
return nil, err
}
s := &Service{
bucket: bucket,
logger: logger,
uiFS: ui,
}
s.BasicService = services.NewBasicService(nil, s.running, nil)
@ -50,24 +34,16 @@ func (s *Service) running(ctx context.Context) error {
return nil
}
func (s *Service) Handler() http.Handler {
func (s *Service) Handler() (string, http.Handler) {
mux := http.NewServeMux()
// API endpoints
mux.HandleFunc("/dataobj/explorer/api/list", s.handleList)
mux.HandleFunc("/dataobj/explorer/api/inspect", s.handleInspect)
mux.HandleFunc("/dataobj/explorer/api/download", s.handleDownload)
mux.HandleFunc("/dataobj/explorer/api/provider", s.handleProvider)
fsHandler := http.FileServer(http.FS(s.uiFS))
mux.Handle("/dataobj/explorer/", http.StripPrefix("/dataobj/explorer/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := s.uiFS.Open(strings.TrimPrefix(r.URL.Path, "/")); err != nil {
r.URL.Path = "/"
}
fsHandler.ServeHTTP(w, r)
})))
mux.HandleFunc("/dataobj/api/v1/list", s.handleList)
mux.HandleFunc("/dataobj/api/v1/inspect", s.handleInspect)
mux.HandleFunc("/dataobj/api/v1/download", s.handleDownload)
mux.HandleFunc("/dataobj/api/v1/provider", s.handleProvider)
return mux
return "/dataobj", mux
}
func (s *Service) handleProvider(w http.ResponseWriter, r *http.Request) {

@ -1,9 +0,0 @@
.PHONY: build
build:
npm install
npm run build
.PHONY: dev
dev:
npm install
npm run dev

File diff suppressed because it is too large Load Diff

@ -1,28 +0,0 @@
{
"name": "dataobj-explorer",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"date-fns": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.2.2",
"vite": "^6.0.0"
}
}

@ -1,13 +0,0 @@
import React from "react";
import { Routes, Route } from "react-router-dom";
import { FileMetadataPage } from "./pages/FileMetadataPage";
import { ExplorerPage } from "./pages/ExplorerPage";
export default function App() {
return (
<Routes>
<Route path="file/:filePath" element={<FileMetadataPage />} />
<Route path="*" element={<ExplorerPage />} />
</Routes>
);
}

@ -1,86 +0,0 @@
import React from "react";
import { formatDistanceToNow, format } from "date-fns";
import { createPortal } from "react-dom";
interface DateWithHoverProps {
date: Date;
className?: string;
}
export const DateWithHover: React.FC<DateWithHoverProps> = ({
date,
className = "",
}) => {
const [isHovered, setIsHovered] = React.useState(false);
const relativeTime = formatDistanceToNow(date, { addSuffix: true });
const localTime = format(date, "yyyy-MM-dd HH:mm:ss");
const utcTime = format(
new Date(date.getTime() + date.getTimezoneOffset() * 60000),
"yyyy-MM-dd HH:mm:ss"
);
const [position, setPosition] = React.useState({ top: 0, left: 0 });
const triggerRef = React.useRef<HTMLDivElement>(null);
const updatePosition = React.useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.top + window.scrollY - 70, // Position above the element
left: rect.left + window.scrollX,
});
}
}, []);
React.useEffect(() => {
if (isHovered) {
updatePosition();
window.addEventListener("scroll", updatePosition);
window.addEventListener("resize", updatePosition);
}
return () => {
window.removeEventListener("scroll", updatePosition);
window.removeEventListener("resize", updatePosition);
};
}, [isHovered, updatePosition]);
return (
<>
<div
ref={triggerRef}
className={`inline-block ${className}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{relativeTime}
</div>
{isHovered &&
createPortal(
<div
style={{
position: "absolute",
top: `${position.top}px`,
left: `${position.left}px`,
}}
className="z-[9999] min-w-[280px] text-sm text-gray-500 bg-white border border-gray-200 rounded-lg shadow-sm dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800"
>
<div className="px-3 py-2 space-y-2">
<div className="flex items-center gap-3">
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 rounded dark:bg-gray-700 w-14 text-center">
UTC
</span>
<span className="font-mono">{utcTime}</span>
</div>
<div className="flex items-center gap-3">
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 rounded dark:bg-gray-700 w-14 text-center">
Local
</span>
<span className="font-mono">{localTime}</span>
</div>
</div>
</div>,
document.body
)}
</>
);
};

@ -1,19 +0,0 @@
import React from "react";
interface ErrorContainerProps {
message: string;
fullScreen?: boolean;
}
export const ErrorContainer: React.FC<ErrorContainerProps> = ({
message,
fullScreen = false,
}) => (
<div
className={`flex items-center justify-center ${
fullScreen ? "min-h-screen" : ""
}`}
>
<div className="text-red-500 p-4">Error: {message}</div>
</div>
);

@ -1,17 +0,0 @@
import React from "react";
interface LoadingContainerProps {
fullScreen?: boolean;
}
export const LoadingContainer: React.FC<LoadingContainerProps> = ({
fullScreen = false,
}) => (
<div
className={`flex items-center justify-center ${
fullScreen ? "min-h-screen" : "min-h-[200px]"
} dark:bg-gray-900`}
>
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 dark:border-blue-400" />
</div>
);

@ -1,165 +0,0 @@
import React from "react";
import { Link } from "react-router-dom";
import { DateWithHover } from "../common/DateWithHover";
interface FileInfo {
name: string;
size: number;
lastModified: string;
}
interface FileListProps {
current: string;
parent: string;
files: FileInfo[];
folders: string[];
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export const FileList: React.FC<FileListProps> = ({
current,
parent,
files,
folders,
}) => {
return (
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg overflow-hidden">
<div className="grid grid-cols-12 bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600">
<div className="col-span-5 p-4 font-semibold text-gray-600 dark:text-gray-200">
Name
</div>
<div className="col-span-3 p-4 font-semibold text-gray-600 dark:text-gray-200">
Last Modified
</div>
<div className="col-span-3 p-4 font-semibold text-gray-600 dark:text-gray-200">
Size
</div>
<div className="col-span-1 p-4"></div>
</div>
{parent !== current && (
<Link
to={`/?path=${encodeURIComponent(parent)}`}
className="grid grid-cols-12 border-b dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-gray-200"
>
<div className="col-span-5 p-4 flex items-center">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
/>
</svg>
..
</div>
<div className="col-span-3 p-4">-</div>
<div className="col-span-3 p-4">-</div>
<div className="col-span-1 p-4"></div>
</Link>
)}
{folders.map((folder) => (
<Link
key={folder}
to={`/?path=${encodeURIComponent(
current ? `${current}/${folder}` : folder
)}`}
className="grid grid-cols-12 border-b dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-gray-200"
>
<div className="col-span-5 p-4 flex items-center">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
{folder}
</div>
<div className="col-span-3 p-4">-</div>
<div className="col-span-3 p-4">-</div>
<div className="col-span-1 p-4"></div>
</Link>
))}
<div className="space-y-2">
{files.map((file) => {
const filePath = current ? `${current}/${file.name}` : file.name;
return (
<div
key={file.name}
className="grid grid-cols-12 border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-gray-200"
>
<Link
to={`file/${encodeURIComponent(filePath)}`}
className="col-span-5 p-4 flex items-center"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
{file.name}
</Link>
<div className="col-span-3 p-4">
<DateWithHover date={new Date(file.lastModified)} />
</div>
<div className="col-span-3 p-4">{formatBytes(file.size)}</div>
<div className="col-span-1 p-4 flex justify-center">
<Link
to={`/api/download?file=${encodeURIComponent(filePath)}`}
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Download file"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</Link>
</div>
</div>
);
})}
</div>
</div>
);
};

@ -1,28 +0,0 @@
import React from "react";
import { Link } from "react-router-dom";
export const BackToListButton: React.FC<{ filePath: string }> = ({
filePath,
}) => (
<Link
to={`/?path=${encodeURIComponent(
filePath ? filePath.split("/").slice(0, -1).join("/") : ""
)}`}
className="mb-4 p-4 inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
/>
</svg>
Back to file list
</Link>
);

@ -1,410 +0,0 @@
import React, { useState } from "react";
import { formatBytes } from "../../utils/format";
import { DateWithHover } from "../common/DateWithHover";
import { Link } from "react-router-dom";
import { useBasename } from "../../contexts/BasenameContext";
import { CompressionRatio } from "./CompressionRatio";
import { FileMetadataResponse } from "../../types/metadata";
interface FileMetadataProps {
metadata: FileMetadataResponse;
filename: string;
className?: string;
}
export const FileMetadata: React.FC<FileMetadataProps> = ({
metadata,
filename,
className = "",
}) => {
const [expandedSectionIndex, setExpandedSectionIndex] = useState<
number | null
>(null);
const [expandedColumns, setExpandedColumns] = useState<
Record<string, boolean>
>({});
if (metadata.error) {
return (
<div className="p-4 bg-red-100 border border-red-400 text-red-700 rounded">
Error: {metadata.error}
</div>
);
}
const toggleSection = (sectionIndex: number) => {
setExpandedSectionIndex(
expandedSectionIndex === sectionIndex ? null : sectionIndex
);
};
const toggleColumn = (sectionIndex: number, columnIndex: number) => {
const key = `${sectionIndex}-${columnIndex}`;
setExpandedColumns((prev) => ({
...prev,
[key]: !prev[key],
}));
};
// Calculate file-level stats
const totalCompressed = metadata.sections.reduce(
(sum, section) => sum + section.totalCompressedSize,
0
);
const totalUncompressed = metadata.sections.reduce(
(sum, section) => sum + section.totalUncompressedSize,
0
);
// Get stream and log counts from first column of each section
const streamSection = metadata.sections.filter(
(s) => s.type === "SECTION_TYPE_STREAMS"
);
const logSection = metadata.sections.filter(
(s) => s.type === "SECTION_TYPE_LOGS"
);
const streamCount = streamSection?.reduce(
(sum, sec) => sum + (sec.columns[0].rows_count || 0),
0
);
const logCount = logSection?.reduce(
(sum, sec) => sum + (sec.columns[0].rows_count || 0),
0
);
const basename = useBasename();
return (
<div className={`space-y-6 p-4 ${className}`}>
{/* Thor Dataobj File */}
<div className="bg-white dark:bg-gray-700 shadow rounded-lg">
{/* Overview */}
<div className="p-4 border-b dark:border-gray-700">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-lg font-semibold mb-2 dark:text-gray-200">
Thor Dataobj File
</h2>
<div className="flex flex-col gap-1">
<p className="text-sm font-mono dark:text-gray-300">
{filename}
</p>
{metadata.lastModified && (
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span>Last modified:</span>
<DateWithHover date={new Date(metadata.lastModified)} />
</div>
)}
</div>
</div>
<Link
to={`/api/download?file=${encodeURIComponent(filename)}`}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
>
Download
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Compression
</div>
<CompressionRatio
compressed={totalCompressed}
uncompressed={totalUncompressed}
showVisualization
/>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatBytes(totalCompressed)} {" "}
{formatBytes(totalUncompressed)}
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Sections
</div>
<div className="font-medium">{metadata.sections.length}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{metadata.sections.map((s) => s.type).join(", ")}
</div>
</div>
{streamCount && (
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Stream Count
</div>
<div className="font-medium">
{streamCount.toLocaleString()}
</div>
</div>
)}
{logCount && (
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Log Count
</div>
<div className="font-medium">{logCount.toLocaleString()}</div>
</div>
)}
</div>
</div>
{/* Sections */}
<div className="divide-y dark:divide-gray-900">
{metadata.sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="dark:bg-gray-700">
{/* Section Header */}
<div
className="p-4 cursor-pointer flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => toggleSection(sectionIndex)}
>
<h3 className="text-lg font-semibold dark:text-gray-200">
Section #{sectionIndex + 1}: {section.type}
</h3>
<svg
className={`w-5 h-5 transform transition-transform duration-700 ${
expandedSectionIndex === sectionIndex ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{/* Section Content */}
<div
className={`transition-all duration-700 ease-in-out ${
expandedSectionIndex === sectionIndex
? "opacity-100"
: "opacity-0 hidden"
}`}
>
<div className="p-4 bg-gray-50 dark:bg-gray-800">
{/* Section Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white dark:bg-gray-700 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Compression
</div>
<CompressionRatio
compressed={section.totalCompressedSize}
uncompressed={section.totalUncompressedSize}
showVisualization
/>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatBytes(section.totalCompressedSize)} {" "}
{formatBytes(section.totalUncompressedSize)}
</div>
</div>
<div className="bg-white dark:bg-gray-700 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Column Count
</div>
<div className="font-medium">{section.columnCount}</div>
</div>
<div className="bg-white dark:bg-gray-700 p-3 rounded">
<div className="text-sm text-gray-500 dark:text-gray-400">
Type
</div>
<div className="font-medium">{section.type}</div>
</div>
</div>
{/* Columns */}
<div className="space-y-4">
<h4 className="font-medium text-lg mb-4 dark:text-gray-200">
Columns ({section.columnCount})
</h4>
{section.columns.map((column, columnIndex) => (
<div
key={columnIndex}
className="bg-white dark:bg-gray-700 shadow rounded-lg overflow-hidden"
>
{/* Column Header */}
<div
className="flex justify-between items-center cursor-pointer p-4 border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600"
onClick={() =>
toggleColumn(sectionIndex, columnIndex)
}
>
<div>
<h5 className="font-medium text-gray-900 dark:text-gray-200">
{column.name
? `${column.name} (${column.type})`
: column.type}
</h5>
<div className="text-sm text-gray-500 dark:text-gray-400">
Type: {column.value_type}
</div>
</div>
<div className="flex items-center">
<div className="text-sm font-medium text-gray-600 dark:text-gray-300 mr-4">
Compression: {column.compression}
</div>
<svg
className={`w-4 h-4 transform transition-transform text-gray-400 ${
expandedColumns[
`${sectionIndex}-${columnIndex}`
]
? "rotate-180"
: ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{/* Column Content */}
{expandedColumns[`${sectionIndex}-${columnIndex}`] && (
<div className="p-4 bg-white dark:bg-gray-700">
{/* Column Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 dark:bg-gray-600 p-3 rounded-lg">
<div className="text-gray-500 dark:text-gray-400 mb-1">
Compression ({column.compression})
</div>
<div className="font-medium whitespace-nowrap">
<CompressionRatio
compressed={column.compressed_size}
uncompressed={column.uncompressed_size}
/>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatBytes(column.compressed_size)} {" "}
{formatBytes(column.uncompressed_size)}
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-600 p-3 rounded-lg">
<div className="text-gray-500 dark:text-gray-400 mb-1">
Rows
</div>
<div className="font-medium">
{column.rows_count.toLocaleString()}
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-600 p-3 rounded-lg">
<div className="text-gray-500 dark:text-gray-400 mb-1">
Values Count
</div>
<div className="font-medium">
{column.values_count.toLocaleString()}
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-600 p-3 rounded-lg">
<div className="text-gray-500 dark:text-gray-400 mb-1">
Offset
</div>
<div className="font-medium">
{formatBytes(column.metadata_offset)}
</div>
</div>
</div>
{/* Pages */}
{column.pages.length > 0 && (
<div className="mt-6">
<h6 className="font-medium text-sm mb-3 dark:text-gray-200">
Pages ({column.pages.length})
</h6>
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-600">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-600 border-b border-gray-200 dark:border-gray-500">
<th className="text-left p-3 font-medium text-gray-600 dark:text-gray-200">
#
</th>
<th className="text-left p-3 font-medium text-gray-600 dark:text-gray-200">
Rows
</th>
<th className="text-left p-3 font-medium text-gray-600 dark:text-gray-200">
Values
</th>
<th className="text-left p-3 font-medium text-gray-600 dark:text-gray-200">
Encoding
</th>
<th className="text-left p-3 font-medium text-gray-600 dark:text-gray-200">
Compression
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-700">
{column.pages.map((page, pageIndex) => (
<tr
key={pageIndex}
className="border-t border-gray-100 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600"
>
<td className="p-3 dark:text-gray-200">
{pageIndex + 1}
</td>
<td className="p-3 dark:text-gray-200">
{page.rows_count.toLocaleString()}
</td>
<td className="p-3 dark:text-gray-200">
{page.values_count.toLocaleString()}
</td>
<td className="p-3 dark:text-gray-200">
{page.encoding}
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<CompressionRatio
compressed={
page.compressed_size
}
uncompressed={
page.uncompressed_size
}
/>
<span className="text-xs text-gray-500 dark:text-gray-400">
(
{formatBytes(
page.compressed_size
)}{" "}
{" "}
{formatBytes(
page.uncompressed_size
)}
)
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

@ -1,49 +0,0 @@
import React from "react";
interface DarkModeToggleProps {
isDarkMode: boolean;
onToggle: () => void;
}
export const DarkModeToggle: React.FC<DarkModeToggleProps> = ({
isDarkMode,
onToggle,
}) => {
return (
<button
onClick={onToggle}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
aria-label={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{isDarkMode ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
</button>
);
};

@ -1,54 +0,0 @@
import React, { useEffect, useState } from "react";
import { DarkModeToggle } from "./DarkModeToggle";
import { Breadcrumb } from "./Breadcrumb";
import { ScrollToTopButton } from "./ScrollToTopButton";
interface LayoutProps {
children: React.ReactNode;
breadcrumbParts?: string[];
isLastBreadcrumbClickable?: boolean;
}
export const Layout: React.FC<LayoutProps> = ({
children,
breadcrumbParts = [],
isLastBreadcrumbClickable = true,
}) => {
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem("theme");
const systemPreference = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
return savedTheme ? savedTheme === "dark" : systemPreference;
});
useEffect(() => {
document.documentElement.classList.toggle("dark", isDarkMode);
localStorage.setItem("theme", isDarkMode ? "dark" : "light");
}, [isDarkMode]);
return (
<div
className={`min-h-screen ${
isDarkMode
? "dark:bg-gray-900 dark:text-gray-200"
: "bg-white text-black"
}`}
>
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<Breadcrumb
parts={breadcrumbParts}
isLastPartClickable={isLastBreadcrumbClickable}
/>
<DarkModeToggle
isDarkMode={isDarkMode}
onToggle={() => setIsDarkMode(!isDarkMode)}
/>
</div>
{children}
<ScrollToTopButton />
</div>
</div>
);
};

@ -1,45 +0,0 @@
import React, { useState, useEffect } from "react";
export const ScrollToTopButton: React.FC = () => {
const [show, setShow] = useState(false);
useEffect(() => {
const handleScroll = () => {
setShow(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
if (!show) return null;
return (
<button
onClick={scrollToTop}
className="fixed bottom-8 right-8 bg-blue-500 dark:bg-blue-600 hover:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-full p-3 shadow-lg transition-all duration-300"
aria-label="Back to top"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</button>
);
};

@ -1,31 +0,0 @@
import React, { createContext, useContext } from "react";
interface BasenameContextType {
basename: string;
}
const BasenameContext = createContext<BasenameContextType | undefined>(
undefined
);
export function useBasename() {
const context = useContext(BasenameContext);
if (context === undefined) {
throw new Error("useBasename must be used within a BasenameProvider");
}
return context.basename;
}
export function BasenameProvider({
basename,
children,
}: {
basename: string;
children: React.ReactNode;
}) {
return (
<BasenameContext.Provider value={{ basename }}>
{children}
</BasenameContext.Provider>
);
}

@ -1,57 +0,0 @@
import React, { useMemo } from "react";
import { useBasename } from "../contexts/BasenameContext";
import { ListResponse, FileInfo } from "../types/explorer";
const sortFilesByDate = (files: FileInfo[]): FileInfo[] => {
return [...files].sort(
(a, b) =>
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
);
};
interface UseExplorerDataResult {
data: ListResponse | null;
loading: boolean;
error: string | null;
}
export const useExplorerData = (path: string): UseExplorerDataResult => {
const [rawData, setRawData] = React.useState<ListResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const basename = useBasename();
// Memoize the sorted data
const data = useMemo(() => {
if (!rawData) return null;
return {
...rawData,
files: sortFilesByDate(rawData.files),
};
}, [rawData]);
React.useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(
`${basename}api/list?path=${encodeURIComponent(path)}`
);
if (!response.ok) {
throw new Error("Failed to fetch data");
}
const json = (await response.json()) as ListResponse;
setRawData(json);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
};
fetchData();
}, [path, basename]);
return { data, loading, error };
};

@ -1,46 +0,0 @@
import React from "react";
import { useBasename } from "../contexts/BasenameContext";
import { FileMetadataResponse } from "../types/metadata";
interface UseFileMetadataResult {
metadata: FileMetadataResponse | null;
loading: boolean;
error: string | null;
}
export const useFileMetadata = (
filePath: string | undefined
): UseFileMetadataResult => {
const [metadata, setMetadata] = React.useState<FileMetadataResponse | null>(
null
);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const basename = useBasename();
React.useEffect(() => {
const fetchMetadata = async () => {
if (!filePath) return;
try {
setLoading(true);
const response = await fetch(
`${basename}api/inspect?file=${encodeURIComponent(filePath)}`
);
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.statusText}`);
}
const data = await response.json();
setMetadata(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
};
fetchMetadata();
}, [filePath, basename]);
return { metadata, loading, error };
};

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@ -1,55 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
type RouterProviderProps,
} from "react-router-dom";
import App from "./App";
import { ExplorerPage } from "./pages/ExplorerPage";
import { FileMetadataPage } from "./pages/FileMetadataPage";
import { BasenameProvider } from "./contexts/BasenameContext";
import "./index.css";
// Extract basename from current URL by matching everything up to and including /dataobj/explorer
const pathname = window.location.pathname;
const match = pathname.match(/(.*\/dataobj\/explorer\/)/);
const basename = match?.[1] || "/dataobj/explorer/";
const router = createBrowserRouter(
[
{
path: "*",
element: <App />,
children: [
{
index: true,
element: <ExplorerPage />,
},
{
path: "file/:filePath",
element: <FileMetadataPage />,
},
],
},
],
{
basename,
future: {
v7_relativeSplatPath: true,
},
}
);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BasenameProvider basename={basename}>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</BasenameProvider>
</React.StrictMode>
);

@ -1,40 +0,0 @@
import React from "react";
import { useSearchParams } from "react-router-dom";
import { FileList } from "../components/explorer/FileList";
import { Layout } from "../components/layout/Layout";
import { LoadingContainer } from "../components/common/LoadingContainer";
import { ErrorContainer } from "../components/common/ErrorContainer";
import { useExplorerData } from "../hooks/useExplorerData";
export const ExplorerPage: React.FC = () => {
const [searchParams] = useSearchParams();
const path = searchParams.get("path") || "";
const { data, loading, error } = useExplorerData(path);
// Get path parts for breadcrumb
const pathParts = React.useMemo(
() => (data?.current || "").split("/").filter(Boolean),
[data?.current]
);
return (
<Layout breadcrumbParts={pathParts} isLastBreadcrumbClickable={true}>
<div className="relative" style={{ overflow: "visible" }}>
{loading ? (
<LoadingContainer fullScreen />
) : error ? (
<ErrorContainer message={error} fullScreen />
) : data ? (
<div className="relative" style={{ overflow: "visible" }}>
<FileList
current={data.current}
parent={data.parent}
files={data.files}
folders={data.folders}
/>
</div>
) : null}
</div>
</Layout>
);
};

@ -1,40 +0,0 @@
import React from "react";
import { useParams } from "react-router-dom";
import { FileMetadata } from "../components/file-metadata/FileMetadata";
import { Layout } from "../components/layout/Layout";
import { BackToListButton } from "../components/file-metadata/BackToList";
import { LoadingContainer } from "../components/common/LoadingContainer";
import { ErrorContainer } from "../components/common/ErrorContainer";
import { useFileMetadata } from "../hooks/useFileMetadata";
export const FileMetadataPage: React.FC = () => {
const { filePath } = useParams<{ filePath: string }>();
const { metadata, loading, error } = useFileMetadata(filePath);
const pathParts = React.useMemo(
() => (filePath || "").split("/").filter(Boolean),
[filePath]
);
return (
<Layout breadcrumbParts={pathParts} isLastBreadcrumbClickable={false}>
<div className="bg-gray-50 dark:bg-gray-800 shadow-md rounded-lg overflow-hidden dark:text-gray-200">
{loading ? (
<LoadingContainer />
) : error ? (
<ErrorContainer message={error} />
) : (
<>
<BackToListButton filePath={filePath || ""} />
{metadata && filePath && (
<FileMetadata
metadata={metadata}
filename={filePath}
className="dark:bg-gray-800 dark:text-gray-200"
/>
)}
</>
)}
</div>
</Layout>
);
};

@ -1,12 +0,0 @@
export interface FileInfo {
name: string;
size: number;
lastModified: string;
}
export interface ListResponse {
files: FileInfo[];
folders: string[];
parent: string;
current: string;
}

@ -1,9 +0,0 @@
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KiB", "MiB", "GiB", "TiB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}

@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

@ -1,20 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/dataobj/explorer/",
css: {
postcss: "./postcss.config.js",
},
build: {
outDir: "../dist",
emptyOutDir: true,
cssCodeSplit: false,
},
server: {
proxy: {
"/dataobj/explorer/api": "http://localhost:3100",
},
},
});

@ -54,7 +54,7 @@ func (cfg *Config) ToLifecyclerConfig(partitionID int32, instanceID string) ring
// ExtractIngesterPartitionID returns the partition ID owner the the given ingester.
func ExtractIngesterPartitionID(ingesterID string) (int32, error) {
if strings.Contains(ingesterID, "local") {
if strings.Contains(ingesterID, "local") || strings.HasSuffix(ingesterID, ".lan") {
return 0, nil
}

@ -65,6 +65,7 @@ import (
"github.com/grafana/loki/v3/pkg/storage/stores/series/index"
"github.com/grafana/loki/v3/pkg/storage/stores/shipper/bloomshipper"
"github.com/grafana/loki/v3/pkg/tracing"
"github.com/grafana/loki/v3/pkg/ui"
"github.com/grafana/loki/v3/pkg/util"
"github.com/grafana/loki/v3/pkg/util/constants"
"github.com/grafana/loki/v3/pkg/util/fakeauth"
@ -84,6 +85,7 @@ type Config struct {
Server server.Config `yaml:"server,omitempty"`
InternalServer internalserver.Config `yaml:"internal_server,omitempty" doc:"hidden"`
UI ui.Config `yaml:"ui,omitempty"`
Distributor distributor.Config `yaml:"distributor,omitempty"`
Querier querier.Config `yaml:"querier,omitempty"`
QueryScheduler scheduler.Config `yaml:"query_scheduler"`
@ -192,6 +194,7 @@ func (c *Config) RegisterFlags(f *flag.FlagSet) {
c.KafkaConfig.RegisterFlags(f)
c.BlockBuilder.RegisterFlags(f)
c.BlockScheduler.RegisterFlags(f)
c.UI.RegisterFlags(f)
c.DataObj.RegisterFlags(f)
}
@ -316,6 +319,10 @@ func (c *Config) Validate() error {
errs = append(errs, errors.Wrap(err, "CONFIG ERROR: invalid distributor config"))
}
if err := c.UI.Validate(); err != nil {
errs = append(errs, errors.Wrap(err, "CONFIG ERROR: invalid ui config"))
}
errs = append(errs, validateSchemaValues(c)...)
errs = append(errs, ValidateConfigCompatibility(*c)...)
errs = append(errs, validateBackendAndLegacyReadMode(c)...)
@ -360,6 +367,7 @@ type Loki struct {
Server *server.Server
InternalServer *server.Server
UI *ui.Service
ring *ring.Ring
Overrides limiter.CombinedLimits
tenantConfigs *runtime.TenantConfigs
@ -712,6 +720,7 @@ func (t *Loki) setupModuleManager() error {
mm.RegisterModule(BlockBuilder, t.initBlockBuilder)
mm.RegisterModule(BlockScheduler, t.initBlockScheduler)
mm.RegisterModule(DataObjExplorer, t.initDataObjExplorer)
mm.RegisterModule(UI, t.initUI)
mm.RegisterModule(DataObjConsumer, t.initDataObjConsumer)
mm.RegisterModule(All, nil)
@ -724,42 +733,43 @@ func (t *Loki) setupModuleManager() error {
Ring: {RuntimeConfig, Server, MemberlistKV},
Analytics: {},
Overrides: {RuntimeConfig},
OverridesExporter: {Overrides, Server},
OverridesExporter: {Overrides, Server, UI},
TenantConfigs: {RuntimeConfig},
Distributor: {Ring, Server, Overrides, TenantConfigs, PatternRingClient, PatternIngesterTee, Analytics, PartitionRing},
UI: {Server},
Distributor: {Ring, Server, Overrides, TenantConfigs, PatternRingClient, PatternIngesterTee, Analytics, PartitionRing, UI},
Store: {Overrides, IndexGatewayRing},
Ingester: {Store, Server, MemberlistKV, TenantConfigs, Analytics, PartitionRing},
Querier: {Store, Ring, Server, IngesterQuerier, PatternRingClient, Overrides, Analytics, CacheGenerationLoader, QuerySchedulerRing},
Ingester: {Store, Server, MemberlistKV, TenantConfigs, Analytics, PartitionRing, UI},
Querier: {Store, Ring, Server, IngesterQuerier, PatternRingClient, Overrides, Analytics, CacheGenerationLoader, QuerySchedulerRing, UI},
QueryFrontendTripperware: {Server, Overrides, TenantConfigs},
QueryFrontend: {QueryFrontendTripperware, Analytics, CacheGenerationLoader, QuerySchedulerRing},
QueryScheduler: {Server, Overrides, MemberlistKV, Analytics, QuerySchedulerRing},
Ruler: {Ring, Server, RulerStorage, RuleEvaluator, Overrides, TenantConfigs, Analytics},
QueryFrontend: {QueryFrontendTripperware, Analytics, CacheGenerationLoader, QuerySchedulerRing, UI},
QueryScheduler: {Server, Overrides, MemberlistKV, Analytics, QuerySchedulerRing, UI},
Ruler: {Ring, Server, RulerStorage, RuleEvaluator, Overrides, TenantConfigs, Analytics, UI},
RuleEvaluator: {Ring, Server, Store, IngesterQuerier, Overrides, TenantConfigs, Analytics},
TableManager: {Server, Analytics},
Compactor: {Server, Overrides, MemberlistKV, Analytics},
IndexGateway: {Server, Store, BloomStore, IndexGatewayRing, IndexGatewayInterceptors, Analytics},
BloomGateway: {Server, BloomStore, Analytics},
BloomPlanner: {Server, BloomStore, Analytics, Store},
BloomBuilder: {Server, BloomStore, Analytics, Store},
TableManager: {Server, Analytics, UI},
Compactor: {Server, Overrides, MemberlistKV, Analytics, UI},
IndexGateway: {Server, Store, BloomStore, IndexGatewayRing, IndexGatewayInterceptors, Analytics, UI},
BloomGateway: {Server, BloomStore, Analytics, UI},
BloomPlanner: {Server, BloomStore, Analytics, Store, UI},
BloomBuilder: {Server, BloomStore, Analytics, Store, UI},
BloomStore: {IndexGatewayRing, BloomGatewayClient},
PatternRingClient: {Server, MemberlistKV, Analytics},
PatternIngesterTee: {Server, Overrides, MemberlistKV, Analytics, PatternRingClient},
PatternIngester: {Server, MemberlistKV, Analytics, PatternRingClient, PatternIngesterTee, Overrides},
PatternIngester: {Server, MemberlistKV, Analytics, PatternRingClient, PatternIngesterTee, Overrides, UI},
IngesterQuerier: {Ring, PartitionRing, Overrides},
QuerySchedulerRing: {Overrides, MemberlistKV},
IndexGatewayRing: {Overrides, MemberlistKV},
PartitionRing: {MemberlistKV, Server, Ring},
MemberlistKV: {Server},
BlockBuilder: {PartitionRing, Store, Server},
BlockScheduler: {Server},
DataObjExplorer: {Server},
DataObjConsumer: {PartitionRing, Server},
BlockBuilder: {PartitionRing, Store, Server, UI},
BlockScheduler: {Server, UI},
DataObjExplorer: {Server, UI},
DataObjConsumer: {PartitionRing, Server, UI},
Read: {QueryFrontend, Querier},
Write: {Ingester, Distributor, PatternIngester},
Backend: {QueryScheduler, Ruler, Compactor, IndexGateway, BloomPlanner, BloomBuilder, BloomGateway},
All: {QueryScheduler, QueryFrontend, Querier, Ingester, PatternIngester, Distributor, Ruler, Compactor},
All: {QueryScheduler, QueryFrontend, Querier, Ingester, PatternIngester, Distributor, Ruler, Compactor, UI},
}
if t.Cfg.Querier.PerRequestLimitsEnabled {

@ -35,6 +35,8 @@ import (
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/model"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"github.com/thanos-io/objstore"
@ -89,6 +91,7 @@ import (
boltdbcompactor "github.com/grafana/loki/v3/pkg/storage/stores/shipper/indexshipper/boltdb/compactor"
"github.com/grafana/loki/v3/pkg/storage/stores/shipper/indexshipper/tsdb"
"github.com/grafana/loki/v3/pkg/storage/types"
"github.com/grafana/loki/v3/pkg/ui"
"github.com/grafana/loki/v3/pkg/util/constants"
"github.com/grafana/loki/v3/pkg/util/httpreq"
"github.com/grafana/loki/v3/pkg/util/limiter"
@ -148,6 +151,7 @@ const (
BlockScheduler = "block-scheduler"
DataObjExplorer = "dataobj-explorer"
DataObjConsumer = "dataobj-consumer"
UI = "ui"
All = "all"
Read = "read"
Write = "write"
@ -207,6 +211,7 @@ func (t *Loki) initServer() (services.Service, error) {
}(t.Server.HTTPServer.Handler)
t.Server.HTTPServer.Handler = middleware.Merge(serverutil.RecoveryHTTPMiddleware).Wrap(h)
t.Server.HTTPServer.Handler = h2c.NewHandler(t.Server.HTTPServer.Handler, &http2.Server{})
if t.Cfg.Server.HTTPListenPort == 0 {
t.Cfg.Server.HTTPListenPort = portFromAddr(t.Server.HTTPListenAddr().String())
@ -1533,10 +1538,11 @@ func (t *Loki) initCompactor() (services.Service, error) {
t.compactor.RegisterIndexCompactor(types.BoltDBShipperType, boltdbcompactor.NewIndexCompactor())
t.compactor.RegisterIndexCompactor(types.TSDBType, tsdb.NewIndexCompactor())
t.Server.HTTP.Path("/compactor/ring").Methods("GET", "POST").Handler(t.compactor)
prefix, compactorHandler := t.compactor.Handler()
t.Server.HTTP.PathPrefix(prefix).Handler(compactorHandler)
if t.Cfg.InternalServer.Enable {
t.InternalServer.HTTP.Path("/compactor/ring").Methods("GET", "POST").Handler(t.compactor)
t.InternalServer.HTTP.PathPrefix(prefix).Handler(compactorHandler)
}
if t.Cfg.CompactorConfig.RetentionEnabled {
@ -1551,7 +1557,7 @@ func (t *Loki) initCompactor() (services.Service, error) {
}
func (t *Loki) addCompactorMiddleware(h http.HandlerFunc) http.Handler {
return t.HTTPAuthMiddleware.Wrap(deletion.TenantMiddleware(t.Overrides, h))
return middleware.Merge(t.HTTPAuthMiddleware, deletion.TenantMiddleware(t.Overrides)).Wrap(h)
}
func (t *Loki) initBloomGateway() (services.Service, error) {
@ -1938,11 +1944,21 @@ func (t *Loki) initDataObjExplorer() (services.Service, error) {
if err != nil {
return nil, err
}
t.Server.HTTP.PathPrefix("/dataobj/explorer/").Handler(explorer.Handler())
path, handler := explorer.Handler()
t.Server.HTTP.PathPrefix(path).Handler(handler)
return explorer, nil
}
func (t *Loki) initUI() (services.Service, error) {
t.Cfg.UI = t.Cfg.UI.WithAdvertisePort(t.Cfg.Server.HTTPListenPort)
svc, err := ui.NewService(t.Cfg.UI, t.Server.HTTP, log.With(util_log.Logger, "component", "ui"))
if err != nil {
return nil, err
}
t.UI = svc
return svc, nil
}
func (t *Loki) initDataObjConsumer() (services.Service, error) {
if !t.Cfg.Ingester.KafkaIngestion.Enabled {
return nil, nil

@ -0,0 +1,243 @@
# Loki UI Architecture in Distributed Mode
## Overview
Loki's UI system is designed to work seamlessly in a distributed environment where multiple Loki nodes can serve the UI and proxy requests to other nodes in the cluster. The system uses [ckit](https://github.com/grafana/ckit) for cluster membership and discovery, allowing any node to serve as an entry point for the UI while maintaining the ability to interact with all nodes in the cluster.
## Key Components
### 1. Node Discovery and Clustering
- Uses `ckit` for cluster membership and discovery
- Each node advertises itself and maintains a list of peers
- Nodes can join and leave the cluster dynamically
- Periodic rejoin mechanism to handle split-brain scenarios
### 2. UI Service Components
- **Static UI Files**: Embedded React frontend served from each node
- **API Layer**: REST endpoints for cluster state and proxying
- **Proxy System**: Allows forwarding requests to specific nodes
- **Service Discovery**: Tracks available nodes and their services
## Architecture Diagram
```mermaid
graph TB
LB[Reverse Proxy /ui/]
subgraph Cluster[Loki Cluster]
subgraph Node1[Node 1]
UI1[UI Frontend]
API1[API Server]
PROXY1[Proxy Handler]
CKIT1[ckit Node]
end
subgraph Node2[Node 2]
UI2[UI Frontend]
API2[API Server]
PROXY2[Proxy Handler]
CKIT2[ckit Node]
end
subgraph Node3[Node 3]
UI3[UI Frontend]
API3[API Server]
PROXY3[Proxy Handler]
CKIT3[ckit Node]
end
end
LB --> Node1
LB --> Node2
LB --> Node3
CKIT1 --- CKIT2
CKIT2 --- CKIT3
CKIT3 --- CKIT1
```
## API Endpoints
All endpoints are prefixed with `/ui/`
### Cluster Management
- `GET /ui/api/v1/cluster/nodes`
- Returns the state of all nodes in the cluster
- Response includes node status, services, and build information
- `GET /ui/api/v1/cluster/nodes/self/details`
- Returns detailed information about the current node
- Includes configuration, analytics, and system information
### Proxy System
- `GET /ui/api/v1/proxy/{nodename}/*`
- Proxies requests to specific nodes in the cluster
- Maintains original request path after the node name
### Analytics
- `GET /ui/api/v1/analytics`
- Returns analytics data for the node
### Static UI
- `GET /ui/*`
- Serves the React frontend application
- Falls back to index.html for client-side routing
## Request Flow Examples
### Example 1: Viewing Cluster Status
1. User accesses `http://loki-cluster/ui/`
2. Frontend loads and makes request to `/ui/api/v1/cluster/nodes`
3. Node handling the request:
- Queries all peers using ckit
- Collects status from each node
- Returns consolidated cluster state
```sequence
Browser->Node 1: GET /ui/api/v1/cluster/nodes
Node 1->Node 2: Fetch status
Node 1->Node 3: Fetch status
Node 2-->Node 1: Status response
Node 3-->Node 1: Status response
Node 1-->Browser: Combined cluster state
```
### Example 2: Accessing Node-Specific Service
1. User requests service data from specific node
2. Frontend makes request to `/ui/api/v1/proxy/{nodename}/services`
3. Request is proxied to target node
4. Response returns directly to client
```sequence
Browser->Node 1: GET /ui/api/v1/proxy/node2/services
Node 1->Node 2: Proxy request
Node 2-->Node 1: Service data
Node 1-->Browser: Proxied response
```
## Configuration
The UI service can be configured with the following key parameters:
```yaml
ui:
node_name: <string> # Name for this node in the cluster
advertise_addr: <string> # IP address to advertise
interface_names: <[]string> # Network interfaces to use
rejoin_interval: <duration> # How often to rejoin cluster
cluster_name: <string> # Cluster identifier
discovery:
join_peers: <[]string> # Initial peers to join
```
## Security Considerations
1. The UI endpoints should be protected behind authentication
2. The `/ui/` prefix allows easy reverse proxy configuration
3. Node-to-node communication should be restricted to internal network
## High Availability
- Any node can serve the UI
- Nodes automatically discover each other
- Periodic rejoin handles split-brain scenarios
- Load balancer can distribute traffic across nodes
## Best Practices
1. Configure a reverse proxy in front of the Loki cluster
2. Use consistent node names across the cluster
3. Configure appropriate rejoin intervals based on cluster size
4. Monitor cluster state for node health
5. Use internal network for node-to-node communication
## Concrete Example: Ring UI via Querier
### Ring UI Overview
The Ring UI is a critical component for understanding the state of Loki's distributed hash ring. Here's how it works when accessed through a querier node. Since each ingester maintains the complete ring state, querying a single ingester is sufficient to view the entire ring:
### Component Interaction
```mermaid
sequenceDiagram
participant Browser
participant Querier Node
participant Ingester
Browser->>Querier Node: GET /ui/
Browser->>Querier Node: GET /ui/api/v1/cluster/nodes
Querier Node-->>Browser: List of available nodes
Note over Browser,Querier Node: User clicks on Ring view
Browser->>Querier Node: GET /ui/api/v1/proxy/querier-1/ring
Note over Querier Node: Querier fetches ring state
Querier Node->>Ingester: Get ring status
Ingester-->>Querier Node: Complete ring state
Querier Node-->>Browser: Ring state
```
### Request Flow Details
1. **Initial UI Load**
```http
GET /ui/
GET /ui/api/v1/cluster/nodes
```
- Frontend loads and discovers available nodes
- UI shows querier node in the node list
2. **Ring State Request**
```http
GET /ui/api/v1/proxy/querier-1/ring
```
- Frontend requests ring state through the proxy endpoint
- Request is forwarded to the querier's ring endpoint
- Querier gets complete ring state from a single ingester
3. **Ring Data Structure**
```json
{
"tokens": [
{
"token": "123456",
"ingester": "ingester-1",
"state": "ACTIVE",
"timestamp": "2024-02-04T12:00:00Z"
},
// ... more tokens
],
"ingesters": {
"ingester-1": {
"state": "ACTIVE",
"tokens": ["123456", "789012"],
"address": "ingester-1:3100",
"last_heartbeat": "2024-02-04T12:00:00Z"
}
// ... more ingesters
}
}
```
### Security Notes
1. Ring access should be restricted to authorized users
2. Internal ring communication uses HTTP/2
3. Ring state contains sensitive cluster information

@ -0,0 +1,351 @@
package ui
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/grafana/ckit/peer"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
"github.com/grafana/loki/v3/pkg/analytics"
)
// Cluster represents a collection of cluster members.
type Cluster struct {
Members map[string]Member `json:"members"`
}
// Member represents a node in the cluster with its current state and capabilities.
type Member struct {
Addr string `json:"addr"`
State string `json:"state"`
IsSelf bool `json:"isSelf"`
Target string `json:"target"`
Services []ServiceState `json:"services"`
Build BuildInfo `json:"build"`
Error error `json:"error,omitempty"`
Ready ReadyResponse `json:"ready,omitempty"`
configBody string
}
// ServiceState represents the current state of a service running on a member.
type ServiceState struct {
Service string `json:"service"`
Status string `json:"status"`
}
// BuildInfo contains version and build information about a member.
type BuildInfo struct {
Version string `json:"version"`
Revision string `json:"revision"`
Branch string `json:"branch"`
BuildUser string `json:"buildUser"`
BuildDate string `json:"buildDate"`
GoVersion string `json:"goVersion"`
}
// fetchClusterMembers retrieves the state of all members in the cluster.
// It uses an errgroup to fetch member states concurrently with a limit of 16 concurrent operations.
func (s *Service) fetchClusterMembers(ctx context.Context) (Cluster, error) {
var cluster Cluster
cluster.Members = make(map[string]Member)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(16)
// Use a mutex to protect concurrent map access
var mu sync.Mutex
for _, p := range s.node.Peers() {
peer := p // Create new variable to avoid closure issues
g.Go(func() error {
member, err := s.fetchMemberState(ctx, peer)
if err != nil {
member.Error = err
}
mu.Lock()
cluster.Members[peer.Name] = member
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return Cluster{}, fmt.Errorf("fetching cluster members: %w", err)
}
return cluster, nil
}
// fetchMemberState retrieves the complete state of a single cluster member.
func (s *Service) fetchMemberState(ctx context.Context, peer peer.Peer) (Member, error) {
member := Member{
Addr: peer.Addr,
IsSelf: peer.Self,
State: peer.State.String(),
}
config, err := s.fetchConfig(ctx, peer)
if err != nil {
return member, fmt.Errorf("fetching config: %w", err)
}
member.configBody = config
member.Target = parseTargetFromConfig(config)
services, err := s.fetchServices(ctx, peer)
if err != nil {
return member, fmt.Errorf("fetching services: %w", err)
}
member.Services = services
build, err := s.fetchBuild(ctx, peer)
if err != nil {
return member, fmt.Errorf("fetching build info: %w", err)
}
member.Build = build
readyResp, err := s.checkNodeReadiness(ctx, peer.Name)
if err != nil {
return member, fmt.Errorf("checking node readiness: %w", err)
}
member.Ready = readyResp
return member, nil
}
// buildProxyPath constructs the proxy URL path for a given peer and endpoint.
func (s *Service) buildProxyPath(peer peer.Peer, endpoint string) string {
// todo support configured server prefix.
return fmt.Sprintf("http://%s/ui/api/v1/proxy/%s%s", s.localAddr, peer.Name, endpoint)
}
// readResponseError checks the HTTP response for errors and returns an appropriate error message.
// If the response status is not OK, it reads and includes the response body in the error message.
func readResponseError(resp *http.Response, operation string) error {
if resp == nil {
return fmt.Errorf("%s: no response received", operation)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s failed: %s, error reading body: %v", operation, resp.Status, err)
}
return fmt.Errorf("%s failed: %s, response: %s", operation, resp.Status, string(body))
}
return nil
}
// NodeDetails contains the details of a node in the cluster.
// It adds on top of Member the config, build, clusterID, clusterSeededAt, os, arch, edition and registered analytics metrics.
type NodeDetails struct {
Member
Config string `json:"config"`
ClusterID string `json:"clusterID"`
ClusterSeededAt int64 `json:"clusterSeededAt"`
OS string `json:"os"`
Arch string `json:"arch"`
Edition string `json:"edition"`
Metrics map[string]interface{} `json:"metrics"`
}
func (s *Service) fetchSelfDetails(ctx context.Context) (NodeDetails, error) {
peer, ok := s.getSelfPeer()
if !ok {
return NodeDetails{}, fmt.Errorf("self peer not found")
}
report, err := s.fetchAnalytics(ctx, peer)
if err != nil {
return NodeDetails{}, fmt.Errorf("fetching analytics: %w", err)
}
member, err := s.fetchMemberState(ctx, peer)
if err != nil {
return NodeDetails{}, fmt.Errorf("fetching member state: %w", err)
}
return NodeDetails{
Member: member,
Config: member.configBody,
Metrics: report.Metrics,
ClusterID: report.ClusterID,
ClusterSeededAt: report.CreatedAt.UnixMilli(),
OS: report.Os,
Arch: report.Arch,
Edition: report.Edition,
}, nil
}
func (s *Service) getSelfPeer() (peer.Peer, bool) {
for _, peer := range s.node.Peers() {
if peer.Self {
return peer, true
}
}
return peer.Peer{}, false
}
func (s *Service) fetchAnalytics(ctx context.Context, peer peer.Peer) (analytics.Report, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.buildProxyPath(peer, "/ui/api/v1/analytics"), nil)
if err != nil {
return analytics.Report{}, fmt.Errorf("creating request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return analytics.Report{}, fmt.Errorf("sending request: %w", err)
}
if err := readResponseError(resp, "fetch build info"); err != nil {
return analytics.Report{}, err
}
defer resp.Body.Close()
var report analytics.Report
if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
return analytics.Report{}, fmt.Errorf("decoding response: %w", err)
}
return report, nil
}
// fetchConfig retrieves the configuration of a cluster member.
func (s *Service) fetchConfig(ctx context.Context, peer peer.Peer) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.buildProxyPath(peer, "/config"), nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("sending request: %w", err)
}
if err := readResponseError(resp, "fetch config"); err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
return string(body), nil
}
// fetchServices retrieves the service states of a cluster member.
func (s *Service) fetchServices(ctx context.Context, peer peer.Peer) ([]ServiceState, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.buildProxyPath(peer, "/services"), nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
if err := readResponseError(resp, "fetch services"); err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
return parseServices(string(body))
}
// fetchBuild retrieves the build information of a cluster member.
func (s *Service) fetchBuild(ctx context.Context, peer peer.Peer) (BuildInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.buildProxyPath(peer, "/loki/api/v1/status/buildinfo"), nil)
if err != nil {
return BuildInfo{}, fmt.Errorf("creating request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return BuildInfo{}, fmt.Errorf("sending request: %w", err)
}
if err := readResponseError(resp, "fetch build info"); err != nil {
return BuildInfo{}, err
}
defer resp.Body.Close()
var build BuildInfo
if err := json.NewDecoder(resp.Body).Decode(&build); err != nil {
return BuildInfo{}, fmt.Errorf("decoding response: %w", err)
}
return build, nil
}
type ReadyResponse struct {
IsReady bool `json:"isReady"`
Message string `json:"message"`
}
func (s *Service) checkNodeReadiness(ctx context.Context, nodeName string) (ReadyResponse, error) {
peer, err := s.findPeerByName(nodeName)
if err != nil {
return ReadyResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, "GET", s.buildProxyPath(peer, "/ready"), nil)
if err != nil {
return ReadyResponse{}, err
}
resp, err := s.client.Do(req)
if err != nil {
return ReadyResponse{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ReadyResponse{}, err
}
return ReadyResponse{
IsReady: resp.StatusCode == http.StatusOK && strings.TrimSpace(string(body)) == "ready",
Message: string(body),
}, nil
}
// parseTargetFromConfig extracts the target value from a YAML configuration string.
// Returns "unknown" if the config cannot be parsed or the target is not found.
func parseTargetFromConfig(config string) string {
var cfg map[string]interface{}
if err := yaml.Unmarshal([]byte(config), &cfg); err != nil {
return "unknown"
}
target, _ := cfg["target"].(string)
return target
}
// parseServices parses a string containing service states in the format:
// service => status
// Returns a slice of ServiceState structs.
func parseServices(body string) ([]ServiceState, error) {
var services []ServiceState
lines := strings.Split(body, "\n")
for _, line := range lines {
parts := strings.SplitN(line, " => ", 2)
if len(parts) != 2 {
continue
}
services = append(services, ServiceState{
Service: parts[0],
Status: parts[1],
})
}
return services, nil
}

@ -0,0 +1,58 @@
package ui
import (
"errors"
"flag"
"fmt"
"os"
"time"
"github.com/grafana/dskit/flagext"
"github.com/grafana/dskit/netutil"
util_log "github.com/grafana/loki/v3/pkg/util/log"
)
type Config struct {
NodeName string `yaml:"node_name" doc:"default=<hostname>"` // Name to use for this node in the cluster.
AdvertiseAddr string `yaml:"advertise_addr"`
InfNames []string `yaml:"interface_names" doc:"default=[<private network interfaces>]"`
RejoinInterval time.Duration `yaml:"rejoin_interval"` // How frequently to rejoin the cluster to address split brain issues.
ClusterMaxJoinPeers int `yaml:"cluster_max_join_peers"` // Number of initial peers to join from the discovered set.
ClusterName string `yaml:"cluster_name"` // Name to prevent nodes without this identifier from joining the cluster.
EnableIPv6 bool `yaml:"enable_ipv6"`
Discovery struct {
JoinPeers []string `yaml:"join_peers"`
} `yaml:"discovery"`
AdvertisePort int `yaml:"-"`
}
func (cfg Config) WithAdvertisePort(port int) Config {
cfg.AdvertisePort = port
return cfg
}
func (cfg *Config) RegisterFlags(f *flag.FlagSet) {
hostname, err := os.Hostname()
if err != nil {
panic(fmt.Errorf("failed to get hostname %s", err))
}
cfg.InfNames = netutil.PrivateNetworkInterfacesWithFallback([]string{"eth0", "en0"}, util_log.Logger)
f.Var((*flagext.StringSlice)(&cfg.InfNames), "ui.interface", "Name of network interface to read address from.")
f.StringVar(&cfg.NodeName, "ui.node-name", hostname, "Name to use for this node in the cluster.")
f.StringVar(&cfg.AdvertiseAddr, "ui.advertise-addr", "", "IP address to advertise in the cluster.")
f.DurationVar(&cfg.RejoinInterval, "ui.rejoin-interval", 15*time.Second, "How frequently to rejoin the cluster to address split brain issues.")
f.IntVar(&cfg.ClusterMaxJoinPeers, "ui.cluster-max-join-peers", 3, "Number of initial peers to join from the discovered set.")
f.StringVar(&cfg.ClusterName, "ui.cluster-name", "", "Name to prevent nodes without this identifier from joining the cluster.")
f.BoolVar(&cfg.EnableIPv6, "ui.enable-ipv6", false, "Enable using a IPv6 instance address.")
f.Var((*flagext.StringSlice)(&cfg.Discovery.JoinPeers), "ui.discovery.join-peers", "List of peers to join the cluster. Supports multiple values separated by commas. Each value can be a hostname, an IP address, or a DNS name (A/AAAA and SRV records).")
}
func (cfg Config) Validate() error {
if cfg.NodeName == "" {
return errors.New("node name is required")
}
return nil
}

@ -0,0 +1,146 @@
package ui
import (
"errors"
"fmt"
"math/rand/v2"
"net"
"strconv"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
)
func (s *Service) getBootstrapPeers() ([]string, error) {
if len(s.cfg.Discovery.JoinPeers) == 0 {
return nil, nil
}
// Use these resolvers in order to resolve the provided addresses into a form that can be used by clustering.
resolvers := []addressResolver{
ipResolver(),
dnsAResolver(nil),
dnsSRVResolver(nil),
}
// Get the addresses.
addresses, err := buildJoinAddresses(s.cfg, resolvers, s.logger)
if err != nil {
return nil, fmt.Errorf("static peer discovery: %w", err)
}
// Return unique addresses.
peers := uniq(addresses)
// Here we return the entire list because we can't take a subset.
if s.cfg.ClusterMaxJoinPeers == 0 || len(peers) < s.cfg.ClusterMaxJoinPeers {
return peers, nil
}
// We shuffle the list and return only a subset of the peers.
rand.Shuffle(len(peers), func(i, j int) {
peers[i], peers[j] = peers[j], peers[i]
})
return peers[:s.cfg.ClusterMaxJoinPeers], nil
}
func uniq(addresses []string) []string {
seen := make(map[string]bool)
var result []string
for _, addr := range addresses {
if !seen[addr] {
seen[addr] = true
result = append(result, addr)
}
}
return result
}
func buildJoinAddresses(opts Config, resolvers []addressResolver, logger log.Logger) ([]string, error) {
var (
result []string
deferredErr error
)
for _, addr := range opts.Discovery.JoinPeers {
// See if we have a port override, if not use the default port.
host, port, err := net.SplitHostPort(addr)
if err != nil {
host = addr
port = strconv.Itoa(opts.AdvertisePort)
}
atLeastOneSuccess := false
for _, resolver := range resolvers {
resolved, err := resolver(host)
deferredErr = errors.Join(deferredErr, err)
for _, foundAddr := range resolved {
result = append(result, net.JoinHostPort(foundAddr, port))
}
// we stop once we find a resolver that succeeded for given address
if len(resolved) > 0 {
atLeastOneSuccess = true
break
}
}
if !atLeastOneSuccess {
// It is still useful to know if user provided an address that we could not resolve, even
// if another addresses resolve successfully, and we don't return an error. To keep things simple, we're
// not including more detail as it's available through debug level.
level.Warn(logger).Log("msg", "failed to resolve provided join address", "addr", addr)
}
}
if len(result) == 0 {
return nil, fmt.Errorf("failed to find any valid join addresses: %w", deferredErr)
}
return result, nil
}
type addressResolver func(addr string) ([]string, error)
func ipResolver() addressResolver {
return func(addr string) ([]string, error) {
// Check if it's IP and use it if so.
ip := net.ParseIP(addr)
if ip == nil {
return nil, fmt.Errorf("could not parse as an IP or IP:port address: %q", addr)
}
return []string{ip.String()}, nil
}
}
func dnsAResolver(ipLookup func(string) ([]net.IP, error)) addressResolver {
// Default to net.LookupIP if not provided. By default, this will look up A/AAAA records.
if ipLookup == nil {
ipLookup = net.LookupIP
}
return func(addr string) ([]string, error) {
ips, err := ipLookup(addr)
result := make([]string, 0, len(ips))
for _, ip := range ips {
result = append(result, ip.String())
}
if err != nil {
err = fmt.Errorf("failed to resolve %q records: %w", "A/AAAA", err)
}
return result, err
}
}
func dnsSRVResolver(srvLookup func(service, proto, name string) (string, []*net.SRV, error)) addressResolver {
// Default to net.LookupSRV if not provided.
if srvLookup == nil {
srvLookup = net.LookupSRV
}
return func(addr string) ([]string, error) {
_, addresses, err := srvLookup("", "", addr)
result := make([]string, 0, len(addresses))
for _, a := range addresses {
result = append(result, a.Target)
}
if err != nil {
err = fmt.Errorf("failed to resolve %q records: %w", "SRV", err)
}
return result, err
}
}

@ -0,0 +1,14 @@
{
"ignores": [
"@types/node",
"autoprefixer",
"postcss",
"tailwindcss",
"tailwindcss-animate",
"@typescript-eslint/*",
"@vitejs/*",
"eslint*",
"depcheck"
],
"ignore-patterns": ["dist", "build", ".next", "coverage"]
}

@ -0,0 +1,34 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"ignorePatterns": [
"dist",
".eslintrc.json"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"react-refresh"
],
"rules": {
"react-refresh/only-export-components": "warn"
},
"overrides": [
{
"files": [
"src/components/ui/**/*"
],
"rules": {
"react-refresh/only-export-components": "off"
}
}
]
}

@ -0,0 +1,18 @@
.PHONY: build
build:
npm install
npm run build
.PHONY: dev
dev:
npm run dev
.PHONY: clean
clean:
rm -rf node_modules
rm -rf dist
.PHONY: check-deps
check-deps:
npx depcheck
npm audit

@ -0,0 +1,120 @@
# Loki UI
The Loki UI is an operational dashboard for managing and operating Grafana Loki clusters. It provides a comprehensive set of tools for cluster administration, operational tasks, and troubleshooting. This includes node management, configuration control, performance monitoring, and diagnostic tools for investigating cluster health and log ingestion issues.
## Tech Stack
- **Framework**: React 18 with TypeScript
- **Routing**: React Router v6
- **Styling**:
- Tailwind CSS for utility-first styling
- Shadcn UI (built on Radix UI) for accessible components
- Tailwind Merge for dynamic class merging
- Tailwind Animate for animations
- **State Management**: React Context + Custom Hooks
- **Data Visualization**: Recharts
- **Development**:
- Vite for build tooling and development server
- TypeScript for type safety
- ESLint for code quality
- PostCSS for CSS processing
## Project Structure
```
src/
├── components/ # React components
│ ├── ui/ # Shadcn UI components
│ │ ├── errors/ # Error handling components
│ │ └── breadcrumbs/ # Navigation breadcrumbs
│ ├── shared/ # Shared components used across pages
│ │ └── {pagename}/ # Page-specific components
│ ├── common/ # Truly reusable components
│ └── features/ # Complex feature modules
│ └── theme/ # Theme-related components and logic
├── pages/ # Page components and routes
├── layout/ # Layout components
├── hooks/ # Custom React hooks
├── contexts/ # React context providers
├── lib/ # Utility functions and constants
└── types/ # TypeScript type definitions
```
## Component Organization Guidelines
1. **Page-Specific Components**
- Place in `components/{pagename}/`
- Only used by a single page
- Colocated with the page they belong to
2. **Shared Components**
- Place in `components/shared/`
- Used across multiple pages
- Well-documented and maintainable
3. **Common Components**
- Place in `components/common/`
- Highly reusable, pure components
- No business logic
4. **UI Components**
- Place in `components/ui/`
- Shadcn components
- Do not modify directly
## Development Guidelines
1. **TypeScript**
- Use TypeScript for all new code
- Prefer interfaces over types
- Avoid enums, use const maps instead
2. **Component Best Practices**
- Use functional components
- Keep components small and focused
- Use composition over inheritance
- Colocate related code
3. **State Management**
- Use React Context for global state
- Custom hooks for reusable logic
- Local state for component-specific data
4. **Styling**
- Use Tailwind CSS for styling
- Avoid inline styles
- Use CSS variables for theming
- Follow responsive design principles
## Getting Started
1. Install dependencies:
```bash
npm install
```
2. Start development server:
```bash
npm run dev
```
3. Build for production:
```bash
npm run build
```
4. Lint code:
```bash
npm run lint
```
## Contributing
1. Follow the folder structure
2. Write clean, maintainable code
3. Add proper TypeScript types
4. Document complex logic

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
import{r as l}from"./react-core-D_V7s-9r.js";var v=(t,i,m,n,s,a,u,d)=>{let r=document.documentElement,h=["light","dark"];function o(e){(Array.isArray(t)?t:[t]).forEach(c=>{let p=c==="class",S=p&&a?s.map(f=>a[f]||f):s;p?(r.classList.remove(...S),r.classList.add(e)):r.setAttribute(c,e)}),y(e)}function y(e){d&&h.includes(e)&&(r.style.colorScheme=e)}function g(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}if(n)o(n);else try{let e=localStorage.getItem(i)||m,c=u&&e==="system"?g():e;o(c)}catch{}},E=l.createContext(void 0),b={setTheme:t=>{},themes:[]},w=()=>{var t;return(t=l.useContext(E))!=null?t:b};l.memo(({forcedTheme:t,storageKey:i,attribute:m,enableSystem:n,enableColorScheme:s,defaultTheme:a,value:u,themes:d,nonce:r,scriptProps:h})=>{let o=JSON.stringify([m,i,a,t,d,u,n,s]).slice(1,-1);return l.createElement("script",{...h,suppressHydrationWarning:!0,nonce:typeof window>"u"?r:"",dangerouslySetInnerHTML:{__html:`(${v.toString()})(${o})`}})});export{w as z};

@ -0,0 +1,191 @@
import{r as h,b as s}from"./react-core-D_V7s-9r.js";/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const g=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),m=(...e)=>e.filter((t,o,r)=>!!t&&t.trim()!==""&&r.indexOf(t)===o).join(" ").trim();/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/var x={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const b=h.forwardRef(({color:e="currentColor",size:t=24,strokeWidth:o=2,absoluteStrokeWidth:r,className:a="",children:c,iconNode:p,...y},i)=>h.createElement("svg",{ref:i,...x,width:t,height:t,stroke:e,strokeWidth:r?Number(o)*24/Number(t):o,className:m("lucide",a),...y},[...p.map(([w,_])=>h.createElement(w,_)),...Array.isArray(c)?c:[c]]));/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const n=(e,t)=>{const o=h.forwardRef(({className:r,...a},c)=>h.createElement(b,{ref:c,iconNode:t,className:m(`lucide-${g(e)}`,r),...a}));return o.displayName=`${e}`,o};/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const C=[["path",{d:"M12 5v14",key:"s699le"}],["path",{d:"m19 12-7 7-7-7",key:"1idqje"}]],le=n("ArrowDown",C);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const M=[["path",{d:"m5 12 7-7 7 7",key:"hav0vg"}],["path",{d:"M12 19V5",key:"x0mq9r"}]],pe=n("ArrowUp",M);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const N=[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]],ue=n("BookOpen",N);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const $=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],ke=n("Check",$);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const j=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],me=n("ChevronDown",j);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const O=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],ve=n("ChevronRight",O);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const P=[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]],fe=n("ChevronUp",P);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const D=[["path",{d:"m7 15 5 5 5-5",key:"1hf1tw"}],["path",{d:"m7 9 5-5 5 5",key:"sgt6xg"}]],we=n("ChevronsUpDown",D);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const A=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]],_e=n("CircleAlert",A);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const q=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 8v8",key:"napkw2"}],["path",{d:"m8 12 4 4 4-4",key:"k98ssh"}]],ge=n("CircleArrowDown",q);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const z=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M8 12h8",key:"1wcyev"}],["path",{d:"m12 16 4-4-4-4",key:"1i9zcv"}]],xe=n("CircleArrowRight",z);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const L=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m16 12-4-4-4 4",key:"177agl"}],["path",{d:"M12 16V8",key:"1sbj14"}]],be=n("CircleArrowUp",L);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const E=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["circle",{cx:"12",cy:"12",r:"1",key:"41hilf"}]],Ce=n("CircleDot",E);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const R=[["path",{d:"M15.6 2.7a10 10 0 1 0 5.7 5.7",key:"1e0p6d"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}],["path",{d:"M13.4 10.6 19 5",key:"1kr7tw"}]],Me=n("CircleGauge",R);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const S=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],Ne=n("Circle",S);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const U=[["rect",{x:"2",y:"6",width:"20",height:"8",rx:"1",key:"1estib"}],["path",{d:"M17 14v7",key:"7m2elx"}],["path",{d:"M7 14v7",key:"1cm7wv"}],["path",{d:"M17 3v3",key:"1v4jwn"}],["path",{d:"M7 3v3",key:"7o6guu"}],["path",{d:"M10 14 2.3 6.3",key:"1023jk"}],["path",{d:"m14 6 7.7 7.7",key:"1s8pl2"}],["path",{d:"m8 6 8 8",key:"hl96qh"}]],$e=n("Construction",U);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const H=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],je=n("Copy",H);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const V=[["ellipse",{cx:"12",cy:"5",rx:"9",ry:"3",key:"msslwz"}],["path",{d:"M3 5V19A9 3 0 0 0 21 19V5",key:"1wlel7"}],["path",{d:"M3 12A9 3 0 0 0 21 12",key:"mv7ke4"}]],Oe=n("Database",V);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const B=[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"7 10 12 15 17 10",key:"2ggqvy"}],["line",{x1:"12",x2:"12",y1:"15",y2:"3",key:"1vk2je"}]],Pe=n("Download",B);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const I=[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}]],De=n("File",I);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const F=[["path",{d:"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",key:"1kt360"}]],Ae=n("Folder",F);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const G=[["path",{d:"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8",key:"5wwlr5"}],["path",{d:"M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",key:"1d0kgt"}]],qe=n("House",G);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const W=[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]],ze=n("LayoutDashboard",W);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Z=[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]],Le=n("LoaderCircle",Z);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const K=[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]],Ee=n("Moon",K);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const T=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}]],Re=n("PanelLeft",T);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const X=[["rect",{x:"14",y:"4",width:"4",height:"16",rx:"1",key:"zuxfzm"}],["rect",{x:"6",y:"4",width:"4",height:"16",rx:"1",key:"1okwgv"}]],Se=n("Pause",X);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const J=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],Ue=n("Plus",J);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Q=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]],He=n("RefreshCw",Q);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Y=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],Ve=n("RotateCcw",Y);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const ee=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]],Be=n("Search",ee);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const te=[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]],Ie=n("Sun",te);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const oe=[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["path",{d:"M16 3.13a4 4 0 0 1 0 7.75",key:"1da9ce"}]],Fe=n("Users",oe);/**
* @license lucide-react v0.474.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const re=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],Ge=n("X",re);var v={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},u=s.createContext&&s.createContext(v),ne=["attr","size","title"];function ae(e,t){if(e==null)return{};var o=ce(e,t),r,a;if(Object.getOwnPropertySymbols){var c=Object.getOwnPropertySymbols(e);for(a=0;a<c.length;a++)r=c[a],!(t.indexOf(r)>=0)&&Object.prototype.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}function ce(e,t){if(e==null)return{};var o={};for(var r in e)if(Object.prototype.hasOwnProperty.call(e,r)){if(t.indexOf(r)>=0)continue;o[r]=e[r]}return o}function d(){return d=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var o=arguments[t];for(var r in o)Object.prototype.hasOwnProperty.call(o,r)&&(e[r]=o[r])}return e},d.apply(this,arguments)}function k(e,t){var o=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable})),o.push.apply(o,r)}return o}function l(e){for(var t=1;t<arguments.length;t++){var o=arguments[t]!=null?arguments[t]:{};t%2?k(Object(o),!0).forEach(function(r){ie(e,r,o[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(o)):k(Object(o)).forEach(function(r){Object.defineProperty(e,r,Object.getOwnPropertyDescriptor(o,r))})}return e}function ie(e,t,o){return t=se(t),t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e}function se(e){var t=he(e,"string");return typeof t=="symbol"?t:t+""}function he(e,t){if(typeof e!="object"||!e)return e;var o=e[Symbol.toPrimitive];if(o!==void 0){var r=o.call(e,t||"default");if(typeof r!="object")return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function f(e){return e&&e.map((t,o)=>s.createElement(t.tag,l({key:o},t.attr),f(t.child)))}function We(e){return t=>s.createElement(ye,d({attr:l({},e.attr)},t),f(e.child))}function ye(e){var t=o=>{var{attr:r,size:a,title:c}=e,p=ae(e,ne),y=a||o.size||"1em",i;return o.className&&(i=o.className),e.className&&(i=(i?i+" ":"")+e.className),s.createElement("svg",d({stroke:"currentColor",fill:"currentColor",strokeWidth:"0"},o.attr,r,p,{className:i,style:l(l({color:e.color||o.color},o.style),e.style),height:y,width:y,xmlns:"http://www.w3.org/2000/svg"}),c&&s.createElement("title",null,c),e.children)};return u!==void 0?s.createElement(u.Consumer,null,o=>t(o)):t(v)}export{le as A,ue as B,ve as C,Pe as D,Ae as F,We as G,qe as H,Le as L,Ee as M,Se as P,Ve as R,Ie as S,Fe as U,Ge as X,ke as a,Ne as b,pe as c,we as d,xe as e,Be as f,_e as g,Ce as h,be as i,ge as j,me as k,fe as l,De as m,je as n,He as o,$e as p,Ue as q,Re as r,ze as s,Oe as t,Me as u};

File diff suppressed because one or more lines are too long

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loki UI</title>
<script type="module" crossorigin src="/ui/assets/index-DqJzRHuy.js"></script>
<link rel="modulepreload" crossorigin href="/ui/assets/react-core-D_V7s-9r.js">
<link rel="modulepreload" crossorigin href="/ui/assets/radix-core-ByqQ8fsu.js">
<link rel="modulepreload" crossorigin href="/ui/assets/react-router-Bj-soKrx.js">
<link rel="modulepreload" crossorigin href="/ui/assets/ui-utils-BNSC_Jv-.js">
<link rel="modulepreload" crossorigin href="/ui/assets/ui-icons-CFVjIJRk.js">
<link rel="modulepreload" crossorigin href="/ui/assets/date-utils-B6syNIuD.js">
<link rel="modulepreload" crossorigin href="/ui/assets/radix-navigation-DYoR-lWZ.js">
<link rel="modulepreload" crossorigin href="/ui/assets/radix-inputs-D4_OLmm6.js">
<link rel="modulepreload" crossorigin href="/ui/assets/radix-layout-BqTpm3s4.js">
<link rel="modulepreload" crossorigin href="/ui/assets/data-viz-BuFFX-vG.js">
<link rel="modulepreload" crossorigin href="/ui/assets/query-management-DbWM5GrR.js">
<link rel="modulepreload" crossorigin href="/ui/assets/theme-utils-CNom64Sw.js">
<link rel="modulepreload" crossorigin href="/ui/assets/form-libs-B6JBoFJD.js">
<link rel="stylesheet" crossorigin href="/ui/assets/style-De_mcyPH.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DataObj Explorer</title>
<title>Loki UI</title>
</head>
<body>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,74 @@
{
"name": "@grafana/loki-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-hover-card": "^1.1.5",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.5",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.7",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@types/lodash": "^4.17.15",
"@types/react-datepicker": "^6.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.3.1",
"lodash": "^4.17.21",
"lucide-react": "^0.474.0",
"next-themes": "^0.4.4",
"prism-react-renderer": "^2.4.1",
"react": "^18.2.0",
"react-code-block": "^1.1.1",
"react-datepicker": "^8.0.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0",
"react-router-dom": "^6.22.0",
"recharts": "^2.15.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"use-react-router-breadcrumbs": "^4.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.12.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.20",
"depcheck": "^1.4.7",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.1",
"typescript": "^5.0.2",
"vite": "^5.1.0"
}
}

@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};

@ -0,0 +1,28 @@
import { Routes, Route } from "react-router-dom";
import { AppLayout } from "./layout/layout";
import { ThemeProvider } from "./features/theme/components/theme-provider";
import { QueryProvider } from "./providers/query-provider";
import { ClusterProvider } from "./contexts/cluster-provider";
import { routes } from "./config/routes";
export default function App() {
return (
<QueryProvider>
<ThemeProvider defaultTheme="dark" storageKey="loki-ui-theme">
<ClusterProvider>
<AppLayout>
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
</Routes>
</AppLayout>
</ClusterProvider>
</ThemeProvider>
</QueryProvider>
);
}

@ -0,0 +1,43 @@
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface CopyButtonProps {
text: string;
className?: string;
onCopy?: () => void;
}
export function CopyButton({ text, className, onCopy }: CopyButtonProps) {
const [hasCopied, setHasCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(text).then(() => {
setHasCopied(true);
onCopy?.();
setTimeout(() => setHasCopied(false), 2000);
});
};
return (
<Button
variant="ghost"
size="sm"
onClick={copyToClipboard}
className={cn("h-8 px-2", className)}
>
{hasCopied ? (
<>
<Check className="h-4 w-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
);
}

@ -0,0 +1,83 @@
import { ChevronsUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
interface DataTableColumnHeaderProps<TField extends string> {
title: string;
field: TField;
sortField: string;
sortDirection: "asc" | "desc";
onSort: (field: TField) => void;
}
export function DataTableColumnHeader<TField extends string>({
title,
field,
sortField,
sortDirection,
onSort,
}: DataTableColumnHeaderProps<TField>) {
const isCurrentSort = sortField === field;
const handleSort = (direction: "asc" | "desc") => {
if (sortField === field && sortDirection === direction) {
return;
}
onSort(field);
};
return (
<div className="flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 hover:bg-muted/50 data-[state=open]:bg-muted/50"
>
<div className="flex items-center">
<span>{title}</span>
{isCurrentSort ? (
sortDirection === "desc" ? (
<ArrowDown className="ml-2 h-4 w-4" />
) : (
<ArrowUp className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => handleSort("asc")}
className={cn(
"cursor-pointer",
isCurrentSort && sortDirection === "asc" && "bg-accent"
)}
>
<ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSort("desc")}
className={cn(
"cursor-pointer",
isCurrentSort && sortDirection === "desc" && "bg-accent"
)}
>
<ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

@ -0,0 +1,47 @@
import React from "react";
import { formatDistanceToNow, format } from "date-fns";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface DateHoverProps {
date: Date;
className?: string;
}
export const DateHover: React.FC<DateHoverProps> = ({
date,
className = "",
}) => {
const relativeTime = formatDistanceToNow(date, { addSuffix: true });
const localTime = format(date, "yyyy-MM-dd HH:mm:ss");
const utcTime = format(
new Date(date.getTime() + date.getTimezoneOffset() * 60000),
"yyyy-MM-dd HH:mm:ss"
);
return (
<HoverCard>
<HoverCardTrigger>
<div className={`inline-block ${className}`}>{relativeTime}</div>
</HoverCardTrigger>
<HoverCardContent className="w-[280px]">
<div className="space-y-2">
<div className="flex items-center gap-3">
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 rounded dark:bg-gray-700 w-14 text-center">
UTC
</span>
<span className="font-mono">{utcTime}</span>
</div>
<div className="flex items-center gap-3">
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 rounded dark:bg-gray-700 w-14 text-center">
Local
</span>
<span className="font-mono">{localTime}</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
};

@ -0,0 +1 @@
export * from "./multi-select";

@ -0,0 +1,110 @@
// src/components/multi-select.tsx
import * as React from "react";
import { ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
export interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
options: Option[];
selected: string[];
onChange: (values: string[]) => void;
placeholder?: string;
emptyMessage?: string;
className?: string;
}
export function MultiSelect({
options = [],
selected = [],
onChange,
placeholder = "Select options...",
emptyMessage = "No options found.",
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = (value: string) => {
const newSelected = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
onChange(newSelected);
};
const handleSelectAll = () => {
if (selected.length === options.length) {
onChange([]);
} else {
onChange(options.map((option) => option.value));
}
};
const selectedCount = selected.length;
const totalOptions = options.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("justify-between", className)}
>
{selectedCount === 0 ? placeholder : `${selectedCount} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={placeholder} />
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{totalOptions > 0 && (
<CommandItem onSelect={handleSelectAll}>
<div className="flex items-center space-x-2">
<Checkbox
checked={
selectedCount > 0 && selectedCount === totalOptions
}
aria-label="Select all"
/>
<span>Select all</span>
</div>
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleSelect(option.value)}
>
<div className="flex items-center space-x-2">
<Checkbox checked={selected.includes(option.value)} />
<span>{option.label}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Loader2, Pause } from "lucide-react";
interface RefreshLoopProps {
onRefresh: () => void;
isPaused?: boolean;
isLoading: boolean;
className?: string;
}
export function RefreshLoop({
onRefresh,
isPaused = false,
isLoading,
className,
}: RefreshLoopProps) {
const [delayedLoading, setDelayedLoading] = useState(isLoading);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (isLoading) {
setDelayedLoading(true);
} else {
timeoutId = setTimeout(() => {
setDelayedLoading(false);
}, 1000); // Keep loading state for 1 second after isLoading becomes false
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [isLoading]);
return (
<div
className={`flex items-center gap-2 text-sm text-muted-foreground ${className}`}
>
<Button
variant="secondary"
size="sm"
className="h-6 px-2 text-xs hover:bg-muted"
onClick={onRefresh}
>
Refresh now
</Button>
{isPaused ? (
<Pause className="h-3 w-3 text-orange-500" />
) : (
<Loader2
className={`h-3 w-3 ${
delayedLoading
? "animate-spin text-emerald-500 "
: "opacity-0 transition-opacity duration-1000"
} `}
/>
)}
<span className="transition-opacity duration-1000">
{isPaused
? "Auto-refresh paused"
: delayedLoading
? "Refreshing..."
: ``}
</span>
</div>
);
}

@ -1,11 +1,15 @@
import React from "react";
import { useSearchParams } from "react-router-dom";
import React, { useMemo } from "react";
import { findNodeName } from "@/lib/utils";
import { useCluster } from "@/contexts/use-cluster";
import {
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
Breadcrumb,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Link } from "react-router-dom";
import { useBasename } from "../../contexts/BasenameContext";
interface BreadcrumbProps {
parts: string[];
isLastPartClickable?: boolean;
}
const getProviderStyles = (
provider: string
@ -91,29 +95,36 @@ const getProviderStyles = (
}
};
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
parts,
isLastPartClickable = false,
}) => {
export function ExplorerBreadcrumb() {
const [provider, setProvider] = React.useState<string>("");
const basename = useBasename();
// const basename = useBasename(); // TODO: use basename
const { cluster } = useCluster();
const nodeName = useMemo(() => {
return findNodeName(cluster?.members, "dataobj-explorer");
}, [cluster?.members]);
React.useEffect(() => {
fetch(`${basename}api/provider`)
if (nodeName) {
fetch(`/ui/api/v1/proxy/${nodeName}/dataobj/api/v1/provider`)
.then((res) => res.json())
.then((data) => setProvider(data.provider))
.catch(console.error);
}, [basename]);
}
}, [nodeName]);
const [searchParams] = useSearchParams();
const path = searchParams.get("path") || "";
const segments = path.split("/").filter(Boolean);
const providerStyles = getProviderStyles(provider);
return (
<nav className="flex mb-4" aria-label="Breadcrumb">
<ol className="inline-flex items-center space-x-1 md:space-x-3">
<li className="inline-flex items-center">
{provider && (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
to="/"
className={`inline-flex items-center h-7 gap-2 px-3 py-1 text-xs font-medium ${providerStyles.bg} ${providerStyles.text} ${providerStyles.darkBg} ${providerStyles.darkText} rounded-full hover:ring-2 hover:ring-gray-300 dark:hover:ring-gray-600 transition-all duration-200`}
to="/storage/dataobj"
className={`inline-flex items-center h-7 gap-2 px-3 py-1 text-xs font-medium ${providerStyles.bg} ${providerStyles.text} ${providerStyles.darkBg} ${providerStyles.darkText} rounded-full hover:ring-1 hover:ring-gray-300 dark:hover:ring-gray-600 transition-all duration-200`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -123,46 +134,36 @@ export const Breadcrumb: React.FC<BreadcrumbProps> = ({
>
<path d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z" />
</svg>
{provider}
{provider || ""}
</Link>
)}
</li>
{parts.map((part, index, array) => (
<li key={index}>
<div className="flex items-center">
<svg
className="w-3 h-3 text-gray-400 mx-1"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m1 9 4-4-4-4"
/>
</svg>
{!isLastPartClickable && index === array.length - 1 ? (
<span className="ml-1 text-sm font-medium text-gray-500 md:ml-2">
{part}
</span>
</BreadcrumbLink>
</BreadcrumbItem>
{segments.length > 0 && <BreadcrumbSeparator />}
{segments.map((segment, index) => {
const currentPath = segments.slice(0, index + 1).join("/");
const isLastItem = index === segments.length - 1;
return (
<React.Fragment key={currentPath}>
<BreadcrumbItem>
<BreadcrumbLink asChild>
{isLastItem ? (
<span className="text-gray-500">{segment}</span>
) : (
<Link
to={`/?path=${encodeURIComponent(
array.slice(0, index + 1).join("/")
to={`/storage/dataobj?path=${encodeURIComponent(
currentPath
)}`}
className="ml-1 text-sm font-medium text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 md:ml-2"
>
{part}
{segment}
</Link>
)}
</div>
</li>
))}
</ol>
</nav>
</BreadcrumbLink>
</BreadcrumbItem>
{index < segments.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
);
};
})}
</BreadcrumbList>
</Breadcrumb>
);
}

@ -0,0 +1,144 @@
import { useNavigate, useSearchParams, Link } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { FolderIcon, FileIcon, DownloadIcon } from "lucide-react";
import { ExplorerFile } from "@/types/explorer";
import { formatBytes } from "@/lib/utils";
import { DateHover } from "../common/date-hover";
import { Button } from "../ui/button";
interface FileListProps {
current: string;
parent: string | null;
files: ExplorerFile[];
folders: string[];
}
export function FileList({ current, parent, files, folders }: FileListProps) {
const navigate = useNavigate();
const [, setSearchParams] = useSearchParams();
const handleNavigate = (path: string) => {
setSearchParams({ path });
};
const handleFileClick = (file: ExplorerFile) => {
navigate(
`/storage/dataobj/metadata?path=${encodeURIComponent(
current + "/" + file.name
)}`
);
};
return (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow className="h-12">
<TableHead>Name</TableHead>
<TableHead>Modified</TableHead>
<TableHead>Size</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parent !== current && (
<TableRow
key="parent"
className="h-12 cursor-pointer hover:bg-muted/50"
onClick={() => handleNavigate(parent || "")}
>
<TableCell className="font-medium">
<div className="flex items-center">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
/>
</svg>
..
</div>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell></TableCell>
</TableRow>
)}
{folders.map((folder) => (
<TableRow
key={folder}
className="h-12 cursor-pointer hover:bg-muted/50"
onClick={() =>
handleNavigate(current ? `${current}/${folder}` : folder)
}
>
<TableCell className="font-medium">
<div className="flex items-center">
<FolderIcon className="mr-2 h-4 w-4" />
{folder}
</div>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell></TableCell>
</TableRow>
))}
{files.map((file) => (
<TableRow
key={file.name}
className="h-12 cursor-pointer hover:bg-muted/50"
onClick={(e) => {
if ((e.target as HTMLElement).closest("a[download]")) {
return;
}
handleFileClick(file);
}}
>
<TableCell className="font-medium">
<div className="flex items-center">
<FileIcon className="mr-2 h-4 w-4" />
{file.name}
</div>
</TableCell>
<TableCell>
<DateHover date={new Date(file.lastModified)} />
</TableCell>
<TableCell>{formatBytes(file.size)}</TableCell>
<TableCell>
<Button
variant="outline"
size="icon"
asChild
className="h-8 w-8"
>
<Link
to={file.downloadUrl}
target="_blank"
download
onClick={(e) => e.stopPropagation()}
>
<DownloadIcon className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

@ -0,0 +1,549 @@
import { Link } from "react-router-dom";
import { DownloadIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { formatBytes } from "@/lib/utils";
import { FileMetadataResponse } from "@/types/explorer";
import { DateHover } from "@/components/common/date-hover";
import { CopyButton } from "../common/copy-button";
import { CompressionRatio } from "../common/compression-ratio";
import { useState } from "react";
// Value type to badge styling mapping
const getValueTypeBadgeStyle = (valueType: string): string => {
switch (valueType) {
case "INT64":
return "bg-blue-500/20 text-blue-700 dark:bg-blue-500/30 dark:text-blue-300 hover:bg-blue-500/30";
case "BYTES":
return "bg-red-500/20 text-red-700 dark:bg-red-500/30 dark:text-red-300 hover:bg-red-500/30";
case "FLOAT64":
return "bg-purple-500/20 text-purple-700 dark:bg-purple-500/30 dark:text-purple-300 hover:bg-purple-500/30";
case "BOOL":
return "bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/30 dark:text-yellow-300 hover:bg-yellow-500/30";
case "STRING":
return "bg-green-500/20 text-green-700 dark:bg-green-500/30 dark:text-green-300 hover:bg-green-500/30";
case "TIMESTAMP":
return "bg-orange-500/20 text-orange-700 dark:bg-orange-500/30 dark:text-orange-300 hover:bg-orange-500/30";
default:
return "bg-gray-500/20 text-gray-700 dark:bg-gray-500/30 dark:text-gray-300 hover:bg-gray-500/30";
}
};
interface FileMetadataViewProps {
metadata: FileMetadataResponse;
filename: string;
downloadUrl: string;
}
export function FileMetadataView({
metadata,
filename,
downloadUrl,
}: FileMetadataViewProps) {
const [expandedSectionIndex, setExpandedSectionIndex] = useState<
number | null
>(null);
const [expandedColumns, setExpandedColumns] = useState<
Record<string, boolean>
>({});
const toggleSection = (sectionIndex: number) => {
setExpandedSectionIndex(
expandedSectionIndex === sectionIndex ? null : sectionIndex
);
};
const toggleColumn = (sectionIndex: number, columnIndex: number) => {
const key = `${sectionIndex}-${columnIndex}`;
setExpandedColumns((prev) => ({
...prev,
[key]: !prev[key],
}));
};
// Calculate file-level stats
const totalCompressed = metadata.sections.reduce(
(sum, section) => sum + section.totalCompressedSize,
0
);
const totalUncompressed = metadata.sections.reduce(
(sum, section) => sum + section.totalUncompressedSize,
0
);
// Get stream and log counts from first column of each section
const streamSection = metadata.sections.filter(
(s) => s.type === "SECTION_TYPE_STREAMS"
);
const logSection = metadata.sections.filter(
(s) => s.type === "SECTION_TYPE_LOGS"
);
const streamCount = streamSection?.reduce(
(sum, sec) => sum + (sec.columns[0].rows_count || 0),
0
);
const logCount = logSection?.reduce(
(sum, sec) => sum + (sec.columns[0].rows_count || 0),
0
);
return (
<Card className="w-full">
<FileHeader
filename={filename}
downloadUrl={downloadUrl}
lastModified={metadata.lastModified}
/>
<CardContent className="space-y-8">
<HeadlineStats
totalCompressed={totalCompressed}
totalUncompressed={totalUncompressed}
sections={metadata.sections}
streamCount={streamCount}
logCount={logCount}
/>
<SectionsList
sections={metadata.sections}
expandedSectionIndex={expandedSectionIndex}
expandedColumns={expandedColumns}
onToggleSection={toggleSection}
onToggleColumn={toggleColumn}
/>
</CardContent>
</Card>
);
}
// Sub-components
interface FileHeaderProps {
filename: string;
downloadUrl: string;
lastModified: string;
}
function FileHeader({ filename, downloadUrl, lastModified }: FileHeaderProps) {
return (
<CardHeader className="space-y-4">
<div className="flex items-center justify-between">
<CardTitle className="text-2xl font-semibold tracking-tight">
Thor Dataobj File
</CardTitle>
<Button asChild variant="outline">
<Link to={downloadUrl} target="_blank" download>
<DownloadIcon className="h-4 w-4 mr-2" />
Download
</Link>
</Button>
</div>
<CardDescription className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-foreground">
{filename}
</span>
<CopyButton text={filename} />
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Last Modified:</span>
<DateHover date={new Date(lastModified)} />
</div>
</div>
</div>
</CardDescription>
</CardHeader>
);
}
interface HeadlineStatsProps {
totalCompressed: number;
totalUncompressed: number;
sections: FileMetadataResponse["sections"];
streamCount?: number;
logCount?: number;
}
function HeadlineStats({
totalCompressed,
totalUncompressed,
sections,
streamCount,
logCount,
}: HeadlineStatsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="rounded-lg bg-muted/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Compression</div>
<CompressionRatio
compressed={totalCompressed}
uncompressed={totalUncompressed}
showVisualization
/>
<div className="text-xs text-muted-foreground mt-2">
{formatBytes(totalCompressed)} {formatBytes(totalUncompressed)}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Sections</div>
<div className="font-medium text-lg">{sections.length}</div>
<div className="text-xs text-muted-foreground mt-2">
{sections.map((s) => s.type).join(", ")}
</div>
</div>
{streamCount && (
<div className="rounded-lg bg-muted/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Stream Count</div>
<div className="font-medium text-lg">
{streamCount.toLocaleString()}
</div>
</div>
)}
{logCount && (
<div className="rounded-lg bg-muted/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Log Count</div>
<div className="font-medium text-lg">{logCount.toLocaleString()}</div>
</div>
)}
</div>
);
}
interface SectionsListProps {
sections: FileMetadataResponse["sections"];
expandedSectionIndex: number | null;
expandedColumns: Record<string, boolean>;
onToggleSection: (index: number) => void;
onToggleColumn: (sectionIndex: number, columnIndex: number) => void;
}
function SectionsList({
sections,
expandedSectionIndex,
expandedColumns,
onToggleSection,
onToggleColumn,
}: SectionsListProps) {
return (
<div className="divide-y divide-border">
{sections.map((section, sectionIndex) => (
<Section
key={sectionIndex}
section={section}
sectionIndex={sectionIndex}
isExpanded={expandedSectionIndex === sectionIndex}
expandedColumns={expandedColumns}
onToggle={() => onToggleSection(sectionIndex)}
onToggleColumn={(columnIndex) =>
onToggleColumn(sectionIndex, columnIndex)
}
/>
))}
</div>
);
}
interface SectionProps {
section: FileMetadataResponse["sections"][0];
sectionIndex: number;
isExpanded: boolean;
expandedColumns: Record<string, boolean>;
onToggle: () => void;
onToggleColumn: (columnIndex: number) => void;
}
function Section({
section,
sectionIndex,
isExpanded,
expandedColumns,
onToggle,
onToggleColumn,
}: SectionProps) {
return (
<div className="py-4">
<button
className="w-full flex justify-between items-center py-4 px-6 rounded-lg hover:bg-accent/50 transition-colors"
onClick={onToggle}
>
<h3 className="text-lg font-semibold">
Section #{sectionIndex + 1}: {section.type}
</h3>
<svg
className={`w-5 h-5 transform transition-transform duration-300 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isExpanded && (
<div className="mt-6 px-6">
<SectionStats section={section} />
<ColumnsList
columns={section.columns}
sectionIndex={sectionIndex}
expandedColumns={expandedColumns}
onToggleColumn={onToggleColumn}
/>
</div>
)}
</div>
);
}
interface SectionStatsProps {
section: FileMetadataResponse["sections"][0];
}
function SectionStats({ section }: SectionStatsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="rounded-lg bg-secondary/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Compression</div>
<CompressionRatio
compressed={section.totalCompressedSize}
uncompressed={section.totalUncompressedSize}
showVisualization
/>
<div className="text-xs text-muted-foreground mt-2">
{formatBytes(section.totalCompressedSize)} {" "}
{formatBytes(section.totalUncompressedSize)}
</div>
</div>
<div className="rounded-lg bg-secondary/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Column Count</div>
<div className="font-medium text-lg">{section.columnCount}</div>
</div>
<div className="rounded-lg bg-secondary/50 p-6 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Type</div>
<div className="font-medium text-lg flex items-center gap-2">
<Badge variant="outline" className="font-mono">
{section.type}
</Badge>
</div>
</div>
</div>
);
}
interface ColumnsListProps {
columns: FileMetadataResponse["sections"][0]["columns"];
sectionIndex: number;
expandedColumns: Record<string, boolean>;
onToggleColumn: (columnIndex: number) => void;
}
function ColumnsList({
columns,
sectionIndex,
expandedColumns,
onToggleColumn,
}: ColumnsListProps) {
return (
<div className="space-y-6">
<h4 className="text-lg font-medium">Columns ({columns.length})</h4>
<div className="space-y-4">
{columns.map((column, columnIndex) => (
<Column
key={columnIndex}
column={column}
isExpanded={expandedColumns[`${sectionIndex}-${columnIndex}`]}
onToggle={() => onToggleColumn(columnIndex)}
/>
))}
</div>
</div>
);
}
interface ColumnProps {
column: FileMetadataResponse["sections"][0]["columns"][0];
isExpanded: boolean;
onToggle: () => void;
}
function Column({ column, isExpanded, onToggle }: ColumnProps) {
return (
<Card className="bg-card/50">
<button
className="w-full flex justify-between items-center p-6 hover:bg-accent/50 transition-colors rounded-t-lg"
onClick={onToggle}
>
<div>
<h5 className="font-medium text-lg">
{column.name ? `${column.name} (${column.type})` : column.type}
</h5>
<div className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
"font-mono text-xs",
getValueTypeBadgeStyle(column.value_type)
)}
>
{column.value_type}
</Badge>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-sm font-medium flex items-center gap-2">
Compression:
<Badge variant="outline" className="font-mono">
{column.compression || "NONE"}
</Badge>
</div>
<svg
className={`w-4 h-4 transform transition-transform ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
{isExpanded && (
<CardContent className="pt-6">
<ColumnStats column={column} />
{column.pages.length > 0 && <ColumnPages pages={column.pages} />}
</CardContent>
)}
</Card>
);
}
interface ColumnStatsProps {
column: FileMetadataResponse["sections"][0]["columns"][0];
}
function ColumnStats({ column }: ColumnStatsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="rounded-lg bg-muted p-6">
<div className="text-sm text-muted-foreground mb-2 flex items-center gap-2">
<Badge variant="outline" className="font-mono">
{column.compression || "NONE"}
</Badge>
</div>
<div className="font-medium">
<CompressionRatio
compressed={column.compressed_size}
uncompressed={column.uncompressed_size}
/>
</div>
<div className="text-xs text-muted-foreground mt-2">
{formatBytes(column.compressed_size)} {" "}
{formatBytes(column.uncompressed_size)}
</div>
</div>
<div className="rounded-lg bg-muted p-6">
<div className="text-sm text-muted-foreground mb-2">Rows</div>
<div className="font-medium text-lg">
{column.rows_count.toLocaleString()}
</div>
</div>
<div className="rounded-lg bg-muted p-6">
<div className="text-sm text-muted-foreground mb-2">Values Count</div>
<div className="font-medium text-lg">
{column.values_count.toLocaleString()}
</div>
</div>
<div className="rounded-lg bg-muted p-6">
<div className="text-sm text-muted-foreground mb-2">Offset</div>
<div className="font-medium text-lg">
{formatBytes(column.metadata_offset)}
</div>
</div>
</div>
);
}
interface ColumnPagesProps {
pages: FileMetadataResponse["sections"][0]["columns"][0]["pages"];
}
function ColumnPages({ pages }: ColumnPagesProps) {
return (
<div className="mt-8">
<h6 className="text-base font-medium mb-4">Pages ({pages.length})</h6>
<div className="rounded-lg border border-border overflow-hidden bg-background">
<table className="w-full">
<thead>
<tr className="bg-secondary/50 border-b border-border">
<th className="text-left p-4 font-medium text-muted-foreground">
#
</th>
<th className="text-left p-4 font-medium text-muted-foreground">
Rows
</th>
<th className="text-left p-4 font-medium text-muted-foreground">
Values
</th>
<th className="text-left p-4 font-medium text-muted-foreground">
Encoding
</th>
<th className="text-left p-4 font-medium text-muted-foreground">
Compression
</th>
</tr>
</thead>
<tbody>
{pages.map((page, pageIndex) => (
<tr
key={pageIndex}
className="border-t border-border hover:bg-accent/50 transition-colors"
>
<td className="p-4">{pageIndex + 1}</td>
<td className="p-4">{page.rows_count.toLocaleString()}</td>
<td className="p-4">{page.values_count.toLocaleString()}</td>
<td className="p-4">
<Badge variant="outline" className="font-mono">
{page.encoding}
</Badge>
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<CompressionRatio
compressed={page.compressed_size}
uncompressed={page.uncompressed_size}
/>
<span className="text-xs text-muted-foreground">
({formatBytes(page.compressed_size)} {" "}
{formatBytes(page.uncompressed_size)})
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

@ -0,0 +1,5 @@
export * from "./ui";
export * from "./shared";
export * from "./nodes";
export * from "./common";
export * from "./version-display";

@ -0,0 +1,83 @@
import { ChevronsUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
interface DataTableColumnHeaderProps {
title: string;
field: "name" | "target" | "version" | "buildDate";
sortField: string;
sortDirection: "asc" | "desc";
onSort: (field: "name" | "target" | "version" | "buildDate") => void;
}
export function DataTableColumnHeader({
title,
field,
sortField,
sortDirection,
onSort,
}: DataTableColumnHeaderProps) {
const isCurrentSort = sortField === field;
const handleSort = (direction: "asc" | "desc") => {
if (sortField === field && sortDirection === direction) {
return;
}
onSort(field);
};
return (
<div className="flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 hover:bg-muted/50 data-[state=open]:bg-muted/50"
>
<div className="flex items-center">
<span>{title}</span>
{isCurrentSort ? (
sortDirection === "desc" ? (
<ArrowDown className="ml-2 h-4 w-4" />
) : (
<ArrowUp className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => handleSort("asc")}
className={cn(
"cursor-pointer",
isCurrentSort && sortDirection === "asc" && "bg-accent"
)}
>
<ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSort("desc")}
className={cn(
"cursor-pointer",
isCurrentSort && sortDirection === "desc" && "bg-accent"
)}
>
<ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

@ -0,0 +1,12 @@
export * from "./data-table-column-header";
export * from "./log-level-select";
export * from "./node-filters";
export * from "./node-list";
export * from "./node-status-indicator";
export * from "./pprof-controls";
export * from "./service-state-distribution";
export * from "./service-table";
export * from "./status-badge";
export * from "./storage-type-indicator";
export * from "./target-distribution-chart";
export * from "./version-information";

@ -0,0 +1,88 @@
"use client";
import { Check, AlertCircle } from "lucide-react";
import { useLogLevel } from "@/hooks/use-log-level";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
interface LogLevelSelectProps {
nodeName: string;
className?: string;
}
export function LogLevelSelect({ nodeName, className }: LogLevelSelectProps) {
const { logLevel, isLoading, error, success, setLogLevel } =
useLogLevel(nodeName);
const handleValueChange = (value: string) => {
setLogLevel(value);
};
return (
<div className="relative flex items-center gap-2">
<Select
value={logLevel}
onValueChange={handleValueChange}
disabled={isLoading}
>
<SelectTrigger
className={cn(
"w-[180px]",
className,
isLoading && "opacity-50 cursor-not-allowed"
)}
>
<SelectValue placeholder="Select log level" />
</SelectTrigger>
<SelectContent>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Success/Error Indicator with Tooltip */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute -right-6 transition-all duration-300 ease-in-out",
success || error
? "opacity-100 translate-x-0"
: "opacity-0 translate-x-2"
)}
>
{success && (
<Check className="h-4 w-4 text-green-500 animate-in zoom-in-50 duration-300" />
)}
{error && (
<AlertCircle className="h-4 w-4 text-red-500 animate-in zoom-in-50 duration-300" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
{success && "Log level updated successfully"}
{error && error}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}

@ -0,0 +1,92 @@
import React from "react";
import { NodeState, ALL_NODE_STATES } from "../../types/cluster";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MultiSelect } from "@/components/common/multi-select";
import { RefreshCw } from "lucide-react";
interface NodeFiltersProps {
nameFilter: string;
targetFilter: string[];
selectedStates: NodeState[];
onNameFilterChange: (value: string) => void;
onTargetFilterChange: (value: string[]) => void;
onStatesChange: (states: NodeState[]) => void;
onRefresh: () => void;
availableTargets: string[];
isLoading?: boolean;
}
const NodeFilters: React.FC<NodeFiltersProps> = ({
nameFilter,
targetFilter,
selectedStates,
onNameFilterChange,
onTargetFilterChange,
onStatesChange,
onRefresh,
availableTargets,
}) => {
const stateOptions = ALL_NODE_STATES.map((state) => ({
label: state,
value: state,
}));
const handleStateChange = (values: string[]) => {
onStatesChange(values as NodeState[]);
};
return (
<div className="grid grid-cols-[auto_1fr_auto] gap-x-4 gap-y-2">
<div className="space-y-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
Node filters
</label>
<Input
value={nameFilter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onNameFilterChange(e.target.value)
}
placeholder="Filter by node name..."
className="w-[300px]"
/>
<MultiSelect
options={availableTargets.map((target) => ({
value: target,
label: target,
}))}
selected={targetFilter}
onChange={onTargetFilterChange}
placeholder="All Targets"
className="w-[300px]"
/>
</div>
</div>
<div className="space-y-1.5 self-end">
<label className="text-sm font-medium text-muted-foreground">
Service states
</label>
<MultiSelect
options={stateOptions}
selected={selectedStates}
onChange={handleStateChange}
placeholder="Filter nodes by service states..."
className="w-full min-w-[300px]"
/>
</div>
<div className="self-end">
<Button
onClick={onRefresh}
size="sm"
variant="outline"
className="h-9 w-9"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
);
};
export default NodeFilters;

@ -0,0 +1,193 @@
import React from "react";
import { formatDistanceToNow, parseISO, isValid } from "date-fns";
import { Member } from "@/types/cluster";
import StatusBadge from "@/components/nodes/status-badge";
import { ReadinessIndicator } from "@/components/nodes/readiness-indicator";
import { DataTableColumnHeader } from "@/components/common/data-table-column-header";
import { Button } from "@/components/ui/button";
import { ArrowRightCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type NodeSortField = "name" | "target" | "version" | "buildDate";
interface NodeListProps {
nodes: { [key: string]: Member };
sortField: NodeSortField;
sortDirection: "asc" | "desc";
onSort: (field: NodeSortField) => void;
}
interface NodeRowProps {
name: string;
node: Member;
onNavigate: (name: string) => void;
}
const formatBuildDate = (dateStr: string) => {
try {
const date = parseISO(dateStr);
if (!isValid(date)) {
return "Invalid date";
}
return formatDistanceToNow(date, { addSuffix: true });
} catch (error) {
console.warn("Error parsing date:", dateStr, error);
return "Invalid date";
}
};
const NodeRow: React.FC<NodeRowProps> = ({ name, node, onNavigate }) => {
return (
<TableRow
key={name}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => onNavigate(name)}
>
<TableCell className="font-medium">{name}</TableCell>
<TableCell>{node.target}</TableCell>
<TableCell className="font-mono text-sm">{node.build.version}</TableCell>
<TableCell>{formatBuildDate(node.build.buildDate)}</TableCell>
<TableCell>
<StatusBadge services={node.services} error={node.error} />
</TableCell>
<TableCell>
<ReadinessIndicator
isReady={node.ready?.isReady}
message={node.ready?.message}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onNavigate(name);
}}
>
<ArrowRightCircle className="h-4 w-4" />
<span className="sr-only">View details</span>
</Button>
</TableCell>
</TableRow>
);
};
const NodeList: React.FC<NodeListProps> = ({
nodes,
sortField,
sortDirection,
onSort,
}) => {
const navigate = useNavigate();
const compareDates = (dateStrA: string, dateStrB: string) => {
const dateA = parseISO(dateStrA);
const dateB = parseISO(dateStrB);
if (!isValid(dateA) && !isValid(dateB)) return 0;
if (!isValid(dateA)) return 1;
if (!isValid(dateB)) return -1;
return dateA.getTime() - dateB.getTime();
};
const sortedNodes = Object.entries(nodes).sort(([aKey, a], [bKey, b]) => {
let comparison = 0;
switch (sortField) {
case "name":
comparison = aKey.localeCompare(bKey);
break;
case "target":
comparison = a.target.localeCompare(b.target);
break;
case "version":
comparison = a.build.version.localeCompare(b.build.version);
break;
case "buildDate":
comparison = compareDates(a.build.buildDate, b.build.buildDate);
break;
}
return sortDirection === "asc" ? comparison : -comparison;
});
const handleNavigate = (name: string) => {
navigate(`/nodes/${name}`);
};
return (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[300px]">
<DataTableColumnHeader<NodeSortField>
title="Node Name"
field="name"
sortField={sortField}
sortDirection={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className="w-[200px]">
<DataTableColumnHeader<NodeSortField>
title="Target"
field="target"
sortField={sortField}
sortDirection={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className="w-[200px]">
<DataTableColumnHeader<NodeSortField>
title="Version"
field="version"
sortField={sortField}
sortDirection={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className="w-[200px]">
<DataTableColumnHeader<NodeSortField>
title="Build Date"
field="buildDate"
sortField={sortField}
sortDirection={sortDirection}
onSort={onSort}
/>
</TableHead>
<TableHead className="w-[150px]">Status</TableHead>
<TableHead className="w-[50px]">Ready</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedNodes.map(([name, node]) => (
<NodeRow
key={name}
name={name}
node={node}
onNavigate={handleNavigate}
/>
))}
{sortedNodes.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="text-muted-foreground">No nodes found</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
export default NodeList;

@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface NodeStatusIndicatorProps {
nodeName: string;
className?: string;
}
interface NodeStatus {
isReady: boolean;
message: string;
}
export function NodeStatusIndicator({
nodeName,
className,
}: NodeStatusIndicatorProps) {
const [status, setStatus] = useState<NodeStatus>({
isReady: false,
message: "Checking status...",
});
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const checkStatus = async () => {
try {
const response = await fetch(`/ui/api/v1/proxy/${nodeName}/ready`);
const text = await response.text();
setStatus({
isReady: response.ok && text.includes("ready"),
message: response.ok ? "Ready" : text,
});
} catch (error) {
setStatus({
isReady: false,
message:
error instanceof Error ? error.message : "Failed to check status",
});
}
};
// Initial check
checkStatus();
// Set up the status check interval
const statusInterval = setInterval(checkStatus, 3000);
// Set up the blink interval
const blinkInterval = setInterval(() => {
setIsVisible((prev) => !prev);
}, 1000);
// Cleanup intervals on unmount
return () => {
clearInterval(statusInterval);
clearInterval(blinkInterval);
};
}, [nodeName]);
return (
<div className={cn("flex items-center gap-2", className)}>
<span
className={cn(
"text-sm",
status.isReady ? "text-muted-foreground" : "text-red-500"
)}
>
{status.message}
</span>
<div
className={cn(
"h-2.5 w-2.5 rounded-full transition-opacity duration-150",
status.isReady ? "bg-green-500" : "bg-red-500",
isVisible ? "opacity-100" : "opacity-30"
)}
/>
</div>
);
}

@ -0,0 +1,124 @@
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface PprofControlsProps {
nodeName: string;
}
const pprofTypes = [
{
name: "allocs",
description: "A sampling of all past memory allocations",
},
{
name: "block",
description:
"Stack traces that led to blocking on synchronization primitives",
},
{
name: "heap",
description: "A sampling of memory allocations of live objects",
},
{
name: "mutex",
description: "Stack traces of holders of contended mutexes",
},
{
name: "profile",
urlSuffix: "?seconds=15",
description: "CPU profile (15 seconds)",
displayName: "profile",
},
{
name: "goroutine",
description: "Stack traces of all current goroutines (debug=1)",
variants: [
{
suffix: "?debug=0",
label: "Basic",
description: "Basic goroutine info",
},
{
suffix: "?debug=1",
label: "Standard",
description: "Standard goroutine stack traces",
},
{
suffix: "?debug=2",
label: "Full",
description: "Full goroutine stack dump with additional info",
},
],
},
{
name: "threadcreate",
description: "Stack traces that led to the creation of new OS threads",
urlSuffix: "?debug=1",
displayName: "threadcreate",
},
{
name: "trace",
description: "A trace of execution of the current program",
urlSuffix: "?debug=1",
displayName: "trace",
},
];
export function PprofControls({ nodeName }: PprofControlsProps) {
const downloadPprof = (type: string) => {
window.open(`/ui/api/v1/proxy/${nodeName}/debug/pprof/${type}`, "_blank");
};
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Profiling Tools:</span>
<div className="flex flex-wrap gap-2">
{pprofTypes.map((type) => {
if (type.variants) {
return type.variants.map((variant) => (
<Tooltip key={`${type.name}${variant.suffix}`}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
downloadPprof(`${type.name}${variant.suffix}`)
}
>
{`${type.name} (${variant.label})`}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{variant.description}</p>
</TooltipContent>
</Tooltip>
));
}
return (
<Tooltip key={type.name}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
downloadPprof(`${type.name}${type.urlSuffix || ""}`)
}
>
{type.displayName || type.name}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{type.description}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

@ -0,0 +1,41 @@
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ReadinessIndicatorProps {
isReady?: boolean;
message?: string;
className?: string;
}
export function ReadinessIndicator({
isReady,
message,
className,
}: ReadinessIndicatorProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("flex items-center gap-2", className)}>
<div
className={cn(
"h-2.5 w-2.5 rounded-full",
isReady ? "bg-green-500" : "bg-red-500"
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm">
{message || (isReady ? "Ready" : "Not Ready")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

@ -0,0 +1,109 @@
import { useMemo } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { NodeState } from "@/types/cluster";
const STATE_COLORS: Record<NodeState, string> = {
Running: "#10B981", // emerald-500
Starting: "#F59E0B", // amber-500
New: "#3B82F6", // blue-500
Stopping: "#F59E0B", // amber-500
Terminated: "#6B7280", // gray-500
Failed: "#EF4444", // red-500
};
interface ServiceStateDistributionProps {
services: Array<{ service: string; status: string }>;
}
export function ServiceStateDistribution({
services,
}: ServiceStateDistributionProps) {
const data = useMemo(() => {
const stateCounts = services.reduce((acc, { status }) => {
const state = status as NodeState;
acc.set(state, (acc.get(state) || 0) + 1);
return acc;
}, new Map<NodeState, number>());
return Array.from(stateCounts.entries())
.sort((a, b) => b[1] - a[1])
.map(([state, count]) => ({
name: state,
value: count,
color: STATE_COLORS[state],
}));
}, [services]);
const total = useMemo(() => services.length, [services]);
if (data.length === 0) {
return null;
}
return (
<div className="h-[180px] w-full flex items-center">
<div className="flex-1 relative">
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-10">
<div className="text-2xl font-bold">{total}</div>
<div className="text-xs text-muted-foreground">Services</div>
</div>
<ResponsiveContainer width="100%" height={180}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={70}
innerRadius={50}
dataKey="value"
paddingAngle={2}
strokeWidth={2}
>
{data.map((entry) => (
<Cell
key={`cell-${entry.name}`}
fill={entry.color}
stroke="hsl(var(--background))"
/>
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || !payload[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-background border rounded-lg shadow-lg px-3 py-2 flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-sm"
style={{ backgroundColor: data.color }}
/>
<span className="text-sm font-medium">{data.name}</span>
<span className="text-sm font-semibold">{data.value}</span>
</div>
);
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col gap-1.5 min-w-[120px] pl-4">
{data.map((item) => (
<div
key={item.name}
className="flex items-center justify-between gap-2 text-sm"
>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-muted-foreground">{item.name}</span>
</div>
<span className="font-medium tabular-nums">{item.value}</span>
</div>
))}
</div>
</div>
);
}

@ -0,0 +1,64 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
interface Service {
service: string;
status: string;
}
interface ServiceTableProps {
services: Service[];
}
const getStatusColor = (status: string) => {
switch (status) {
case "Running":
return "text-green-600 dark:text-green-400";
case "Starting":
return "text-yellow-600 dark:text-yellow-400";
case "Failed":
return "text-red-600 dark:text-red-400";
case "New":
return "text-blue-600 dark:text-blue-400";
case "Terminated":
return "text-gray-600 dark:text-gray-400";
default:
return "text-gray-600 dark:text-gray-400";
}
};
export function ServiceTable({ services }: ServiceTableProps) {
return (
<ScrollArea className="h-[180px] rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((service) => (
<TableRow key={service.service} className="hover:bg-muted/50">
<TableCell className="font-medium">{service.service}</TableCell>
<TableCell
className={`text-right ${getStatusColor(
service.status
)} font-medium`}
>
{service.status}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
);
}

@ -0,0 +1,109 @@
import React from "react";
import { ServiceState } from "../../types/cluster";
import { Badge } from "@/components/ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface StatusBadgeProps {
services: ServiceState[];
error?: string;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ services, error }) => {
const getStatusInfo = () => {
if (error) {
return {
className:
"bg-red-500 dark:bg-red-500/80 hover:bg-red-600 dark:hover:bg-red-500 text-white border-transparent",
tooltip: `Error: ${error}`,
status: "error",
};
}
const allRunning = services.every((s) => s.status === "Running");
const onlyStartingOrRunning = services.every(
(s) => s.status === "Starting" || s.status === "Running"
);
if (allRunning) {
return {
className:
"bg-green-500 dark:bg-green-500/80 hover:bg-green-600 dark:hover:bg-green-500 text-white border-transparent",
status: "healthy",
};
} else if (onlyStartingOrRunning) {
return {
className:
"bg-yellow-500 dark:bg-yellow-500/80 hover:bg-yellow-600 dark:hover:bg-yellow-500 text-white border-transparent",
status: "pending",
};
} else {
return {
className:
"bg-red-500 dark:bg-red-500/80 hover:bg-red-600 dark:hover:bg-red-500 text-white border-transparent",
status: "unhealthy",
};
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "Running":
return "text-green-600 dark:text-green-400";
case "Starting":
return "text-yellow-600 dark:text-yellow-400";
case "Failed":
return "text-red-600 dark:text-red-400";
case "Terminated":
return "text-gray-600 dark:text-gray-400";
case "Stopping":
return "text-orange-600 dark:text-orange-400";
case "New":
return "text-blue-600 dark:text-blue-400";
default:
return "text-gray-600 dark:text-gray-400";
}
};
const { className } = getStatusInfo();
return (
<HoverCard>
<HoverCardTrigger>
<button type="button">
<Badge className={className}>{services.length} services</Badge>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
sideOffset={5}
>
<div className="space-y-2">
<div className="font-medium border-b border-gray-200 dark:border-gray-700 pb-1">
Service Status
</div>
<div className="space-y-1">
{services.map((service, idx) => (
<div key={idx} className="flex justify-between items-center">
<span className="mr-4 font-medium">{service.service}</span>
<span className={`${getStatusColor(service.status)}`}>
{service.status}
</span>
</div>
))}
</div>
{error && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-red-600 dark:text-red-400">
{error}
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
);
};
export default StatusBadge;

@ -0,0 +1,73 @@
import { cn } from "@/lib/utils";
interface StorageTypeIndicatorProps {
type: string;
className?: string;
}
const storageTypeColors: Record<string, string> = {
// AWS related
aws: "text-yellow-600 bg-yellow-100 dark:bg-yellow-950 dark:text-yellow-400",
"aws-dynamo":
"text-yellow-600 bg-yellow-100 dark:bg-yellow-950 dark:text-yellow-400",
s3: "text-yellow-600 bg-yellow-100 dark:bg-yellow-950 dark:text-yellow-400",
// Azure
azure: "text-blue-600 bg-blue-100 dark:bg-blue-950 dark:text-blue-400",
// GCP related
gcp: "text-blue-600 bg-blue-100 dark:bg-blue-950 dark:text-blue-400",
"gcp-columnkey":
"text-blue-600 bg-blue-100 dark:bg-blue-950 dark:text-blue-400",
gcs: "text-blue-600 bg-blue-100 dark:bg-blue-950 dark:text-blue-400",
// Alibaba Cloud
alibabacloud:
"text-orange-600 bg-orange-100 dark:bg-orange-950 dark:text-orange-400",
// Local storage types
filesystem: "text-gray-600 bg-gray-100 dark:bg-gray-800 dark:text-gray-400",
local: "text-gray-600 bg-gray-100 dark:bg-gray-800 dark:text-gray-400",
// Database types
boltdb:
"text-emerald-600 bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-400",
cassandra: "text-blue-700 bg-blue-100 dark:bg-blue-950 dark:text-blue-400",
bigtable: "text-red-600 bg-red-100 dark:bg-red-950 dark:text-red-400",
"bigtable-hashed":
"text-red-600 bg-red-100 dark:bg-red-950 dark:text-red-400",
// Other cloud providers
bos: "text-cyan-600 bg-cyan-100 dark:bg-cyan-950 dark:text-cyan-400",
cos: "text-green-600 bg-green-100 dark:bg-green-950 dark:text-green-400",
swift:
"text-orange-600 bg-orange-100 dark:bg-orange-950 dark:text-orange-400",
// Special types
inmemory:
"text-purple-600 bg-purple-100 dark:bg-purple-950 dark:text-purple-400",
"grpc-store":
"text-indigo-600 bg-indigo-100 dark:bg-indigo-950 dark:text-indigo-400",
};
export function StorageTypeIndicator({
type,
className,
}: StorageTypeIndicatorProps) {
const normalizedType = type.toLowerCase();
const colorClasses =
storageTypeColors[normalizedType] ||
"text-gray-600 bg-gray-100 dark:bg-gray-800 dark:text-gray-400";
return (
<span
className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
colorClasses,
className
)}
>
{normalizedType}
</span>
);
}

@ -0,0 +1,90 @@
import { useMemo } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { Member } from "@/types/cluster";
// Use theme chart colors directly
const getChartColor = (index: number): string => {
return `hsl(var(--chart-${(index % 6) + 1}))`;
};
interface TargetDistributionChartProps {
nodes: { [key: string]: Member };
}
export function TargetDistributionChart({
nodes,
}: TargetDistributionChartProps) {
const data = useMemo(() => {
const targetCounts = new Map<string, number>();
Object.values(nodes).forEach((node) => {
const target = node.target || "unknown";
targetCounts.set(target, (targetCounts.get(target) || 0) + 1);
});
return Array.from(targetCounts.entries())
.sort((a, b) => b[1] - a[1])
.map(([name, value], index) => ({
name,
value,
color: getChartColor(index),
}));
}, [nodes]);
const totalNodes = useMemo(() => {
return Object.keys(nodes).length;
}, [nodes]);
if (data.length === 0) {
return null;
}
return (
<div className="w-full h-[120px] relative">
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-xl font-bold">{totalNodes}</div>
<div className="text-xs text-muted-foreground">Nodes</div>
</div>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={60}
innerRadius={42}
fill="hsl(var(--chart-1))"
dataKey="value"
paddingAngle={1}
strokeWidth={1}
>
{data.map((entry, index) => (
<Cell
key={`cell-${entry.name}`}
fill={getChartColor(index)}
stroke="hsl(var(--background))"
/>
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || !payload[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-background border rounded-lg shadow-lg px-3 py-2 flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-sm"
style={{ backgroundColor: data.color }}
/>
<span className="text-sm font-medium">{data.name}</span>
<span className="text-sm font-semibold">{data.value}</span>
</div>
);
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save