feat(dashboard): added new Pages

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-09-04 11:19:10 +02:00
parent 4e5adbedff
commit 7e941080dc
26 changed files with 635 additions and 132 deletions

View File

@@ -3,8 +3,10 @@ import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon } from 'lucide-react';
import { column } from 'mathjs';
import type { IServiceEvent } from '@openpanel/db';
@@ -25,15 +27,7 @@ export const EventsTable = ({ query, ...props }: Props) => {
const { data, isFetching, isLoading } = query;
if (isLoading) {
return (
<div className="flex flex-col gap-2">
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
</div>
);
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {

View File

@@ -60,7 +60,7 @@ export const GridCell: React.FC<
}) => (
<Component
className={cn(
'flex h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
'flex min-h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
colSpan && `col-span-${colSpan}`,
className

View File

@@ -9,7 +9,7 @@ export function PageTabs({
className?: string;
}) {
return (
<div className={cn('overflow-x-auto', className)}>
<div className={cn('h-7 overflow-x-auto', className)}>
<div className="flex gap-4 whitespace-nowrap text-3xl font-semibold">
{children}
</div>

View File

@@ -3,6 +3,7 @@ import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon } from 'lucide-react';
@@ -26,15 +27,7 @@ export const ProfilesTable = ({ type, query, ...props }: Props) => {
const { data, isFetching, isLoading } = query;
if (isLoading) {
return (
<div className="flex flex-col gap-2">
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
</div>
);
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {

View File

@@ -1,8 +1,8 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';
import { MetricCardEmpty } from './MetricCard';
import { ResponsiveContainer } from './ResponsiveContainer';
export function ChartEmpty() {
const { editMode, chartType } = useChartContext();
@@ -20,12 +20,10 @@ export function ChartEmpty() {
}
return (
<div
className={
'flex aspect-video max-h-[300px] min-h-[200px] w-full items-center justify-center'
}
>
No data
</div>
<ResponsiveContainer>
<div className={'flex h-full w-full items-center justify-center'}>
No data
</div>
</ResponsiveContainer>
);
}

View File

@@ -1,15 +1,20 @@
import { cn } from '@/utils/cn';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ChartLoadingProps {
className?: string;
aspectRatio?: number;
}
export function ChartLoading({ className }: ChartLoadingProps) {
export function ChartLoading({ className, aspectRatio }: ChartLoadingProps) {
return (
<div
className={cn(
'bg-def-200 aspect-video max-h-[300px] min-h-[200px] w-full animate-pulse rounded',
className
)}
/>
<ResponsiveContainer aspectRatio={aspectRatio}>
<div
className={cn(
'h-full w-full animate-pulse rounded bg-def-200',
className
)}
/>
</ResponsiveContainer>
);
}

View File

@@ -6,6 +6,9 @@ import type { LucideIcon } from 'lucide-react';
import type { IChartProps, IChartSerie } from '@openpanel/validation';
export interface IChartContextType extends IChartProps {
hideXAxis?: boolean;
hideYAxis?: boolean;
aspectRatio?: number;
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;

View File

@@ -1,13 +1,17 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useInViewport } from 'react-in-viewport';
import type { IChartRoot } from '.';
import { ChartRoot } from '.';
import { ChartLoading } from './ChartLoading';
export function LazyChart(props: IChartRoot) {
export function LazyChart({
className,
...props
}: IChartRoot & { className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
@@ -21,11 +25,11 @@ export function LazyChart(props: IChartRoot) {
}, [inViewport]);
return (
<div ref={ref}>
<div ref={ref} className={cn('w-full', className)}>
{once.current || inViewport ? (
<ChartRoot {...props} editMode={false} />
) : (
<ChartLoading />
<ChartLoading aspectRatio={props.aspectRatio} />
)}
</div>
);

View File

@@ -1,9 +1,11 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import type { IToolTipProps } from '@/types';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
@@ -24,11 +26,35 @@ export function ReportChartTooltip({
const { unit, interval } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!active || !payload) {
return null;
}
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
);
if (!payload.length) {
const inactive = !active || !payload?.length;
useEffect(() => {
const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
type: 'mousemove',
listener(event) {
if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
}
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => {
unsubMouseMove();
unsubDragEnter();
};
}, [inactive]);
if (inactive) {
return null;
}
@@ -41,55 +67,81 @@ export function ReportChartTooltip({
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
}
const tooltipWidth = 300;
const screenWidth = window.innerWidth;
const newX = x;
if (newX + tooltipWidth > screenWidth) {
return screenWidth - tooltipWidth;
}
return newX;
};
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
<Portal.Portal
style={{
position: 'fixed',
top: position?.y,
left: correctXPosition(position?.x),
zIndex: 1000,
}}
>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
<div className="font-mono flex justify-between gap-8 font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...data.previous} />
<PreviousDiffIndicator {...data.previous} />
</div>
</div>
</div>
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">and {hidden.length} more...</div>
)}
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">
and {hidden.length} more...
</div>
)}
</div>
</Portal.Portal>
);
}

View File

@@ -6,10 +6,9 @@ import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { useTheme } from 'next-themes';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
@@ -21,7 +20,11 @@ interface ReportHistogramChartProps {
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const bg = theme?.colors?.slate?.['200'] as string;
const themeMode = useTheme();
const bg =
themeMode?.theme === 'dark'
? theme.colors['def-100']
: theme.colors['def-300'];
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
@@ -33,7 +36,7 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
}
export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
const { editMode, previous, interval } = useChartContext();
const { editMode, previous, interval, aspectRatio } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
@@ -41,10 +44,15 @@ export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
return (
<>
<div className={cn(editMode && 'card p-4')}>
<ResponsiveContainer>
<div className={cn('w-full', editMode && 'card p-4')}>
<ResponsiveContainer aspectRatio={aspectRatio}>
{({ width, height }) => (
<BarChart width={width} height={height} data={rechartData}>
<BarChart
width={width}
height={height}
data={rechartData}
barCategoryGap={10}
>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
@@ -70,22 +78,45 @@ export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
{series.map((serie) => {
return (
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id="colorGradient"
x1="0"
y1="1"
x2="0"
y2="0"
>
<stop
offset="0%"
stopColor={getChartColor(serie.index)}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={getChartColor(serie.index)}
stopOpacity={1}
/>
</linearGradient>
</defs>
{previous && (
<Bar
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
fillOpacity={0.1}
radius={3}
barSize={20} // Adjust the bar width here
/>
)}
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
fill="url(#colorGradient)"
radius={3}
fillOpacity={1}
barSize={20} // Adjust the bar width here
/>
</React.Fragment>
);

View File

@@ -45,7 +45,11 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
endDate,
range,
lineType,
aspectRatio,
hideXAxis,
hideYAxis,
} = useChartContext();
const dataLength = data.series[0]?.data?.length || 0;
const references = api.reference.getChartReferences.useQuery(
{
projectId,
@@ -120,11 +124,10 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
}, [series]);
const isAreaStyle = series.length === 1;
return (
<>
<div className={cn(editMode && 'card p-4')}>
<ResponsiveContainer>
<div className={cn('w-full', editMode && 'card p-4')}>
<ResponsiveContainer aspectRatio={aspectRatio}>
{({ width, height }) => (
<ComposedChart width={width} height={height} data={rechartData}>
<CartesianGrid
@@ -149,7 +152,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
/>
))}
<YAxis
width={getYAxisWidth(data.metrics.max)}
width={hideYAxis ? 0 : getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
@@ -164,6 +167,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
)}
<Tooltip content={<ReportChartTooltip />} />
<XAxis
height={hideXAxis ? 0 : undefined}
axisLine={false}
fontSize={12}
dataKey="timestamp"
@@ -212,7 +216,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
)}
</defs>
<Line
dot={isAreaStyle}
dot={isAreaStyle && dataLength <= 8}
type={lineType}
name={serie.id}
isAnimationActive={false}

View File

@@ -1,28 +1,45 @@
'use client';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
interface ResponsiveContainerProps {
children: (props: { width: number; height: number }) => React.ReactNode;
aspectRatio?: number;
children:
| ((props: { width: number; height: number }) => React.ReactNode)
| React.ReactNode;
}
export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
export function ResponsiveContainer({
children,
aspectRatio = 0.5625,
}: ResponsiveContainerProps) {
const maxHeight = 300;
const minHeight = 200;
return (
<div
className="w-full"
style={{
aspectRatio: 1 / (aspectRatio || DEFAULT_ASPECT_RATIO),
maxHeight,
minHeight,
}}
className={'aspect-video w-full max-sm:-mx-3'}
>
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(maxHeight, width * 0.5625),
})
}
</AutoSizer>
{typeof children === 'function' ? (
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(
maxHeight,
width * aspectRatio || DEFAULT_ASPECT_RATIO
),
})
}
</AutoSizer>
) : (
children
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import * as Portal from '@radix-ui/react-portal';
import type { IChartProps } from '@openpanel/validation';
@@ -24,14 +25,18 @@ export function ChartRoot(props: IChartContextType) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
<ChartLoading aspectRatio={props.aspectRatio} />
);
}
return (
<Suspense
fallback={
props.chartType === 'metric' ? <MetricCardLoading /> : <ChartLoading />
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading aspectRatio={props.aspectRatio} />
)
}
>
<ChartProvider {...props}>
@@ -49,9 +54,13 @@ interface ChartRootShortcutProps {
interval?: IChartProps['interval'];
events: IChartProps['events'];
breakdowns?: IChartProps['breakdowns'];
lineType?: IChartProps['lineType'];
hideXAxis?: boolean;
aspectRatio?: number;
}
export const ChartRootShortcut = ({
hideXAxis,
projectId,
range = '7d',
previous = false,
@@ -59,19 +68,25 @@ export const ChartRootShortcut = ({
interval = 'day',
events,
breakdowns,
aspectRatio,
lineType = 'monotone',
}: ChartRootShortcutProps) => {
return (
<ChartRoot
projectId={projectId}
range={range}
breakdowns={breakdowns ?? []}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType="bump"
metric="sum"
events={events}
/>
<Portal.Root>
<ChartRoot
projectId={projectId}
range={range}
breakdowns={breakdowns ?? []}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType={lineType}
metric="sum"
events={events}
aspectRatio={aspectRatio}
hideXAxis={hideXAxis}
/>
</Portal.Root>
);
};

View File

@@ -110,6 +110,39 @@ const TableCaption = React.forwardRef<
));
TableCaption.displayName = 'TableCaption';
export function TableSkeleton({
rows = 10,
cols = 2,
}: {
rows?: number;
cols?: number;
}) {
return (
<Table>
<TableHeader>
<TableRow>
{Array.from({ length: cols }).map((_, j) => (
<TableHead key={j}>
<div className="h-4 w-1/4 animate-pulse rounded-full bg-def-300" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: cols }).map((_, j) => (
<TableCell key={j}>
<div className="h-4 w-1/4 min-w-20 animate-pulse rounded-full bg-def-300" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
export {
Table,
TableHeader,

View File

@@ -37,7 +37,7 @@ export function WidgetTitle({
)}
>
{Icon && (
<div className="bg-def-200 absolute left-0 rounded-lg p-2">
<div className="absolute left-0 rounded-lg bg-def-200 p-2">
<Icon size={18} />
</div>
)}