From beae85d2d21530ce9d3ee52be0ab01d001a15061 Mon Sep 17 00:00:00 2001 From: Vlad Durnea Date: Mon, 30 Mar 2026 21:57:33 +0300 Subject: [PATCH] feat(control-ui): modernize UX with premium Ultrabase design system --- control/ui/src/app/layout.tsx | 359 +++++++++++++++++------ control/ui/src/components/primitives.tsx | 348 +++++++++++++++++----- control/ui/src/components/ui/Icons.tsx | 99 +++++++ control/ui/src/index.css | 151 ++++------ 4 files changed, 701 insertions(+), 256 deletions(-) create mode 100644 control/ui/src/components/ui/Icons.tsx diff --git a/control/ui/src/app/layout.tsx b/control/ui/src/app/layout.tsx index 8447bc3..7b5701d 100644 --- a/control/ui/src/app/layout.tsx +++ b/control/ui/src/app/layout.tsx @@ -1,28 +1,61 @@ -import { useMemo, useState } from 'react' +import React, { useMemo, useState } from 'react' import { Link, Outlet, useLocation } from 'react-router-dom' import { getLastRequestIds } from '../api/client' import { Button, Code, TextInput } from '../components/primitives' +import { Icons } from '../components/ui/Icons' type NavItem = { label: string to: string + icon: React.ComponentType } -const navItems: NavItem[] = [ - { label: 'Overview', to: '/' }, - { label: 'Tenants', to: '/tenants' }, - { label: 'Users', to: '/users' }, - { label: 'Sessions', to: '/sessions' }, - { label: 'Roles & Permissions', to: '/roles-permissions' }, - { label: 'Config', to: '/config' }, - { label: 'Definitions', to: '/definitions' }, - { label: 'Documents', to: '/documents' }, - { label: 'Scale & Placement', to: '/scale-placement' }, - { label: 'Deployments', to: '/deployments' }, - { label: 'Observability', to: '/observability' }, - { label: 'Platform Drift', to: '/drift' }, - { label: 'Audit Log', to: '/audit-log' }, - { label: 'Settings', to: '/settings' }, +type NavCategory = { + label: string + items: NavItem[] +} + +const navCategories: NavCategory[] = [ + { + label: 'Platform', + items: [ + { label: 'Overview', to: '/', icon: Icons.Activity }, + { label: 'Tenants', to: '/tenants', icon: Icons.Tenants }, + { label: 'Fleet Nodes', to: '/fleet', icon: Icons.Fleet }, + { label: 'Observability', to: '/observability', icon: Icons.Observability }, + ], + }, + { + label: 'Infrastructure', + items: [ + { label: 'Scale & Placement', to: '/scale-placement', icon: Icons.Topology }, + { label: 'Deployments', to: '/deployments', icon: Icons.Deployments }, + ], + }, + { + label: 'Resources', + items: [ + { label: 'Config Registry', to: '/config', icon: Icons.Config }, + { label: 'Type Definitions', to: '/definitions', icon: Icons.Definitions }, + { label: 'Documents', to: '/documents', icon: Icons.Storage }, + ], + }, + { + label: 'Governance', + items: [ + { label: 'Audit Log', to: '/audit-log', icon: Icons.Audit }, + { label: 'Platform Drift', to: '/drift', icon: Icons.Drift }, + ], + }, + { + label: 'Management', + items: [ + { label: 'Users', to: '/users', icon: Icons.Users }, + { label: 'Sessions', to: '/sessions', icon: Icons.Sessions }, + { label: 'Roles & Permissions', to: '/roles-permissions', icon: Icons.ShieldAlert }, + { label: 'Settings', to: '/settings', icon: Icons.Settings }, + ], + }, ] function normalizePath(pathname: string) { @@ -33,7 +66,7 @@ function normalizePath(pathname: string) { export function Layout() { const location = useLocation() - const active = normalizePath(location.pathname) + const activePath = normalizePath(location.pathname) const [query, setQuery] = useState('') const lastIds = getLastRequestIds() @@ -74,111 +107,251 @@ export function Layout() { } } - return ( -
- + + + + {item.label} + + ) + } -
-
+ {/* Sidebar */} + + + {/* Main Content */} +
+
+
+
{ - if (e.key === 'Enter') { + if (e.key === 'Enter') { + const id = query.trim() + if (!id) return + openGrafanaLogs(id) + } + }} + /> +
+
+ +
- -
- {lastIds ? ( -
-
- request_id + {lastIds && ( +
+
+ request_id {lastIds.requestId} - +
- {lastIds.correlationId ? ( -
- correlation_id + {lastIds.correlationId && ( +
+ correlation_id {lastIds.correlationId} - +
- ) : null} + )}
- ) : null} + )}
- + +
+ +
) diff --git a/control/ui/src/components/primitives.tsx b/control/ui/src/components/primitives.tsx index 99168a7..e7f6565 100644 --- a/control/ui/src/components/primitives.tsx +++ b/control/ui/src/components/primitives.tsx @@ -1,43 +1,78 @@ -import type { KeyboardEvent, ReactNode } from 'react' +import React, { type KeyboardEvent, type ReactNode } from 'react' -const colors = { - border: '#ddd', - borderSubtle: '#eee', - text: '#111', - muted: '#666', - danger: '#b00020', - bg: '#fff', - bgSubtle: '#fafafa', - bgActive: '#eaeaea', +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' + size?: 'sm' | 'md' | 'lg' + loading?: boolean + icon?: ReactNode } -export function Button(props: { - children: ReactNode - onClick?: () => void - disabled?: boolean - variant?: 'default' | 'danger' - type?: 'button' | 'submit' -}) { - const variant = props.variant ?? 'default' - const borderColor = variant === 'danger' ? colors.danger : colors.border - const textColor = variant === 'danger' ? colors.danger : colors.text +export const Button: React.FC = ({ + children, + variant = 'primary', + size = 'md', + loading, + icon, + style, + ...props +}) => { + const baseStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + borderRadius: 'var(--radius-md)', + fontWeight: 500, + cursor: props.disabled || loading ? 'not-allowed' : 'pointer', + transition: 'all 0.2s ease', + border: '1px solid transparent', + fontSize: size === 'sm' ? '13px' : size === 'lg' ? '16px' : '14px', + padding: size === 'sm' ? '4px 10px' : size === 'lg' ? '10px 20px' : '6px 14px', + opacity: props.disabled || loading ? 0.6 : 1, + ...style, + } + + const variants = { + primary: { + background: 'var(--accent-primary)', + color: '#000', + }, + secondary: { + background: 'var(--bg-tertiary)', + color: 'var(--text-primary)', + borderColor: 'var(--border-primary)', + }, + danger: { + background: 'var(--error-primary)', + color: '#fff', + }, + ghost: { + background: 'transparent', + color: 'var(--text-secondary)', + }, + } + + const currentVariant = variants[variant as keyof typeof variants] || variants.primary return ( ) } @@ -48,70 +83,237 @@ export function TextInput(props: { onChange: (value: string) => void placeholder?: string ariaLabel?: string + type?: string onKeyDown?: (e: KeyboardEvent) => void }) { return ( - props.onChange(e.target.value)} - placeholder={props.placeholder} - onKeyDown={props.onKeyDown} - style={{ - padding: '8px 10px', - borderRadius: 8, - border: `1px solid ${colors.border}`, - width: '100%', - }} - /> +
+ {props.ariaLabel && ( + + )} + props.onChange(e.target.value)} + placeholder={props.placeholder} + onKeyDown={props.onKeyDown} + style={{ + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-primary)', + borderRadius: 'var(--radius-md)', + color: 'var(--text-primary)', + fontSize: '14px', + width: '100%', + outline: 'none', + transition: 'border-color 0.2s ease', + }} + onFocus={(e) => (e.currentTarget.style.borderColor = 'var(--accent-primary)')} + onBlur={(e) => (e.currentTarget.style.borderColor = 'var(--border-primary)')} + /> +
) } export function Code(props: { children: ReactNode }) { - return {props.children} + return ( + + {props.children} + + ) } export function ErrorText(props: { children: ReactNode }) { - return
{props.children}
+ return ( +
+ {props.children} +
+ ) } export function MutedText(props: { children: ReactNode }) { - return
{props.children}
+ return ( +
+ {props.children} +
+ ) } -export function Table(props: { columns: ReactNode[]; rows: ReactNode[][] }) { +export function Table(props: { + columns?: ReactNode[] + headers?: ReactNode[] + rows: ReactNode[][] + style?: React.CSSProperties +}) { + const headers = props.headers || props.columns || [] + return ( -
- +
+
- - {props.columns.map((c, idx) => ( + + {headers.map((h, i) => ( ))} - {props.rows.map((r, ridx) => ( - - {r.map((cell, cidx) => ( - - ))} + {props.rows.length === 0 ? ( + + - ))} + ) : ( + props.rows.map((row, i) => ( + (e.currentTarget.style.background = 'rgba(255,255,255,0.02)')} + onMouseOut={(e) => (e.currentTarget.style.background = 'transparent')} + > + {row.map((cell, j) => ( + + ))} + + )) + )}
- {c} + {h}
- {cell} -
+ No data available +
+ {cell} +
) } +export interface CardProps { + children: ReactNode + title?: string + subtitle?: string + footer?: ReactNode + headerAction?: ReactNode + style?: React.CSSProperties + bodyStyle?: React.CSSProperties +} + +export const Card: React.FC = ({ + children, + title, + subtitle, + footer, + headerAction, + style, + bodyStyle, +}) => { + return ( +
+ {(title || subtitle || headerAction) && ( +
+
+ {title &&

{title}

} + {subtitle && ( +

+ {subtitle} +

+ )} +
+ {headerAction &&
{headerAction}
} +
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ) +} + export function Modal(props: { title: string open: boolean @@ -128,21 +330,25 @@ export function Modal(props: { style={{ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.35)', + background: 'rgba(0,0,0,0.6)', + backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, + zIndex: 1000, }} onMouseDown={(e) => { if (e.target === e.currentTarget) props.onClose() }} > -
-
{props.title}
-
{props.children}
- {props.footer ?
{props.footer}
: null} -
+ + {props.children} +
) } diff --git a/control/ui/src/components/ui/Icons.tsx b/control/ui/src/components/ui/Icons.tsx new file mode 100644 index 0000000..0e05853 --- /dev/null +++ b/control/ui/src/components/ui/Icons.tsx @@ -0,0 +1,99 @@ +import React from "react"; + +type IconProps = React.SVGProps; + +export const Icons = { + Tenants: (props: IconProps) => ( + + ), + Topology: (props: IconProps) => ( + + ), + Fleet: (props: IconProps) => ( + + ), + Observability: (props: IconProps) => ( + + ), + Users: (props: IconProps) => ( + + ), + Sessions: (props: IconProps) => ( + + ), + Logout: (props: IconProps) => ( + + ), + ChevronRight: (props: IconProps) => ( + + ), + Database: (props: IconProps) => ( + + ), + Mail: (props: IconProps) => ( + + ), + Storage: (props: IconProps) => ( + + ), + Functions: (props: IconProps) => ( + + ), + Realtime: (props: IconProps) => ( + + ), + Refresh: (props: IconProps) => ( + + ), + Plus: (props: IconProps) => ( + + ), + Trash: (props: IconProps) => ( + + ), + AlertTriangle: (props: IconProps) => ( + + ), + Search: (props: IconProps) => ( + + ), + Activity: (props: IconProps) => ( + + ), + ShieldAlert: (props: IconProps) => ( + + ), + ChevronLeft: (props: IconProps) => ( + + ), + Zap: (props: IconProps) => ( + + ), + Key: (props: IconProps) => ( + + ), + MoreVertical: (props: IconProps) => ( + + ), + ExternalLink: (props: IconProps) => ( + + ), + Config: (props: IconProps) => ( + + ), + Definitions: (props: IconProps) => ( + + ), + Deployments: (props: IconProps) => ( + + ), + Drift: (props: IconProps) => ( + + ), + Audit: (props: IconProps) => ( + + ), + Settings: (props: IconProps) => ( + + ), +}; diff --git a/control/ui/src/index.css b/control/ui/src/index.css index 5fb3313..bc8b7f9 100644 --- a/control/ui/src/index.css +++ b/control/ui/src/index.css @@ -1,111 +1,78 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --bg-primary: #1c1c1c; + --bg-secondary: #242424; + --bg-tertiary: #2e2e2e; + + --border-primary: #30363d; + --border-secondary: #3e444d; + + --text-primary: #ededed; + --text-secondary: #a0a0a0; + --text-muted: #6e7681; + + --accent-primary: #3ecf8e; + --accent-secondary: #34b27b; + --accent-muted: rgba(62, 207, 142, 0.1); + + --error-primary: #ea4e4e; + --error-muted: rgba(234, 78, 78, 0.1); + + --warning-primary: #f5a623; + --warning-muted: rgba(245, 166, 35, 0.1); - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } body { margin: 0; + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +button, input, select, textarea { + font-family: inherit; } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; +a { + color: var(--accent-primary); + text-decoration: none; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; +a:hover { + text-decoration: underline; +} + +/* Custom scrollbar for a more premium feel */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--bg-primary); +} +::-webkit-scrollbar-thumb { + background: var(--border-primary); border-radius: 4px; - color: var(--text-h); +} +::-webkit-scrollbar-thumb:hover { + background: var(--border-secondary); } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +#root { + min-height: 100svh; + display: flex; + flex-direction: column; }