@annondeveloper/ui-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/chunk-5OKSXPWK.js +270 -0
- package/dist/chunk-5OKSXPWK.js.map +1 -0
- package/dist/cli/index.js +430 -0
- package/dist/form.d.ts +65 -0
- package/dist/form.js +148 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +942 -0
- package/dist/index.js +2812 -0
- package/dist/index.js.map +1 -0
- package/dist/select-nnBJUO8U.d.ts +26 -0
- package/package.json +114 -0
- package/src/components/animated-counter.stories.tsx +68 -0
- package/src/components/animated-counter.tsx +85 -0
- package/src/components/avatar.tsx +106 -0
- package/src/components/badge.stories.tsx +70 -0
- package/src/components/badge.tsx +97 -0
- package/src/components/button.stories.tsx +101 -0
- package/src/components/button.tsx +67 -0
- package/src/components/card.tsx +128 -0
- package/src/components/checkbox.stories.tsx +64 -0
- package/src/components/checkbox.tsx +58 -0
- package/src/components/confirm-dialog.stories.tsx +96 -0
- package/src/components/confirm-dialog.tsx +145 -0
- package/src/components/data-table.stories.tsx +125 -0
- package/src/components/data-table.tsx +791 -0
- package/src/components/dropdown-menu.tsx +111 -0
- package/src/components/empty-state.stories.tsx +42 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/filter-pill.stories.tsx +71 -0
- package/src/components/filter-pill.tsx +45 -0
- package/src/components/form-input.stories.tsx +91 -0
- package/src/components/form-input.tsx +77 -0
- package/src/components/log-viewer.tsx +212 -0
- package/src/components/metric-card.tsx +141 -0
- package/src/components/pipeline-stage.tsx +134 -0
- package/src/components/popover.tsx +72 -0
- package/src/components/port-status-grid.tsx +102 -0
- package/src/components/progress.tsx +128 -0
- package/src/components/radio-group.tsx +162 -0
- package/src/components/select.stories.tsx +52 -0
- package/src/components/select.tsx +92 -0
- package/src/components/severity-timeline.tsx +125 -0
- package/src/components/sheet.tsx +164 -0
- package/src/components/skeleton.stories.tsx +64 -0
- package/src/components/skeleton.tsx +62 -0
- package/src/components/slider.tsx +208 -0
- package/src/components/sparkline.tsx +104 -0
- package/src/components/status-badge.stories.tsx +84 -0
- package/src/components/status-badge.tsx +71 -0
- package/src/components/status-pulse.stories.tsx +56 -0
- package/src/components/status-pulse.tsx +78 -0
- package/src/components/success-checkmark.stories.tsx +67 -0
- package/src/components/success-checkmark.tsx +53 -0
- package/src/components/tabs.tsx +177 -0
- package/src/components/threshold-gauge.tsx +149 -0
- package/src/components/time-range-selector.tsx +86 -0
- package/src/components/toast.stories.tsx +70 -0
- package/src/components/toast.tsx +48 -0
- package/src/components/toggle-switch.stories.tsx +66 -0
- package/src/components/toggle-switch.tsx +51 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/truncated-text.stories.tsx +56 -0
- package/src/components/truncated-text.tsx +80 -0
- package/src/components/uptime-tracker.tsx +138 -0
- package/src/components/utilization-bar.tsx +103 -0
- package/src/theme.css +178 -0
- package/src/utils.ts +123 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { ToggleSwitch } from './toggle-switch'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ToggleSwitch> = {
|
|
6
|
+
title: 'Components/ToggleSwitch',
|
|
7
|
+
component: ToggleSwitch,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
size: { control: 'select', options: ['sm', 'md'] },
|
|
11
|
+
disabled: { control: 'boolean' },
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof ToggleSwitch>
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
render: (args) => {
|
|
19
|
+
const [enabled, setEnabled] = useState(false)
|
|
20
|
+
return <ToggleSwitch {...args} enabled={enabled} onChange={setEnabled} />
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const On: Story = {
|
|
25
|
+
render: () => {
|
|
26
|
+
const [enabled, setEnabled] = useState(true)
|
|
27
|
+
return <ToggleSwitch enabled={enabled} onChange={setEnabled} />
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const Off: Story = {
|
|
32
|
+
render: () => {
|
|
33
|
+
const [enabled, setEnabled] = useState(false)
|
|
34
|
+
return <ToggleSwitch enabled={enabled} onChange={setEnabled} />
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SmallSize: Story = {
|
|
39
|
+
render: () => {
|
|
40
|
+
const [enabled, setEnabled] = useState(true)
|
|
41
|
+
return <ToggleSwitch size="sm" enabled={enabled} onChange={setEnabled} />
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Disabled: Story = {
|
|
46
|
+
render: () => (
|
|
47
|
+
<div style={{ display: 'flex', gap: '1rem' }}>
|
|
48
|
+
<ToggleSwitch enabled={true} onChange={() => {}} disabled />
|
|
49
|
+
<ToggleSwitch enabled={false} onChange={() => {}} disabled />
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const WithLabel: Story = {
|
|
55
|
+
render: () => {
|
|
56
|
+
const [enabled, setEnabled] = useState(false)
|
|
57
|
+
return (
|
|
58
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
59
|
+
<ToggleSwitch enabled={enabled} onChange={setEnabled} label="Enable notifications" />
|
|
60
|
+
<span style={{ fontSize: '0.875rem', color: 'hsl(210 20% 70%)' }}>
|
|
61
|
+
Notifications {enabled ? 'on' : 'off'}
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ToggleLeft, ToggleRight } from 'lucide-react'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
export interface ToggleSwitchProps {
|
|
7
|
+
/** Whether the toggle is on. */
|
|
8
|
+
enabled: boolean
|
|
9
|
+
/** Callback when toggled. */
|
|
10
|
+
onChange: (enabled: boolean) => void
|
|
11
|
+
/** Size variant. */
|
|
12
|
+
size?: 'sm' | 'md'
|
|
13
|
+
/** Disable the toggle. */
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
/** Accessible label. */
|
|
16
|
+
label?: string
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @description A themed toggle switch using lucide icons for on/off states.
|
|
22
|
+
* Supports dark/light mode via CSS custom property tokens.
|
|
23
|
+
*/
|
|
24
|
+
export function ToggleSwitch({
|
|
25
|
+
enabled, onChange, size = 'md', disabled, label, className,
|
|
26
|
+
}: ToggleSwitchProps) {
|
|
27
|
+
const iconSize = size === 'sm' ? 'size-5' : 'size-6'
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
role="switch"
|
|
33
|
+
aria-checked={enabled}
|
|
34
|
+
aria-label={label}
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
onClick={() => onChange(!enabled)}
|
|
37
|
+
className={cn(
|
|
38
|
+
'inline-flex items-center transition-colors',
|
|
39
|
+
'text-[hsl(var(--text-secondary))] hover:text-[hsl(var(--text-primary))]',
|
|
40
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{enabled ? (
|
|
45
|
+
<ToggleRight className={cn(iconSize, 'text-[hsl(var(--status-ok))]')} />
|
|
46
|
+
) : (
|
|
47
|
+
<ToggleLeft className={cn(iconSize, 'text-[hsl(var(--text-tertiary))]')} />
|
|
48
|
+
)}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react'
|
|
4
|
+
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
export interface TooltipProps {
|
|
8
|
+
/** Tooltip content (can be text or ReactNode). */
|
|
9
|
+
content: ReactNode
|
|
10
|
+
/** Trigger element. */
|
|
11
|
+
children: ReactNode
|
|
12
|
+
/** Side of the trigger to display the tooltip. */
|
|
13
|
+
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
14
|
+
/** Delay in milliseconds before the tooltip appears. */
|
|
15
|
+
delay?: number
|
|
16
|
+
/** Additional class name for the tooltip content. */
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @description A simple tooltip wrapper built on Radix Tooltip.
|
|
22
|
+
* Theme-styled content with arrow pointer and configurable delay.
|
|
23
|
+
* Requires a TooltipProvider ancestor (included by default).
|
|
24
|
+
*/
|
|
25
|
+
export function Tooltip({
|
|
26
|
+
content,
|
|
27
|
+
children,
|
|
28
|
+
side = 'top',
|
|
29
|
+
delay = 200,
|
|
30
|
+
className,
|
|
31
|
+
}: TooltipProps) {
|
|
32
|
+
return (
|
|
33
|
+
<TooltipPrimitive.Provider delayDuration={delay}>
|
|
34
|
+
<TooltipPrimitive.Root>
|
|
35
|
+
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
|
36
|
+
<TooltipPrimitive.Portal>
|
|
37
|
+
<TooltipPrimitive.Content
|
|
38
|
+
side={side}
|
|
39
|
+
sideOffset={6}
|
|
40
|
+
className={cn(
|
|
41
|
+
'z-50 px-3 py-1.5 rounded-lg text-xs font-medium',
|
|
42
|
+
'bg-[hsl(var(--bg-elevated))] text-[hsl(var(--text-primary))]',
|
|
43
|
+
'border border-[hsl(var(--border-default))] shadow-lg',
|
|
44
|
+
'animate-in fade-in-0 zoom-in-95',
|
|
45
|
+
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
|
46
|
+
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
|
47
|
+
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{content}
|
|
52
|
+
<TooltipPrimitive.Arrow
|
|
53
|
+
className="fill-[hsl(var(--bg-elevated))]"
|
|
54
|
+
width={10}
|
|
55
|
+
height={5}
|
|
56
|
+
/>
|
|
57
|
+
</TooltipPrimitive.Content>
|
|
58
|
+
</TooltipPrimitive.Portal>
|
|
59
|
+
</TooltipPrimitive.Root>
|
|
60
|
+
</TooltipPrimitive.Provider>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { TruncatedText } from './truncated-text'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof TruncatedText> = {
|
|
5
|
+
title: 'Components/TruncatedText',
|
|
6
|
+
component: TruncatedText,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
}
|
|
9
|
+
export default meta
|
|
10
|
+
type Story = StoryObj<typeof TruncatedText>
|
|
11
|
+
|
|
12
|
+
export const ShortText: Story = {
|
|
13
|
+
args: {
|
|
14
|
+
text: 'core-sw-01',
|
|
15
|
+
maxWidth: 200,
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const LongText: Story = {
|
|
20
|
+
args: {
|
|
21
|
+
text: 'very-long-hostname-that-definitely-will-not-fit-in-a-small-column-width.example.datacenter.internal',
|
|
22
|
+
maxWidth: 200,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const IPAddress: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
text: '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
|
|
29
|
+
maxWidth: 150,
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const InTableContext: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<div style={{ width: '400px', border: '1px solid hsl(222 25% 25%)', borderRadius: '0.5rem', overflow: 'hidden' }}>
|
|
36
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
37
|
+
<thead>
|
|
38
|
+
<tr style={{ borderBottom: '1px solid hsl(222 30% 20%)' }}>
|
|
39
|
+
<th style={{ textAlign: 'left', padding: '0.5rem 1rem', fontSize: '0.75rem', color: 'hsl(210 15% 50%)' }}>Hostname</th>
|
|
40
|
+
<th style={{ textAlign: 'left', padding: '0.5rem 1rem', fontSize: '0.75rem', color: 'hsl(210 15% 50%)' }}>Description</th>
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
<tr>
|
|
45
|
+
<td style={{ padding: '0.5rem 1rem' }}><TruncatedText text="sw-core-01" maxWidth={120} /></td>
|
|
46
|
+
<td style={{ padding: '0.5rem 1rem' }}><TruncatedText text="Primary core switch in building A, floor 3, MDF closet" maxWidth={180} /></td>
|
|
47
|
+
</tr>
|
|
48
|
+
<tr>
|
|
49
|
+
<td style={{ padding: '0.5rem 1rem' }}><TruncatedText text="fw-edge-datacenter-primary-01" maxWidth={120} /></td>
|
|
50
|
+
<td style={{ padding: '0.5rem 1rem' }}><TruncatedText text="Edge firewall" maxWidth={180} /></td>
|
|
51
|
+
</tr>
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, useEffect } from 'react'
|
|
4
|
+
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
5
|
+
import { Copy, Check } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
export interface TruncatedTextProps {
|
|
8
|
+
/** The full text to display (truncated if it overflows). */
|
|
9
|
+
text: string
|
|
10
|
+
/** Max width constraint for truncation. */
|
|
11
|
+
maxWidth?: string | number
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @description A text element that truncates with ellipsis and shows a tooltip with the
|
|
17
|
+
* full text on hover when truncated. Includes a copy-to-clipboard button in the tooltip.
|
|
18
|
+
*/
|
|
19
|
+
export function TruncatedText({ text, maxWidth = '100%', className = '' }: TruncatedTextProps) {
|
|
20
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
21
|
+
const [isTruncated, setIsTruncated] = useState(false)
|
|
22
|
+
const [copied, setCopied] = useState(false)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const el = ref.current
|
|
26
|
+
if (el) setIsTruncated(el.scrollWidth > el.clientWidth)
|
|
27
|
+
}, [text])
|
|
28
|
+
|
|
29
|
+
const handleCopy = async (e: React.MouseEvent) => {
|
|
30
|
+
e.stopPropagation()
|
|
31
|
+
await navigator.clipboard.writeText(text)
|
|
32
|
+
setCopied(true)
|
|
33
|
+
setTimeout(() => setCopied(false), 1500)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const inner = (
|
|
37
|
+
<span
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={`block truncate ${className}`}
|
|
40
|
+
style={{ maxWidth }}
|
|
41
|
+
>
|
|
42
|
+
{text}
|
|
43
|
+
</span>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (!isTruncated) return inner
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Tooltip.Provider delayDuration={300}>
|
|
50
|
+
<Tooltip.Root>
|
|
51
|
+
<Tooltip.Trigger asChild>{inner}</Tooltip.Trigger>
|
|
52
|
+
<Tooltip.Portal>
|
|
53
|
+
<Tooltip.Content
|
|
54
|
+
side="top"
|
|
55
|
+
align="start"
|
|
56
|
+
sideOffset={6}
|
|
57
|
+
className="z-50 max-w-[400px] rounded-lg border border-[hsl(var(--border-default))]
|
|
58
|
+
bg-[hsl(var(--bg-elevated))] px-3 py-2 shadow-lg
|
|
59
|
+
animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out
|
|
60
|
+
data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
|
|
61
|
+
>
|
|
62
|
+
<p className="text-small text-[hsl(var(--text-primary))] break-all">{text}</p>
|
|
63
|
+
<div className="mt-1.5 flex justify-end">
|
|
64
|
+
<button
|
|
65
|
+
onClick={handleCopy}
|
|
66
|
+
className="flex items-center gap-1 rounded px-2 py-0.5
|
|
67
|
+
text-[11px] text-[hsl(var(--brand-primary))]
|
|
68
|
+
hover:bg-[hsl(var(--bg-surface))] transition-colors"
|
|
69
|
+
>
|
|
70
|
+
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
71
|
+
{copied ? 'Copied' : 'Copy'}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<Tooltip.Arrow className="fill-[hsl(var(--bg-elevated))]" />
|
|
75
|
+
</Tooltip.Content>
|
|
76
|
+
</Tooltip.Portal>
|
|
77
|
+
</Tooltip.Root>
|
|
78
|
+
</Tooltip.Provider>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
export interface DayStatus {
|
|
7
|
+
/** Date in YYYY-MM-DD format. */
|
|
8
|
+
date: string
|
|
9
|
+
/** Operational status for that day. */
|
|
10
|
+
status: 'ok' | 'degraded' | 'outage' | 'no-data'
|
|
11
|
+
/** Uptime percentage for that day (0-100). */
|
|
12
|
+
uptime?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UptimeTrackerProps {
|
|
16
|
+
/** Array of day-status entries (oldest first). */
|
|
17
|
+
days: DayStatus[]
|
|
18
|
+
/** Show overall uptime percentage. */
|
|
19
|
+
showPercentage?: boolean
|
|
20
|
+
/** Optional label displayed above the bar. */
|
|
21
|
+
label?: string
|
|
22
|
+
/** Callback when a day bar is clicked. */
|
|
23
|
+
onDayClick?: (day: DayStatus) => void
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dayColor: Record<string, string> = {
|
|
28
|
+
ok: 'bg-[hsl(var(--status-ok))]',
|
|
29
|
+
degraded: 'bg-[hsl(var(--status-warning))]',
|
|
30
|
+
outage: 'bg-[hsl(var(--status-critical))]',
|
|
31
|
+
'no-data': 'bg-[hsl(var(--text-disabled))]',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dayHover: Record<string, string> = {
|
|
35
|
+
ok: 'hover:bg-[hsl(var(--status-ok))]/80',
|
|
36
|
+
degraded: 'hover:bg-[hsl(var(--status-warning))]/80',
|
|
37
|
+
outage: 'hover:bg-[hsl(var(--status-critical))]/80',
|
|
38
|
+
'no-data': 'hover:bg-[hsl(var(--text-disabled))]/80',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatDate(dateStr: string): string {
|
|
42
|
+
try {
|
|
43
|
+
const d = new Date(dateStr + 'T00:00:00')
|
|
44
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
45
|
+
} catch {
|
|
46
|
+
return dateStr
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @description A row of thin vertical bars representing daily uptime status,
|
|
52
|
+
* inspired by GitHub/Statuspage uptime indicators. Each bar is colored by
|
|
53
|
+
* operational status (ok/degraded/outage/no-data). Hover shows date and uptime
|
|
54
|
+
* percentage. Designed for SLA and availability tracking displays.
|
|
55
|
+
*/
|
|
56
|
+
export function UptimeTracker({
|
|
57
|
+
days,
|
|
58
|
+
showPercentage = true,
|
|
59
|
+
label,
|
|
60
|
+
onDayClick,
|
|
61
|
+
className,
|
|
62
|
+
}: UptimeTrackerProps) {
|
|
63
|
+
const reduced = useReducedMotion()
|
|
64
|
+
|
|
65
|
+
// Calculate overall uptime
|
|
66
|
+
const daysWithUptime = days.filter(d => d.uptime != null)
|
|
67
|
+
const overallUptime = daysWithUptime.length > 0
|
|
68
|
+
? daysWithUptime.reduce((sum, d) => sum + (d.uptime ?? 0), 0) / daysWithUptime.length
|
|
69
|
+
: null
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className={cn('space-y-2', className)}>
|
|
73
|
+
{/* Header */}
|
|
74
|
+
{(label || showPercentage) && (
|
|
75
|
+
<div className="flex items-center justify-between">
|
|
76
|
+
{label && (
|
|
77
|
+
<span className="text-[0.8125rem] font-medium text-[hsl(var(--text-primary))]">
|
|
78
|
+
{label}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
{showPercentage && overallUptime != null && (
|
|
82
|
+
<span className={cn(
|
|
83
|
+
'text-[0.8125rem] font-semibold tabular-nums',
|
|
84
|
+
overallUptime >= 99.9
|
|
85
|
+
? 'text-[hsl(var(--status-ok))]'
|
|
86
|
+
: overallUptime >= 99
|
|
87
|
+
? 'text-[hsl(var(--status-warning))]'
|
|
88
|
+
: 'text-[hsl(var(--status-critical))]',
|
|
89
|
+
)}>
|
|
90
|
+
{overallUptime.toFixed(2)}% uptime
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Day bars */}
|
|
97
|
+
<div className="flex items-end gap-px h-8">
|
|
98
|
+
{days.map((day, i) => {
|
|
99
|
+
const tooltip = [
|
|
100
|
+
formatDate(day.date),
|
|
101
|
+
day.uptime != null ? `${day.uptime.toFixed(1)}%` : day.status,
|
|
102
|
+
].join(' \u2014 ')
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<motion.button
|
|
106
|
+
key={day.date}
|
|
107
|
+
type="button"
|
|
108
|
+
initial={reduced ? false : { scaleY: 0 }}
|
|
109
|
+
animate={{ scaleY: 1 }}
|
|
110
|
+
transition={{ duration: 0.15, delay: reduced ? 0 : Math.min(i * 0.005, 0.3) }}
|
|
111
|
+
style={{ originY: '100%' }}
|
|
112
|
+
onClick={() => onDayClick?.(day)}
|
|
113
|
+
title={tooltip}
|
|
114
|
+
aria-label={tooltip}
|
|
115
|
+
className={cn(
|
|
116
|
+
'flex-1 min-w-[2px] h-full rounded-[1px] transition-opacity cursor-pointer',
|
|
117
|
+
dayColor[day.status] ?? dayColor['no-data'],
|
|
118
|
+
dayHover[day.status] ?? dayHover['no-data'],
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Date range labels */}
|
|
126
|
+
{days.length > 0 && (
|
|
127
|
+
<div className="flex items-center justify-between">
|
|
128
|
+
<span className="text-[0.625rem] text-[hsl(var(--text-tertiary))]">
|
|
129
|
+
{formatDate(days[0].date)}
|
|
130
|
+
</span>
|
|
131
|
+
<span className="text-[0.625rem] text-[hsl(var(--text-tertiary))]">
|
|
132
|
+
{formatDate(days[days.length - 1].date)}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { cn, clamp } from '../utils'
|
|
6
|
+
|
|
7
|
+
export interface UtilizationBarProps {
|
|
8
|
+
/** Utilization value from 0 to 100. */
|
|
9
|
+
value: number
|
|
10
|
+
/** Warning and critical thresholds. */
|
|
11
|
+
thresholds?: { warning: number; critical: number }
|
|
12
|
+
/** Optional label displayed to the left. */
|
|
13
|
+
label?: string
|
|
14
|
+
/** Show percentage value text to the right. */
|
|
15
|
+
showValue?: boolean
|
|
16
|
+
/** Bar height size. */
|
|
17
|
+
size?: 'sm' | 'md' | 'lg'
|
|
18
|
+
/** Animate the fill width on mount. */
|
|
19
|
+
animated?: boolean
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sizeMap = {
|
|
24
|
+
sm: 'h-1.5',
|
|
25
|
+
md: 'h-2.5',
|
|
26
|
+
lg: 'h-4',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getBarColor(value: number, warning: number, critical: number): string {
|
|
30
|
+
if (value >= critical) return 'hsl(var(--util-high))'
|
|
31
|
+
if (value >= warning) return 'hsl(var(--util-medium))'
|
|
32
|
+
return 'hsl(var(--util-low))'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getTextClass(value: number, warning: number, critical: number): string {
|
|
36
|
+
if (value >= critical) return 'text-[hsl(var(--status-critical))]'
|
|
37
|
+
if (value >= warning) return 'text-[hsl(var(--status-warning))]'
|
|
38
|
+
return 'text-[hsl(var(--text-secondary))]'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @description A segmented horizontal bar showing resource utilization with
|
|
43
|
+
* color-coded thresholds (green/yellow/red). Supports animated fill on mount.
|
|
44
|
+
*/
|
|
45
|
+
export function UtilizationBar({
|
|
46
|
+
value: rawValue,
|
|
47
|
+
thresholds,
|
|
48
|
+
label,
|
|
49
|
+
showValue = true,
|
|
50
|
+
size = 'md',
|
|
51
|
+
animated = true,
|
|
52
|
+
className,
|
|
53
|
+
}: UtilizationBarProps) {
|
|
54
|
+
const reduced = useReducedMotion()
|
|
55
|
+
const value = clamp(rawValue, 0, 100)
|
|
56
|
+
const warning = thresholds?.warning ?? 70
|
|
57
|
+
const critical = thresholds?.critical ?? 90
|
|
58
|
+
|
|
59
|
+
const [displayWidth, setDisplayWidth] = useState(animated && !reduced ? 0 : value)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!animated || reduced) {
|
|
63
|
+
setDisplayWidth(value)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
// Trigger the animated width after mount
|
|
67
|
+
const raf = requestAnimationFrame(() => setDisplayWidth(value))
|
|
68
|
+
return () => cancelAnimationFrame(raf)
|
|
69
|
+
}, [value, animated, reduced])
|
|
70
|
+
|
|
71
|
+
const barColor = getBarColor(value, warning, critical)
|
|
72
|
+
const textClass = getTextClass(value, warning, critical)
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className={cn('flex items-center gap-2', className)} title={`${value.toFixed(1)}%`}>
|
|
76
|
+
{label && (
|
|
77
|
+
<span className="text-[0.75rem] font-medium text-[hsl(var(--text-secondary))] shrink-0 min-w-[3rem]">
|
|
78
|
+
{label}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
<div
|
|
82
|
+
className={cn(
|
|
83
|
+
'flex-1 rounded-full bg-[hsl(var(--bg-elevated))] overflow-hidden',
|
|
84
|
+
sizeMap[size],
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
className="h-full rounded-full"
|
|
89
|
+
style={{
|
|
90
|
+
width: `${displayWidth}%`,
|
|
91
|
+
backgroundColor: barColor,
|
|
92
|
+
transition: animated && !reduced ? 'width 600ms cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
{showValue && (
|
|
97
|
+
<span className={cn('text-[0.75rem] font-medium tabular-nums shrink-0 min-w-[2.5rem] text-right', textClass)}>
|
|
98
|
+
{value.toFixed(0)}%
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|