This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-09 19:58:24 +01:00
parent ca15717885
commit ba79ac570c
27 changed files with 1826 additions and 108 deletions

View File

@@ -8,7 +8,13 @@ import { LogoSquare } from '../logo';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
export function ShareEnterPassword({ shareId }: { shareId: string }) { export function ShareEnterPassword({
shareId,
shareType = 'overview',
}: {
shareId: string;
shareType?: 'overview' | 'dashboard' | 'report';
}) {
const trpc = useTRPC(); const trpc = useTRPC();
const mutation = useMutation( const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({ trpc.auth.signInShare.mutationOptions({
@@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
defaultValues: { defaultValues: {
password: '', password: '',
shareId, shareId,
shareType,
}, },
}); });
@@ -32,6 +39,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
mutation.mutate({ mutation.mutate({
password: data.password, password: data.password,
shareId, shareId,
shareType,
}); });
}); });
@@ -40,9 +48,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left"> <div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2"> <div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" /> <LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div> <div className="text-xl font-semibold">
{shareType === 'dashboard'
? 'Dashboard is locked'
: shareType === 'report'
? 'Report is locked'
: 'Overview is locked'}
</div>
<div className="text-lg text-muted-foreground leading-normal"> <div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview Please enter correct password to access this{' '}
{shareType === 'dashboard'
? 'dashboard'
: shareType === 'report'
? 'report'
: 'overview'}
</div> </div>
</div> </div>
<form onSubmit={onSubmit} className="col gap-4 mt-6"> <form onSubmit={onSubmit} className="col gap-4 mt-6">

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -9,11 +10,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportAreaChart() { export function ReportAreaChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.chartByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
@@ -9,11 +10,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportBarChart() { export function ReportBarChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.aggregate.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.aggregateByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -28,9 +28,11 @@ export type ReportChartContextType = {
onClick: () => void; onClick: () => void;
}[]; }[];
}>; }>;
report: IChartProps; report: IChartProps & { id?: string };
isLazyLoading: boolean; isLazyLoading: boolean;
isEditMode: boolean; isEditMode: boolean;
shareId?: string;
shareType?: 'dashboard' | 'report';
}; };
type ReportChartContextProviderProps = ReportChartContextType & { type ReportChartContextProviderProps = ReportChartContextType & {
@@ -40,6 +42,8 @@ type ReportChartContextProviderProps = ReportChartContextType & {
export type ReportChartProps = Partial<ReportChartContextType> & { export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput; report: IChartInput;
lazy?: boolean; lazy?: boolean;
shareId?: string;
shareType?: 'dashboard' | 'report';
}; };
const context = createContext<ReportChartContextType | null>(null); const context = createContext<ReportChartContextType | null>(null);

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
@@ -11,11 +12,29 @@ import { Chart } from './chart';
import { Summary } from './summary'; import { Summary } from './summary';
export function ReportConversionChart() { export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit); console.log(report.limit);
const res = useQuery( const res = useQuery(
trpc.chart.conversion.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.conversionByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import type { IChartInput } from '@openpanel/validation'; import type { IChartInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
@@ -14,6 +15,7 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() { export function ReportFunnelChart() {
const { const {
report: { report: {
id,
series, series,
range, range,
projectId, projectId,
@@ -25,8 +27,29 @@ export function ReportFunnelChart() {
breakdowns, breakdowns,
}, },
isLazyLoading, isLazyLoading,
shareId,
shareType,
} = useReportChartContext(); } = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const trpc = useTRPC();
const res = useQuery(
shareId && shareType && id
? trpc.chart.funnelByReport.queryOptions(
{
reportId: id,
shareId,
shareType,
range: overviewRange ?? undefined,
startDate: overviewStartDate ?? undefined,
endDate: overviewEndDate ?? undefined,
interval: overviewInterval ?? undefined,
},
{
enabled: !isLazyLoading && series.length > 0,
},
)
: (() => {
const input: IChartInput = { const input: IChartInput = {
series, series,
range, range,
@@ -42,11 +65,10 @@ export function ReportFunnelChart() {
endDate, endDate,
limit: 20, limit: 20,
}; };
const trpc = useTRPC(); return trpc.chart.funnel.queryOptions(input, {
const res = useQuery(
trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0, enabled: !isLazyLoading && input.series.length > 0,
}), });
})(),
); );
if (isLazyLoading || res.isLoading) { if (isLazyLoading || res.isLoading) {

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -9,11 +10,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportHistogramChart() { export function ReportHistogramChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.chartByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -10,11 +11,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportLineChart() { export function ReportLineChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.chartByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -9,11 +10,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportMapChart() { export function ReportMapChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.chartByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -8,11 +9,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportMetricChart() { export function ReportMetricChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.chart.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.chartByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -9,11 +10,29 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportPieChart() { export function ReportPieChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report, shareId, shareType } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
trpc.chart.aggregate.queryOptions(report, { shareId && shareType && 'id' in report && report.id
? trpc.chart.aggregateByReport.queryOptions(
{
reportId: report.id,
shareId,
shareType,
range: range ?? undefined,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
interval: interval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
)
: trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,

View File

@@ -18,8 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch, useSelector } from '@/redux';
import { GanttChartSquareIcon } from 'lucide-react'; import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db'; import type { IServiceReport } from '@openpanel/db';
@@ -52,8 +54,19 @@ export default function ReportEditor({
return ( return (
<Sheet> <Sheet>
<div> <div>
<div className="p-4"> <div className="p-4 flex items-center justify-between">
<EditReportName /> <EditReportName />
{initialReport?.id && (
<Button
variant="outline"
icon={ShareIcon}
onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id })
}
>
Share
</Button>
)}
</div> </div>
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6"> <div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild> <SheetTrigger asChild>

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container'; import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty'; import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error'; import { ReportChartError } from '../common/error';
@@ -12,6 +13,7 @@ import CohortTable from './table';
export function ReportRetentionChart() { export function ReportRetentionChart() {
const { const {
report: { report: {
id,
series, series,
range, range,
projectId, projectId,
@@ -21,7 +23,10 @@ export function ReportRetentionChart() {
interval, interval,
}, },
isLazyLoading, isLazyLoading,
shareId,
shareType,
} = useReportChartContext(); } = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const eventSeries = series.filter((item) => item.type === 'event'); const eventSeries = series.filter((item) => item.type === 'event');
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String); const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String); const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
@@ -29,7 +34,24 @@ export function ReportRetentionChart() {
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery( const res = useQuery(
trpc.chart.cohort.queryOptions( shareId && shareType && id
? trpc.chart.cohortByReport.queryOptions(
{
reportId: id,
shareId,
shareType,
range: overviewRange ?? undefined,
startDate: overviewStartDate ?? undefined,
endDate: overviewEndDate ?? undefined,
interval: overviewInterval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: isEnabled,
},
)
: trpc.chart.cohort.queryOptions(
{ {
firstEvent, firstEvent,
secondEvent, secondEvent,

View File

@@ -30,7 +30,9 @@ import OverviewFilters from './overview-filters';
import RequestPasswordReset from './request-reset-password'; import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report'; import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan'; import SelectBillingPlan from './select-billing-plan';
import ShareDashboardModal from './share-dashboard-modal';
import ShareOverviewModal from './share-overview-modal'; import ShareOverviewModal from './share-overview-modal';
import ShareReportModal from './share-report-modal';
import ViewChartUsers from './view-chart-users'; import ViewChartUsers from './view-chart-users';
const modals = { const modals = {
@@ -51,6 +53,8 @@ const modals = {
EditReport: EditReport, EditReport: EditReport,
EditReference: EditReference, EditReference: EditReference,
ShareOverviewModal: ShareOverviewModal, ShareOverviewModal: ShareOverviewModal,
ShareDashboardModal: ShareDashboardModal,
ShareReportModal: ShareReportModal,
AddReference: AddReference, AddReference: AddReference,
ViewChartUsers: ViewChartUsers, ViewChartUsers: ViewChartUsers,
Instructions: Instructions, Instructions: Instructions,

View File

@@ -0,0 +1,97 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zShareDashboard } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = zShareDashboard;
type IForm = z.infer<typeof validator>;
export default function ShareDashboardModal({
dashboardId,
}: {
dashboardId: string;
}) {
const { projectId, organizationId } = useAppParams();
const navigate = useNavigate();
const { register, handleSubmit } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
password: '',
projectId,
organizationId,
dashboardId,
},
});
const trpc = useTRPC();
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.share.createDashboard.mutationOptions({
onError: handleError,
onSuccess(res) {
queryClient.invalidateQueries(trpc.share.dashboard.pathFilter());
toast('Success', {
description: `Your dashboard is now ${
res.public ? 'public' : 'private'
}`,
action: {
label: 'View',
onClick: () =>
navigate({
to: '/share/dashboard/$shareId',
params: {
shareId: res.id,
},
}),
},
});
popModal();
},
}),
);
return (
<ModalContent className="max-w-md">
<ModalHeader
title="Dashboard public availability"
text="You can choose if you want to add a password to make it a bit more private."
/>
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Input
{...register('password')}
placeholder="Enter your password"
size="large"
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isPending}>
Make it public
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,91 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zShareReport } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = zShareReport;
type IForm = z.infer<typeof validator>;
export default function ShareReportModal({ reportId }: { reportId: string }) {
const { projectId, organizationId } = useAppParams();
const navigate = useNavigate();
const { register, handleSubmit } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
password: '',
projectId,
organizationId,
reportId,
},
});
const trpc = useTRPC();
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.share.createReport.mutationOptions({
onError: handleError,
onSuccess(res) {
queryClient.invalidateQueries(trpc.share.report.pathFilter());
toast('Success', {
description: `Your report is now ${res.public ? 'public' : 'private'}`,
action: {
label: 'View',
onClick: () =>
navigate({
to: '/share/report/$shareId',
params: {
shareId: res.id,
},
}),
},
});
popModal();
},
}),
);
return (
<ModalContent className="max-w-md">
<ModalHeader
title="Report public availability"
text="You can choose if you want to add a password to make it a bit more private."
/>
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Input
{...register('password')}
placeholder="Enter your password"
size="large"
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isPending}>
Make it public
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -23,7 +23,9 @@ import { Route as LoginResetPasswordRouteImport } from './routes/_login.reset-pa
import { Route as LoginLoginRouteImport } from './routes/_login.login' import { Route as LoginLoginRouteImport } from './routes/_login.login'
import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId' import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId'
import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index' import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index'
import { Route as ShareReportShareIdRouteImport } from './routes/share.report.$shareId'
import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId' import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId'
import { Route as ShareDashboardShareIdRouteImport } from './routes/share.dashboard.$shareId'
import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project' import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project'
import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings' import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings'
import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing' import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing'
@@ -164,11 +166,21 @@ const AppOrganizationIdIndexRoute = AppOrganizationIdIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AppOrganizationIdRoute, getParentRoute: () => AppOrganizationIdRoute,
} as any) } as any)
const ShareReportShareIdRoute = ShareReportShareIdRouteImport.update({
id: '/share/report/$shareId',
path: '/share/report/$shareId',
getParentRoute: () => rootRouteImport,
} as any)
const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({ const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({
id: '/share/overview/$shareId', id: '/share/overview/$shareId',
path: '/share/overview/$shareId', path: '/share/overview/$shareId',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ShareDashboardShareIdRoute = ShareDashboardShareIdRouteImport.update({
id: '/share/dashboard/$shareId',
path: '/share/dashboard/$shareId',
getParentRoute: () => rootRouteImport,
} as any)
const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({ const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({
id: '/onboarding/project', id: '/onboarding/project',
path: '/onboarding/project', path: '/onboarding/project',
@@ -498,7 +510,9 @@ export interface FileRoutesByFullPath {
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute '/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute '/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
'/onboarding/project': typeof StepsOnboardingProjectRoute '/onboarding/project': typeof StepsOnboardingProjectRoute
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute
'/share/report/$shareId': typeof ShareReportShareIdRoute
'/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
@@ -556,7 +570,9 @@ export interface FileRoutesByTo {
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute '/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute '/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
'/onboarding/project': typeof StepsOnboardingProjectRoute '/onboarding/project': typeof StepsOnboardingProjectRoute
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute
'/share/report/$shareId': typeof ShareReportShareIdRoute
'/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
@@ -614,7 +630,9 @@ export interface FileRoutesById {
'/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute '/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute '/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
'/_steps/onboarding/project': typeof StepsOnboardingProjectRoute '/_steps/onboarding/project': typeof StepsOnboardingProjectRoute
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute
'/share/report/$shareId': typeof ShareReportShareIdRoute
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
@@ -683,7 +701,9 @@ export interface FileRouteTypes {
| '/$organizationId/billing' | '/$organizationId/billing'
| '/$organizationId/settings' | '/$organizationId/settings'
| '/onboarding/project' | '/onboarding/project'
| '/share/dashboard/$shareId'
| '/share/overview/$shareId' | '/share/overview/$shareId'
| '/share/report/$shareId'
| '/$organizationId/' | '/$organizationId/'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
@@ -741,7 +761,9 @@ export interface FileRouteTypes {
| '/$organizationId/billing' | '/$organizationId/billing'
| '/$organizationId/settings' | '/$organizationId/settings'
| '/onboarding/project' | '/onboarding/project'
| '/share/dashboard/$shareId'
| '/share/overview/$shareId' | '/share/overview/$shareId'
| '/share/report/$shareId'
| '/$organizationId' | '/$organizationId'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
@@ -798,7 +820,9 @@ export interface FileRouteTypes {
| '/_app/$organizationId/billing' | '/_app/$organizationId/billing'
| '/_app/$organizationId/settings' | '/_app/$organizationId/settings'
| '/_steps/onboarding/project' | '/_steps/onboarding/project'
| '/share/dashboard/$shareId'
| '/share/overview/$shareId' | '/share/overview/$shareId'
| '/share/report/$shareId'
| '/_app/$organizationId/' | '/_app/$organizationId/'
| '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/chat'
| '/_app/$organizationId/$projectId/dashboards' | '/_app/$organizationId/$projectId/dashboards'
@@ -862,7 +886,9 @@ export interface RootRouteChildren {
StepsRoute: typeof StepsRouteWithChildren StepsRoute: typeof StepsRouteWithChildren
ApiConfigRoute: typeof ApiConfigRoute ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute ApiHealthcheckRoute: typeof ApiHealthcheckRoute
ShareDashboardShareIdRoute: typeof ShareDashboardShareIdRoute
ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute
ShareReportShareIdRoute: typeof ShareReportShareIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -965,6 +991,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdIndexRouteImport preLoaderRoute: typeof AppOrganizationIdIndexRouteImport
parentRoute: typeof AppOrganizationIdRoute parentRoute: typeof AppOrganizationIdRoute
} }
'/share/report/$shareId': {
id: '/share/report/$shareId'
path: '/share/report/$shareId'
fullPath: '/share/report/$shareId'
preLoaderRoute: typeof ShareReportShareIdRouteImport
parentRoute: typeof rootRouteImport
}
'/share/overview/$shareId': { '/share/overview/$shareId': {
id: '/share/overview/$shareId' id: '/share/overview/$shareId'
path: '/share/overview/$shareId' path: '/share/overview/$shareId'
@@ -972,6 +1005,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ShareOverviewShareIdRouteImport preLoaderRoute: typeof ShareOverviewShareIdRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/share/dashboard/$shareId': {
id: '/share/dashboard/$shareId'
path: '/share/dashboard/$shareId'
fullPath: '/share/dashboard/$shareId'
preLoaderRoute: typeof ShareDashboardShareIdRouteImport
parentRoute: typeof rootRouteImport
}
'/_steps/onboarding/project': { '/_steps/onboarding/project': {
id: '/_steps/onboarding/project' id: '/_steps/onboarding/project'
path: '/onboarding/project' path: '/onboarding/project'
@@ -1751,7 +1791,9 @@ const rootRouteChildren: RootRouteChildren = {
StepsRoute: StepsRouteWithChildren, StepsRoute: StepsRouteWithChildren,
ApiConfigRoute: ApiConfigRoute, ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute, ApiHealthcheckRoute: ApiHealthcheckRoute,
ShareDashboardShareIdRoute: ShareDashboardShareIdRoute,
ShareOverviewShareIdRoute: ShareOverviewShareIdRoute, ShareOverviewShareIdRoute: ShareOverviewShareIdRoute,
ShareReportShareIdRoute: ShareReportShareIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -17,6 +17,7 @@ import {
MoreHorizontal, MoreHorizontal,
PlusIcon, PlusIcon,
RotateCcw, RotateCcw,
ShareIcon,
Trash, Trash,
TrashIcon, TrashIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -30,7 +31,7 @@ import { OverviewRange } from '@/components/overview/overview-range';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react'; import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals'; import { pushModal, showConfirm } from '@/modals';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, useRouter } from '@tanstack/react-router';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -484,6 +485,12 @@ function Component() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem
onClick={() => pushModal('ShareDashboardModal', { dashboardId })}
>
<ShareIcon className="mr-2 size-4" />
Share dashboard
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
showConfirm({ showConfirm({

View File

@@ -0,0 +1,281 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
import { ReportChart } from '@/components/report-chart';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { PageContainer } from '@/components/page-container';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
import { z } from 'zod';
import { useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { cn } from '@/utils/cn';
import { timeWindows } from '@openpanel/constants';
const ResponsiveGridLayout = WidthProvider(Responsive);
type Layout = {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
};
const shareSearchSchema = z.object({
header: z.optional(z.number().or(z.string().or(z.boolean()))),
});
export const Route = createFileRoute('/share/dashboard/$shareId')({
component: RouteComponent,
validateSearch: shareSearchSchema,
loader: async ({ context, params }) => {
const share = await context.queryClient.ensureQueryData(
context.trpc.share.dashboard.queryOptions({
shareId: params.shareId,
}),
);
return { share };
},
head: ({ loaderData }) => {
if (!loaderData || !loaderData.share) {
return {
meta: [
{
title: 'Share not found - OpenPanel.dev',
},
],
};
}
return {
meta: [
{
title: `${loaderData.share.dashboard?.name} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
},
],
};
},
pendingComponent: FullPageLoadingState,
errorComponent: () => (
<FullPageEmptyState
title="Share not found"
description="The dashboard you are looking for does not exist."
className="min-h-[calc(100vh-theme(spacing.16))]"
/>
),
});
// Report Item Component for shared view
function ReportItem({
report,
shareId,
range,
startDate,
endDate,
interval,
}: {
report: any;
shareId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
}) {
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
<div className="flex-1">
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={
{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
} as any
}
shareId={shareId}
shareType="dashboard"
/>
</div>
</div>
);
}
function RouteComponent() {
const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/dashboard/$shareId' });
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const shareQuery = useSuspenseQuery(
trpc.share.dashboard.queryOptions({
shareId,
}),
);
const reportsQuery = useQuery(
trpc.share.dashboardReports.queryOptions({
shareId,
}),
);
const hasAccess = shareQuery.data?.hasAccess;
if (!shareQuery.data) {
throw notFound();
}
if (!shareQuery.data.public) {
throw notFound();
}
const share = shareQuery.data;
// Handle password protection
if (share.password && !hasAccess) {
return (
<ShareEnterPassword shareId={share.id} shareType="dashboard" />
);
}
const isHeaderVisible =
header !== '0' && header !== 0 && header !== 'false' && header !== false;
const reports = reportsQuery.data ?? [];
// Convert reports to grid layout format for all breakpoints
const layouts = useMemo(() => {
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
// Create responsive layouts for different breakpoints
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
return (
<div>
{isHeaderVisible && (
<div className="mx-auto max-w-7xl">
<LoginNavbar className="relative p-4" />
</div>
)}
<PageContainer>
<div className="sticky-header [animation-range:50px_100px]!">
<div className="p-4 col gap-2 mx-auto max-w-7xl">
<div className="row justify-between">
<div className="flex gap-2">
<OverviewRange />
<OverviewInterval />
</div>
</div>
</div>
</div>
{reports.length === 0 ? (
<FullPageEmptyState title="No reports" />
) : (
<div className="w-full overflow-hidden -mx-4">
<style>{`
.react-grid-item {
transition: none !important;
}
.react-grid-item.react-grid-placeholder {
display: none !important;
}
`}</style>
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isDraggable={false}
isResizable={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
>
{reports.map((report) => (
<div key={report.id}>
<ReportItem
report={report}
shareId={shareId}
range={range}
startDate={startDate}
endDate={endDate}
interval={interval}
/>
</div>
))}
</ResponsiveGridLayout>
</div>
)}
</PageContainer>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
import { ReportChart } from '@/components/report-chart';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { PageContainer } from '@/components/page-container';
import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
import { z } from 'zod';
const shareSearchSchema = z.object({
header: z.optional(z.number().or(z.string().or(z.boolean()))),
});
export const Route = createFileRoute('/share/report/$shareId')({
component: RouteComponent,
validateSearch: shareSearchSchema,
loader: async ({ context, params }) => {
const share = await context.queryClient.ensureQueryData(
context.trpc.share.report.queryOptions({
shareId: params.shareId,
}),
);
if (!share) {
return { share: null };
}
const report = await context.queryClient.ensureQueryData(
context.trpc.report.get.queryOptions({
reportId: share.reportId,
}),
);
return { share, report };
},
head: ({ loaderData }) => {
if (!loaderData || !loaderData.share) {
return {
meta: [
{
title: 'Share not found - OpenPanel.dev',
},
],
};
}
return {
meta: [
{
title: `${loaderData.report?.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
},
],
};
},
pendingComponent: FullPageLoadingState,
errorComponent: () => (
<FullPageEmptyState
title="Share not found"
description="The report you are looking for does not exist."
className="min-h-[calc(100vh-theme(spacing.16))]"
/>
),
});
function RouteComponent() {
const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/report/$shareId' });
const trpc = useTRPC();
const shareQuery = useSuspenseQuery(
trpc.share.report.queryOptions({
shareId,
}),
);
const reportQuery = useSuspenseQuery(
trpc.report.get.queryOptions({
reportId: shareQuery.data!.reportId,
}),
);
const hasAccess = shareQuery.data?.hasAccess;
if (!shareQuery.data) {
throw notFound();
}
if (!shareQuery.data.public) {
throw notFound();
}
const share = shareQuery.data;
const report = reportQuery.data;
// Handle password protection
if (share.password && !hasAccess) {
return <ShareEnterPassword shareId={share.id} shareType="report" />;
}
const isHeaderVisible =
header !== '0' && header !== 0 && header !== 'false' && header !== false;
return (
<div>
{isHeaderVisible && (
<div className="mx-auto max-w-7xl">
<LoginNavbar className="relative p-4" />
</div>
)}
<PageContainer>
<div className="sticky-header [animation-range:50px_100px]!">
<div className="p-4 col gap-2 mx-auto max-w-7xl">
<div className="row justify-between">
<div className="flex gap-2">
<OverviewRange />
<OverviewInterval />
</div>
</div>
</div>
</div>
<div className="p-4">
<div className="card">
<div className="p-4 border-b">
<div className="font-medium text-xl">{report.name}</div>
</div>
<div className="p-4">
<ReportChart
report={report}
shareId={shareId}
shareType="report"
/>
</div>
</div>
</div>
</PageContainer>
</div>
);
}

View File

@@ -0,0 +1,53 @@
-- CreateTable
CREATE TABLE "public"."share_dashboards" (
"id" TEXT NOT NULL,
"dashboardId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"public" BOOLEAN NOT NULL DEFAULT false,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "public"."share_reports" (
"id" TEXT NOT NULL,
"reportId" UUID NOT NULL,
"organizationId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"public" BOOLEAN NOT NULL DEFAULT false,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "share_dashboards_id_key" ON "public"."share_dashboards"("id");
-- CreateIndex
CREATE UNIQUE INDEX "share_dashboards_dashboardId_key" ON "public"."share_dashboards"("dashboardId");
-- CreateIndex
CREATE UNIQUE INDEX "share_reports_id_key" ON "public"."share_reports"("id");
-- CreateIndex
CREATE UNIQUE INDEX "share_reports_reportId_key" ON "public"."share_reports"("reportId");
-- AddForeignKey
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."dashboards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "public"."reports"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -56,6 +56,8 @@ model Organization {
Client Client[] Client Client[]
Dashboard Dashboard[] Dashboard Dashboard[]
ShareOverview ShareOverview[] ShareOverview ShareOverview[]
ShareDashboard ShareDashboard[]
ShareReport ShareReport[]
integrations Integration[] integrations Integration[]
invites Invite[] invites Invite[]
timezone String? timezone String?
@@ -189,6 +191,8 @@ model Project {
reports Report[] reports Report[]
dashboards Dashboard[] dashboards Dashboard[]
share ShareOverview? share ShareOverview?
shareDashboards ShareDashboard[]
shareReports ShareReport[]
meta EventMeta[] meta EventMeta[]
references Reference[] references Reference[]
access ProjectAccess[] access ProjectAccess[]
@@ -290,6 +294,7 @@ model Dashboard {
projectId String projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
reports Report[] reports Report[]
share ShareDashboard?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@ -328,6 +333,7 @@ model Report {
dashboardId String dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade) dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
layout ReportLayout? layout ReportLayout?
share ShareReport?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@ -372,6 +378,38 @@ model ShareOverview {
@@map("shares") @@map("shares")
} }
model ShareDashboard {
id String @unique
dashboardId String @unique
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
public Boolean @default(false)
password String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("share_dashboards")
}
model ShareReport {
id String @unique
reportId String @unique @db.Uuid
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
public Boolean @default(false)
password String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("share_reports")
}
model EventMeta { model EventMeta {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String

View File

@@ -18,3 +18,100 @@ export function getShareByProjectId(projectId: string) {
}, },
}); });
} }
// Dashboard sharing functions
export function getShareDashboardById(id: string) {
return db.shareDashboard.findFirst({
where: {
id,
},
include: {
dashboard: {
include: {
project: true,
},
},
},
});
}
export function getShareDashboardByDashboardId(dashboardId: string) {
return db.shareDashboard.findUnique({
where: {
dashboardId,
},
});
}
// Report sharing functions
export function getShareReportById(id: string) {
return db.shareReport.findFirst({
where: {
id,
},
include: {
report: {
include: {
project: true,
},
},
},
});
}
export function getShareReportByReportId(reportId: string) {
return db.shareReport.findUnique({
where: {
reportId,
},
});
}
// Validation for secure endpoints
export async function validateReportAccess(
reportId: string,
shareId: string,
shareType: 'dashboard' | 'report',
) {
if (shareType === 'dashboard') {
const share = await db.shareDashboard.findUnique({
where: { id: shareId },
include: {
dashboard: {
include: {
reports: {
where: { id: reportId },
},
},
},
},
});
if (!share || !share.public) {
throw new Error('Share not found or not public');
}
if (!share.dashboard.reports.some((r) => r.id === reportId)) {
throw new Error('Report does not belong to this dashboard');
}
return share;
} else {
const share = await db.shareReport.findUnique({
where: { id: shareId },
include: {
report: true,
},
});
if (!share || !share.public) {
throw new Error('Share not found or not public');
}
if (share.reportId !== reportId) {
throw new Error('Report ID mismatch');
}
return share;
}
}

View File

@@ -352,8 +352,23 @@ export const authRouter = createTRPCRouter({
) )
.input(zSignInShare) .input(zSignInShare)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { password, shareId } = input; const { password, shareId, shareType = 'overview' } = input;
const share = await getShareOverviewById(input.shareId);
let share: { password: string | null; public: boolean } | null = null;
let cookieName = '';
if (shareType === 'overview') {
share = await getShareOverviewById(shareId);
cookieName = `shared-overview-${shareId}`;
} else if (shareType === 'dashboard') {
const { getShareDashboardById } = await import('@openpanel/db');
share = await getShareDashboardById(shareId);
cookieName = `shared-dashboard-${shareId}`;
} else if (shareType === 'report') {
const { getShareReportById } = await import('@openpanel/db');
share = await getShareReportById(shareId);
cookieName = `shared-report-${shareId}`;
}
if (!share) { if (!share) {
throw TRPCNotFoundError('Share not found'); throw TRPCNotFoundError('Share not found');
@@ -373,7 +388,7 @@ export const authRouter = createTRPCRouter({
throw TRPCAccessError('Incorrect password'); throw TRPCAccessError('Incorrect password');
} }
ctx.setCookie(`shared-overview-${shareId}`, '1', { ctx.setCookie(cookieName, '1', {
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
...COOKIE_OPTIONS, ...COOKIE_OPTIONS,
}); });

View File

@@ -19,10 +19,12 @@ import {
getEventFiltersWhereClause, getEventFiltersWhereClause,
getEventMetasCached, getEventMetasCached,
getProfilesCached, getProfilesCached,
getReportById,
getSelectPropertyKey, getSelectPropertyKey,
getSettingsForProject, getSettingsForProject,
onlyReportEvents, onlyReportEvents,
sankeyService, sankeyService,
validateReportAccess,
} from '@openpanel/db'; } from '@openpanel/db';
import { import {
type IChartEvent, type IChartEvent,
@@ -815,6 +817,397 @@ export const chartRouter = createTRPCRouter({
return profiles; return profiles;
}), }),
chartByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
// Build chart input from report, merging date overrides
const chartInput: z.infer<typeof zChartInput> = {
projectId: report.projectId,
chartType: report.chartType,
series: report.series,
breakdowns: report.breakdowns,
interval: input.interval ?? report.interval,
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
previous: report.previous,
formula: report.formula,
metric: report.metric,
};
return ChartEngine.execute(chartInput);
}),
aggregateByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
// Build chart input from report, merging date overrides
const chartInput: z.infer<typeof zChartInput> = {
projectId: report.projectId,
chartType: report.chartType,
series: report.series,
breakdowns: report.breakdowns,
interval: input.interval ?? report.interval,
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
previous: report.previous,
formula: report.formula,
metric: report.metric,
};
return AggregateChartEngine.execute(chartInput);
}),
funnelByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const currentPeriod = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
funnelService.getFunnel({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...currentPeriod,
timezone,
funnelGroup: report.funnelGroup,
funnelWindow: report.funnelWindow,
}),
report.previous
? funnelService.getFunnel({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...previousPeriod,
timezone,
funnelGroup: report.funnelGroup,
funnelWindow: report.funnelWindow,
})
: Promise.resolve(null),
]);
return {
current,
previous,
};
}),
cohortByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const eventSeries = onlyReportEvents(report.series);
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(
String,
);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(
String,
);
if (firstEvent.length === 0 || secondEvent.length === 0) {
throw new Error('Report must have at least 2 event series');
}
const dates = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const interval = (input.interval ?? report.interval) as
| 'minute'
| 'hour'
| 'day'
| 'week'
| 'month';
const diffInterval = {
minute: () => differenceInDays(dates.endDate, dates.startDate),
hour: () => differenceInDays(dates.endDate, dates.startDate),
day: () => differenceInDays(dates.endDate, dates.startDate),
week: () => differenceInWeeks(dates.endDate, dates.startDate),
month: () => differenceInMonths(dates.endDate, dates.startDate),
}[interval]();
const sqlInterval = {
minute: 'DAY',
hour: 'DAY',
day: 'DAY',
week: 'WEEK',
month: 'MONTH',
}[interval];
const sqlToStartOf = {
minute: 'toDate',
hour: 'toDate',
day: 'toDate',
week: 'toStartOfWeek',
month: 'toStartOfMonth',
}[interval];
const countCriteria =
(report.criteria ?? 'on_or_after') === 'on_or_after' ? '>=' : '=';
const usersSelect = range(0, diffInterval + 1)
.map(
(index) =>
`groupUniqArrayIf(profile_id, x_after_cohort ${countCriteria} ${index}) AS interval_${index}_users`,
)
.join(',\n');
const countsSelect = range(0, diffInterval + 1)
.map(
(index) =>
`length(interval_${index}_users) AS interval_${index}_user_count`,
)
.join(',\n');
const whereEventNameIs = (event: string[]) => {
if (event.length === 1) {
return `name = ${sqlstring.escape(event[0])}`;
}
return `name IN (${event.map((e) => sqlstring.escape(e)).join(',')})`;
};
const cohortQuery = `
WITH
cohort_users AS (
SELECT
profile_id AS userID,
project_id,
${sqlToStartOf}(created_at) AS cohort_interval
FROM ${TABLE_NAMES.cohort_events_mv}
WHERE ${whereEventNameIs(firstEvent)}
AND project_id = ${sqlstring.escape(report.projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}')
),
last_event AS
(
SELECT
profile_id,
project_id,
toDate(created_at) AS event_date
FROM cohort_events_mv
WHERE ${whereEventNameIs(secondEvent)}
AND project_id = ${sqlstring.escape(report.projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}') + INTERVAL ${diffInterval} ${sqlInterval}
),
retention_matrix AS
(
SELECT
f.cohort_interval,
l.profile_id,
dateDiff('${sqlInterval}', f.cohort_interval, ${sqlToStartOf}(l.event_date)) AS x_after_cohort
FROM cohort_users AS f
INNER JOIN last_event AS l ON f.userID = l.profile_id
WHERE (l.event_date >= f.cohort_interval)
AND (l.event_date <= (f.cohort_interval + INTERVAL ${diffInterval} ${sqlInterval}))
),
interval_users AS (
SELECT
cohort_interval,
${usersSelect}
FROM retention_matrix
GROUP BY cohort_interval
),
cohort_sizes AS (
SELECT
cohort_interval,
COUNT(DISTINCT userID) AS total_first_event_count
FROM cohort_users
GROUP BY cohort_interval
)
SELECT
cohort_interval,
cohort_sizes.total_first_event_count,
${countsSelect}
FROM interval_users
LEFT JOIN cohort_sizes AS cs ON cohort_interval = cs.cohort_interval
ORDER BY cohort_interval ASC
`;
const cohortData = await chQuery<{
cohort_interval: string;
total_first_event_count: number;
[key: string]: any;
}>(cohortQuery);
return processCohortData(cohortData, diffInterval);
}),
conversionByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const currentPeriod = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
conversionService.getConversion({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...currentPeriod,
timezone,
}),
report.previous
? conversionService.getConversion({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...previousPeriod,
timezone,
})
: Promise.resolve(null),
]);
return {
current: current.map((serie, sIndex) => ({
...serie,
data: serie.data.map((d, dIndex) => ({
...d,
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
})),
})),
previous,
};
}),
}); });
function processCohortData( function processCohortData(

View File

@@ -1,11 +1,18 @@
import ShortUniqueId from 'short-unique-id'; import ShortUniqueId from 'short-unique-id';
import { db } from '@openpanel/db'; import {
import { zShareOverview } from '@openpanel/validation'; db,
getReportsByDashboardId,
getReportById,
getShareDashboardById,
getShareReportById,
} from '@openpanel/db';
import { zShareDashboard, zShareOverview, zShareReport } from '@openpanel/validation';
import { hashPassword } from '@openpanel/auth'; import { hashPassword } from '@openpanel/auth';
import { z } from 'zod'; import { z } from 'zod';
import { TRPCNotFoundError } from '../errors'; import { getProjectAccess } from '../access';
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
const uid = new ShortUniqueId({ length: 6 }); const uid = new ShortUniqueId({ length: 6 });
@@ -85,4 +92,206 @@ export const shareRouter = createTRPCRouter({
}, },
}); });
}), }),
// Dashboard sharing
dashboard: publicProcedure
.input(
z
.object({
dashboardId: z.string(),
})
.or(
z.object({
shareId: z.string(),
}),
),
)
.query(async ({ input, ctx }) => {
const share = await db.shareDashboard.findUnique({
include: {
organization: {
select: {
name: true,
},
},
project: {
select: {
name: true,
},
},
dashboard: {
select: {
name: true,
},
},
},
where:
'dashboardId' in input
? {
dashboardId: input.dashboardId,
}
: {
id: input.shareId,
},
});
if (!share) {
if ('shareId' in input) {
throw TRPCNotFoundError('Dashboard share not found');
}
return null;
}
return {
...share,
hasAccess: !!ctx.cookies[`shared-dashboard-${share?.id}`],
};
}),
createDashboard: protectedProcedure
.input(zShareDashboard)
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const passwordHash = input.password
? await hashPassword(input.password)
: null;
return db.shareDashboard.upsert({
where: {
dashboardId: input.dashboardId,
},
create: {
id: uid.rnd(),
organizationId: input.organizationId,
projectId: input.projectId,
dashboardId: input.dashboardId,
public: input.public,
password: passwordHash,
},
update: {
public: input.public,
password: passwordHash,
},
});
}),
dashboardReports: publicProcedure
.input(
z.object({
shareId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const share = await getShareDashboardById(input.shareId);
if (!share || !share.public) {
throw TRPCNotFoundError('Dashboard share not found');
}
// Check password access
const hasAccess = !!ctx.cookies[`shared-dashboard-${share.id}`];
if (share.password && !hasAccess) {
throw TRPCAccessError('Password required');
}
return getReportsByDashboardId(share.dashboardId);
}),
// Report sharing
report: publicProcedure
.input(
z
.object({
reportId: z.string(),
})
.or(
z.object({
shareId: z.string(),
}),
),
)
.query(async ({ input, ctx }) => {
const share = await db.shareReport.findUnique({
include: {
organization: {
select: {
name: true,
},
},
project: {
select: {
name: true,
},
},
report: {
select: {
name: true,
},
},
},
where:
'reportId' in input
? {
reportId: input.reportId,
}
: {
id: input.shareId,
},
});
if (!share) {
if ('shareId' in input) {
throw TRPCNotFoundError('Report share not found');
}
return null;
}
return {
...share,
hasAccess: !!ctx.cookies[`shared-report-${share?.id}`],
};
}),
createReport: protectedProcedure
.input(zShareReport)
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const passwordHash = input.password
? await hashPassword(input.password)
: null;
return db.shareReport.upsert({
where: {
reportId: input.reportId,
},
create: {
id: uid.rnd(),
organizationId: input.organizationId,
projectId: input.projectId,
reportId: input.reportId,
public: input.public,
password: passwordHash,
},
update: {
public: input.public,
password: passwordHash,
},
});
}),
}); });

View File

@@ -246,6 +246,22 @@ export const zShareOverview = z.object({
public: z.boolean(), public: z.boolean(),
}); });
export const zShareDashboard = z.object({
organizationId: z.string(),
projectId: z.string(),
dashboardId: z.string(),
password: z.string().nullable(),
public: z.boolean(),
});
export const zShareReport = z.object({
organizationId: z.string(),
projectId: z.string(),
reportId: z.string(),
password: z.string().nullable(),
public: z.boolean(),
});
export const zCreateReference = z.object({ export const zCreateReference = z.object({
title: z.string(), title: z.string(),
description: z.string().nullish(), description: z.string().nullish(),
@@ -485,6 +501,7 @@ export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
export const zSignInShare = z.object({ export const zSignInShare = z.object({
password: z.string().min(1), password: z.string().min(1),
shareId: z.string().min(1), shareId: z.string().min(1),
shareType: z.enum(['overview', 'dashboard', 'report']).optional().default('overview'),
}); });
export type ISignInShare = z.infer<typeof zSignInShare>; export type ISignInShare = z.infer<typeof zSignInShare>;