mirror of https://github.com/grafana/loki
feat: Distributed Operational UI (#16097)
parent
d1e0fa7597
commit
dbf2befc1d
@ -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. |
||||
@ -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 |
||||
} |
||||
}) |
||||
} |
||||
@ -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> |
||||
@ -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", |
||||
}, |
||||
}, |
||||
}); |
||||
@ -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> |
||||
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" |
||||
} |
||||
} |
||||
@ -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> |
||||
); |
||||
} |
||||
@ -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…
Reference in new issue