-
Kvrocks Controller UI
-
Work in progress...
-
- Click here to submit your suggestions
-
+
+ {/* Hero Section */}
+
+
+
+
+
+
+
+ Apache Kvrocks Controller
+
+
+
+ A web management interface for Apache Kvrocks clusters, enabling efficient distribution, monitoring, and maintenance of your Redis compatible database infrastructure.
+
+
+
+ router.push('/namespaces')}
+ >
+ Get Started
+
+
+
+ Submit Feedback
+
+
+
+
+
+ {/* Features Section */}
+
+
+
+ Key Features
+
+
+
+ {features.map((feature, index) => (
+
+
+
+ {feature.icon}
+
+
+ {feature.title}
+
+
+ {feature.description}
+
+
+
+ ))}
+
+
+
+
+ {/* Resources Section */}
+
+
+
+ Resources
+
+
+
+ {resources.map((resource, index) => (
+
+
+
+
+
+ {resource.icon}
+
+
+
+ {resource.title}
+
+
+
+ {resource.description}
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Footer */}
+
+
+
+ Copyright © {currentYear} The Apache Software Foundation. Apache Kvrocks, Kvrocks, and its feather logo are trademarks of The Apache Software Foundation. Redis and its cube logo are registered trademarks of Redis Ltd. Apache Kvrocks Controller is released under Apache License, Version 2.0.
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/webui/src/app/theme-provider.tsx b/webui/src/app/theme-provider.tsx
new file mode 100644
index 00000000..1b7e18fa
--- /dev/null
+++ b/webui/src/app/theme-provider.tsx
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+"use client";
+
+import { createContext, useContext, useEffect, useState } from "react";
+import { ThemeProvider as MuiThemeProvider, createTheme } from "@mui/material/styles";
+import CssBaseline from "@mui/material/CssBaseline";
+
+type ThemeContextType = {
+ isDarkMode: boolean;
+ toggleTheme: () => void;
+};
+
+const ThemeContext = createContext
({
+ isDarkMode: false,
+ toggleTheme: () => {}
+});
+
+export const useTheme = () => useContext(ThemeContext);
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [isDarkMode, setIsDarkMode] = useState(false);
+
+ useEffect(() => {
+ // Check if user has already set a preference
+ const storedTheme = localStorage.getItem("theme");
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+
+ if (storedTheme === "dark" || (!storedTheme && prefersDark)) {
+ setIsDarkMode(true);
+ document.documentElement.classList.add("dark");
+ } else {
+ setIsDarkMode(false);
+ document.documentElement.classList.remove("dark");
+ }
+ }, []);
+
+ const toggleTheme = () => {
+ setIsDarkMode((prev) => {
+ const newMode = !prev;
+ if (newMode) {
+ document.documentElement.classList.add("dark");
+ localStorage.setItem("theme", "dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ localStorage.setItem("theme", "light");
+ }
+ return newMode;
+ });
+ };
+
+ // Create MUI theme based on current mode
+ const theme = createTheme({
+ palette: {
+ mode: isDarkMode ? "dark" : "light",
+ primary: {
+ main: "#1976d2",
+ light: "#42a5f5",
+ dark: "#1565c0",
+ contrastText: "#fff",
+ },
+ secondary: {
+ main: "#9c27b0",
+ light: "#ba68c8",
+ dark: "#7b1fa2",
+ contrastText: "#fff",
+ },
+ background: {
+ default: isDarkMode ? "#121212" : "#fafafa",
+ paper: isDarkMode ? "#1e1e1e" : "#ffffff",
+ },
+ },
+ components: {
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ transition: "background-color 0.3s ease",
+ },
+ },
+ },
+ },
+ });
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/webui/src/app/ui/banner.tsx b/webui/src/app/ui/banner.tsx
index b9b4d1c7..69850e48 100644
--- a/webui/src/app/ui/banner.tsx
+++ b/webui/src/app/ui/banner.tsx
@@ -17,9 +17,16 @@
* under the License.
*/
-import { AppBar, Container, Toolbar } from "@mui/material";
+"use client";
+
+import { AppBar, Container, Toolbar, IconButton, Box, Tooltip, Typography } from "@mui/material";
import Image from "next/image";
import NavLinks from "./nav-links";
+import { useTheme } from "../theme-provider";
+import Brightness4Icon from "@mui/icons-material/Brightness4";
+import Brightness7Icon from "@mui/icons-material/Brightness7";
+import GitHubIcon from "@mui/icons-material/GitHub";
+import { usePathname } from "next/navigation";
const links = [
{
@@ -30,18 +37,69 @@ const links = [
title: 'Namespaces'
},{
url: 'https://kvrocks.apache.org',
- title: 'community',
+ title: 'Documentation',
_blank: true
},
-]
+];
export default function Banner() {
- return (
-
-
-
-
-
-
- )
+ const { isDarkMode, toggleTheme } = useTheme();
+ const pathname = usePathname();
+
+ // Generate breadcrumb from pathname
+ const breadcrumbs = pathname.split('/').filter(Boolean);
+
+ return (
+
+
+
+
+
+
+ Apache Kvrocks Controller
+
+
+
+
+
+
+
+
+ {breadcrumbs.length > 0 && (
+
+ {breadcrumbs.map((breadcrumb, i) => (
+
+ {i > 0 && " / "}
+ {breadcrumb}
+
+ ))}
+
+ )}
+
+
+
+ {isDarkMode ? : }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx
index d09f498d..d3e6b322 100644
--- a/webui/src/app/ui/createCard.tsx
+++ b/webui/src/app/ui/createCard.tsx
@@ -19,7 +19,7 @@
"use client";
-import { Card, Box } from "@mui/material";
+import { Box, Paper, Chip, Tooltip } from "@mui/material";
import React, { ReactNode } from "react";
import {
ClusterCreation,
@@ -33,56 +33,38 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface CreateCardProps {
children: ReactNode;
+ className?: string;
}
-export const CreateCard: React.FC = ({ children }) => {
+export const CreateCard: React.FC = ({ children, className = "" }) => {
return (
-
-
+
{children}
-
+
);
};
export const AddClusterCard = ({ namespace }: { namespace: string }) => {
return (
-
-
-
-
-
-
-
-
+
+
@@ -97,25 +79,19 @@ export const AddShardCard = ({
cluster: string;
}) => {
return (
-
-
-
-
+
+
+
+
-
-
{
return (
-
-
-
-
+
+
+ );
+};
+
+export const ResourceCard = ({
+ title,
+ description,
+ tags,
+ children
+}: {
+ title: string;
+ description?: string;
+ tags?: Array<{label: string, color?: string}>;
+ children: ReactNode;
+}) => {
+ return (
+
+
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+
+ {children}
+
+ {tags && tags.length > 0 && (
+
+ {tags.map((tag, i) => (
+
+ ))}
+
+ )}
);
diff --git a/webui/src/app/ui/emptyState.tsx b/webui/src/app/ui/emptyState.tsx
new file mode 100644
index 00000000..a329c871
--- /dev/null
+++ b/webui/src/app/ui/emptyState.tsx
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { ReactNode } from 'react';
+import { Box, Paper, Typography, Button } from '@mui/material';
+
+interface EmptyStateProps {
+ title: string;
+ description: string;
+ icon?: ReactNode;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+}
+
+const EmptyState: React.FC = ({ title, description, icon, action }) => {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {title}
+
+
+ {description}
+
+ {action && (
+
+ {action.label}
+
+ )}
+
+ );
+};
+
+export default EmptyState;
diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx
index 2e26df0c..157ae86a 100644
--- a/webui/src/app/ui/formDialog.tsx
+++ b/webui/src/app/ui/formDialog.tsx
@@ -23,6 +23,7 @@ import {
Dialog,
DialogActions,
DialogContent,
+ DialogContentText,
DialogTitle,
Snackbar,
TextField,
@@ -34,22 +35,25 @@ import {
Select,
InputLabel,
FormControl,
+ Paper,
+ CircularProgress,
} from "@mui/material";
import React, { useCallback, useState, FormEvent } from "react";
-
- interface FormDialogProps {
+import AddIcon from '@mui/icons-material/Add';
+
+interface FormDialogProps {
position: string;
title: string;
submitButtonLabel: string;
formFields: {
- name: string;
- label: string;
- type: string;
- required?: boolean;
- values?: string[];
+ name: string;
+ label: string;
+ type: string;
+ required?: boolean;
+ values?: string[];
}[];
onSubmit: (formData: FormData) => Promise;
- }
+}
const FormDialog: React.FC = ({
position,
@@ -63,6 +67,7 @@ const FormDialog: React.FC = ({
const closeDialog = useCallback(() => setShowDialog(false), []);
const [errorMessage, setErrorMessage] = useState("");
const [arrayValues, setArrayValues] = useState<{ [key: string]: string[] }>({});
+ const [submitting, setSubmitting] = useState(false);
const handleArrayChange = (name: string, value: string[]) => {
setArrayValues({ ...arrayValues, [name]: value });
@@ -70,40 +75,70 @@ const FormDialog: React.FC = ({
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
+ setSubmitting(true);
const formData = new FormData(event.currentTarget);
Object.keys(arrayValues).forEach((name) => {
formData.append(name, JSON.stringify(arrayValues[name]));
});
- const error = await onSubmit(formData);
- if (error) {
- setErrorMessage(error);
- } else {
- closeDialog();
+ try {
+ const error = await onSubmit(formData);
+ if (error) {
+ setErrorMessage(error);
+ } else {
+ closeDialog();
+ }
+ } catch (error) {
+ setErrorMessage("An unexpected error occurred");
+ } finally {
+ setSubmitting(false);
}
};
return (
<>
{position === "card" ? (
-
+ }
+ size="small"
+ >
{title}
) : (
-
+ }
+ >
{title}
)}
-
+
@@ -177,12 +237,13 @@ const FormDialog: React.FC = ({
autoHideDuration={5000}
onClose={() => setErrorMessage("")}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ className="mb-4"
>
setErrorMessage("")}
severity="error"
variant="filled"
- sx={{ width: "100%" }}
+ className="shadow-lg"
>
{errorMessage}
@@ -192,4 +253,3 @@ const FormDialog: React.FC = ({
};
export default FormDialog;
-
\ No newline at end of file
diff --git a/webui/src/app/ui/loadingSpinner.tsx b/webui/src/app/ui/loadingSpinner.tsx
index 51f7e11e..3b314c9b 100644
--- a/webui/src/app/ui/loadingSpinner.tsx
+++ b/webui/src/app/ui/loadingSpinner.tsx
@@ -20,21 +20,49 @@
"use client";
import React from 'react';
-import { Box, CircularProgress } from '@mui/material';
+import { Box, CircularProgress, Typography, Fade } from '@mui/material';
+
+interface LoadingSpinnerProps {
+ message?: string;
+ size?: 'small' | 'medium' | 'large';
+ fullScreen?: boolean;
+}
+
+export const LoadingSpinner: React.FC = ({
+ message = 'Loading...',
+ size = 'medium',
+ fullScreen = false
+}) => {
+ const spinnerSize = {
+ small: 24,
+ medium: 40,
+ large: 60
+ }[size];
-export const LoadingSpinner: React.FC = () => {
return (
-
-
-
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+
);
};
\ No newline at end of file
diff --git a/webui/src/app/ui/nav-links.tsx b/webui/src/app/ui/nav-links.tsx
index 66312d6c..ad4a0754 100644
--- a/webui/src/app/ui/nav-links.tsx
+++ b/webui/src/app/ui/nav-links.tsx
@@ -17,8 +17,11 @@
* under the License.
*/
-import { Button } from "@mui/material"
-import Link from "next/link"
+"use client";
+
+import { Button } from "@mui/material";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
export default function NavLinks({links}: {
links: Array<{
@@ -27,15 +30,32 @@ export default function NavLinks({links}: {
_blank?: boolean,
}>
}) {
+ const pathname = usePathname();
+
return <>
- {links.map(link =>
-
- {link.title}
-
- )}
- >
+ {links.map(link => {
+ const isActive = pathname === link.url ||
+ (link.url !== '/' && pathname.startsWith(link.url));
+
+ return (
+
+
+ {link.title}
+
+
+ );
+ })}
+ >;
}
\ No newline at end of file
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index 63bbd38f..9ff1b658 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.tsx
@@ -19,7 +19,7 @@
"use client";
-import { Divider, List, Typography } from "@mui/material";
+import { Divider, List, Typography, Paper, Box, Collapse } from "@mui/material";
import {
fetchClusters,
fetchNamespaces,
@@ -35,10 +35,50 @@ import {
} from "./formCreation";
import Link from "next/link";
import { useState, useEffect } from "react";
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import FolderIcon from '@mui/icons-material/Folder';
+import StorageIcon from '@mui/icons-material/Storage';
+import DnsIcon from '@mui/icons-material/Dns';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
+
+// Sidebar section header component
+const SidebarHeader = ({
+ title,
+ count,
+ isOpen,
+ toggleOpen,
+ icon
+}: {
+ title: string;
+ count: number;
+ isOpen: boolean;
+ toggleOpen: () => void;
+ icon: React.ReactNode;
+}) => (
+
+
+ {icon}
+
+ {title}
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+ {isOpen ?
:
}
+
+);
export function NamespaceSidebar() {
const [namespaces, setNamespaces] = useState([]);
const [error, setError] = useState(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -53,34 +93,45 @@ export function NamespaceSidebar() {
}, []);
return (
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
- {namespaces.map((namespace) => (
-
-
-
+
+
+
+
+
+ setIsOpen(!isOpen)}
+ icon={ }
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {namespaces.map((namespace) => (
+
-
- ))}
-
-
-
-
+ ))}
+
+
+
);
}
export function ClusterSidebar({ namespace }: { namespace: string }) {
const [clusters, setClusters] = useState([]);
const [error, setError] = useState(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -95,31 +146,38 @@ export function ClusterSidebar({ namespace }: { namespace: string }) {
}, [namespace]);
return (
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
- {clusters.map((cluster) => (
-
-
-
+
+
+
+
+
+ setIsOpen(!isOpen)}
+ icon={ }
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {clusters.map((cluster) => (
+
-
- ))}
-
-
-
-
+ ))}
+
+
+
);
}
@@ -132,6 +190,7 @@ export function ShardSidebar({
}) {
const [shards, setShards] = useState([]);
const [error, setError] = useState(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -149,27 +208,36 @@ export function ShardSidebar({
}, [namespace, cluster]);
return (
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
- {shards.map((shard, index) => (
-
-
-
+
+
+
+
+
+ setIsOpen(!isOpen)}
+ icon={ }
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {shards.map((shard, index) => (
+
-
- ))}
-
-
-
-
+ ))}
+
+
+
);
}
+
interface NodeItem {
addr: string;
created_at: number;
@@ -204,6 +271,7 @@ export function NodeSidebar({
}) {
const [nodes, setNodes] = useState([]);
const [error, setError] = useState(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -219,46 +287,56 @@ export function NodeSidebar({
}
};
fetchData();
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [namespace, cluster, shard]);
return (
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
- {nodes.map((node, index) => (
-
-
+
+
+
+
+
+ setIsOpen(!isOpen)}
+ icon={ }
+ />
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {nodes.map((node, index) => (
-
- ))}
-
-
-
-
+ ))}
+
+
+
);
}
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
index 2342d8a5..32b2d79d 100644
--- a/webui/src/app/ui/sidebarItem.tsx
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -29,13 +29,15 @@ import {
IconButton,
ListItem,
ListItemButton,
+ ListItemIcon,
ListItemText,
Menu,
MenuItem,
Snackbar,
Tooltip,
+ Badge,
} from "@mui/material";
-import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
+import MoreVertIcon from "@mui/icons-material/MoreVert";
import { useCallback, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
@@ -45,8 +47,12 @@ import {
deleteNode,
deleteShard,
} from "../lib/api";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import FolderIcon from '@mui/icons-material/Folder';
+import StorageIcon from '@mui/icons-material/Storage';
+import DnsIcon from '@mui/icons-material/Dns';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
interface NamespaceItemProps {
item: string;
@@ -85,7 +91,7 @@ export default function Item(props: ItemProps) {
const { item, type } = props;
const [hover, setHover] = useState(false);
const [showMenu, setShowMenu] = useState(false);
- const listItemTextRef = useRef(null);
+ const listItemRef = useRef(null);
const openMenu = useCallback(() => setShowMenu(true), []);
const closeMenu = useCallback(
() => (setShowMenu(false), setHover(false)),
@@ -105,6 +111,22 @@ export default function Item(props: ItemProps) {
const router = useRouter();
let activeItem = usePathname().split("/").pop() || "";
+
+ const getItemIcon = () => {
+ switch (type) {
+ case "namespace":
+ return ;
+ case "cluster":
+ return ;
+ case "shard":
+ return ;
+ case "node":
+ return ;
+ default:
+ return null;
+ }
+ };
+
const confirmDelete = useCallback(async () => {
let response = "";
if (type === "namespace") {
@@ -150,76 +172,111 @@ export default function Item(props: ItemProps) {
if (type === "shard") {
activeItem = "Shard\t" + (parseInt(activeItem) + 1);
- }else if (type === "node") {
+ } else if (type === "node") {
activeItem = "Node\t" + (parseInt(activeItem) + 1);
}
const isActive = item === activeItem;
+
+ const displayName = item.includes("\t") ? item.split("\t")[0] + " " + item.split("\t")[1] : item;
+
return (
-
-
- )
- }
+ className="mb-1"
+ ref={listItemRef}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => !showMenu && setHover(false)}
- sx={{
- backgroundColor: isActive ? "rgba(0, 0, 0, 0.1)" : "transparent",
- "&:hover": {
- backgroundColor: "rgba(0, 0, 0, 0.05)",
- },
- }}
>
-
-
-
-
+
+
+ {getItemIcon()}
+
+
+ {hover && (
+
+
+
+ )}
+
-
-
+
+
+ Delete
-
- Confirm
+
+
+ Confirm Delete
- {type === "node" ? (
+ {type === "node" || type === "shard" ? (
- Please confirm you want to delete {item}
-
- ) : type === "shard" ? (
-
- Please confirm you want to delete {item}
+ Are you sure you want to delete {displayName}?
) : (
- Please confirm you want to delete {type} {item}
+ Are you sure you want to delete {type} {item} ?
)}
-
- Cancel
-
- Delete
+
+
+ Cancel
+
+
+ Delete
+
{
- return text.length > limit ? `${text.slice(0, limit)}...` : text;
+/**
+ * Truncates text to a specific length and adds an ellipsis
+ */
+export const truncateText = (text: string, maxLength: number): string => {
+ if (!text || text.length <= maxLength) return text;
+ return `${text.substring(0, maxLength)}...`;
+};
+
+/**
+ * Format a timestamp to a human-readable date
+ */
+export const formatTimestamp = (timestamp: number): string => {
+ return new Date(timestamp * 1000).toLocaleString();
+};
+
+/**
+ * Format bytes into a human-readable format
+ */
+export const formatBytes = (bytes: number, decimals: number = 2): string => {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+};
+
+/**
+ * Calculate uptime from creation timestamp
+ */
+export const calculateUptime = (timestamp: number): string => {
+ const now = Math.floor(Date.now() / 1000);
+ const uptimeSeconds = now - timestamp;
+
+ if (uptimeSeconds < 60) return `${uptimeSeconds} seconds`;
+ if (uptimeSeconds < 3600) return `${Math.floor(uptimeSeconds / 60)} minutes`;
+ if (uptimeSeconds < 86400) return `${Math.floor(uptimeSeconds / 3600)} hours`;
+ return `${Math.floor(uptimeSeconds / 86400)} days`;
+};
+
+/**
+ * Format slot ranges for better display
+ */
+export const formatSlotRanges = (ranges: string[]): string => {
+ if (!ranges || ranges.length === 0) return "None";
+ if (ranges.length <= 2) return ranges.join(", ");
+ return `${ranges[0]}, ${ranges[1]}, ... (+${ranges.length - 2} more)`;
};
diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts
index 0305e162..7b220bc1 100644
--- a/webui/tailwind.config.ts
+++ b/webui/tailwind.config.ts
@@ -25,12 +25,65 @@ const config: Config = {
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
+ darkMode: 'class',
theme: {
extend: {
+ colors: {
+ primary: {
+ DEFAULT: '#1976d2',
+ light: '#42a5f5',
+ dark: '#1565c0',
+ contrastText: '#fff',
+ },
+ secondary: {
+ DEFAULT: '#9c27b0',
+ light: '#ba68c8',
+ dark: '#7b1fa2',
+ contrastText: '#fff',
+ },
+ success: {
+ DEFAULT: '#2e7d32',
+ light: '#4caf50',
+ dark: '#1b5e20',
+ },
+ error: {
+ DEFAULT: '#d32f2f',
+ light: '#ef5350',
+ dark: '#c62828',
+ },
+ warning: {
+ DEFAULT: '#ed6c02',
+ light: '#ff9800',
+ dark: '#e65100',
+ },
+ info: {
+ DEFAULT: '#0288d1',
+ light: '#03a9f4',
+ dark: '#01579b',
+ },
+ dark: {
+ DEFAULT: '#121212',
+ paper: '#1e1e1e',
+ border: '#333333',
+ },
+ light: {
+ DEFAULT: '#fafafa',
+ paper: '#ffffff',
+ border: '#e0e0e0',
+ }
+ },
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
- "gradient-conic":
- "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
+ "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
+ },
+ boxShadow: {
+ 'card': '0 2px 8px rgba(0, 0, 0, 0.08)',
+ 'card-hover': '0 4px 12px rgba(0, 0, 0, 0.15)',
+ 'sidebar': '2px 0 5px rgba(0, 0, 0, 0.05)',
+ },
+ transitionProperty: {
+ 'height': 'height',
+ 'spacing': 'margin, padding',
},
},
},