feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,70 @@
import { cn } from '@/utils/cn';
import type { useChat } from '@ai-sdk/react';
import { useLocalStorage } from 'usehooks-ts';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
type Props = Pick<
ReturnType<typeof useChat>,
'handleSubmit' | 'handleInputChange' | 'input' | 'append'
> & {
projectId: string;
isLimited: boolean;
};
export function ChatForm({
handleSubmit: handleSubmitProp,
input,
handleInputChange,
append,
projectId,
isLimited,
}: Props) {
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
`chat-quick-actions:${projectId}`,
[],
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
handleSubmitProp(e);
setQuickActions([input, ...quickActions].slice(0, 5));
};
return (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm z-20">
<ScrollArea orientation="horizontal">
<div className="row gap-2 px-4">
{quickActions.map((q) => (
<Button
disabled={isLimited}
key={q}
type="button"
variant="outline"
size="sm"
onClick={() => {
append({
role: 'user',
content: q,
});
}}
>
{q}
</Button>
))}
</div>
</ScrollArea>
<form onSubmit={handleSubmit} className="p-4 pt-2">
<input
disabled={isLimited}
className={cn(
'w-full h-12 px-4 outline-none border border-border text-foreground rounded-md font-mono placeholder:text-foreground/50 bg-background/50',
isLimited && 'opacity-50 cursor-not-allowed',
)}
value={input}
placeholder="Ask me anything..."
onChange={handleInputChange}
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zChartInputAI } from '@openpanel/validation';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
import { Card } from '../card';
import { LogoSquare } from '../logo';
import { Skeleton } from '../skeleton';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
import { ChatReport } from './chat-report';
export const ChatMessage = memo(
({
message,
isLast,
isStreaming,
debug,
}: {
message: UIMessage;
isLast: boolean;
isStreaming: boolean;
debug: boolean;
}) => {
const showIsStreaming = isLast && isStreaming;
return (
<div className="max-w-xl w-full">
<div className="row">
<div className="w-8 shrink-0">
<div className="size-6 relative">
{message.role === 'assistant' ? (
<LogoSquare className="size-full rounded-full" />
) : (
<div className="size-full bg-black text-white rounded-full center-center">
<UserIcon className="size-4" />
</div>
)}
<div
className={cn(
'absolute inset-0 bg-background rounded-full center-center opacity-0',
showIsStreaming && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin text-foreground" />
</div>
</div>
</div>
<div className="col gap-4 flex-1">
{message.parts.map((p, index) => {
const key = index.toString() + p.type;
const isToolInvocation = p.type === 'tool-invocation';
if (p.type === 'step-start') {
return null;
}
if (!isToolInvocation && p.type !== 'text') {
return <Debug enabled={debug} json={p} />;
}
if (p.type === 'text') {
return (
<div className="prose dark:prose-invert prose-sm" key={key}>
<Markdown>{p.text}</Markdown>
</div>
);
}
if (isToolInvocation && p.toolInvocation.state === 'result') {
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zChartInputAI.safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>
<Debug json={result} enabled={debug} />
<ChatReport report={report.data} lazy={!isLast} />
</Fragment>
);
}
}
return (
<Debug
key={key}
json={p.toolInvocation.result}
enabled={debug}
/>
);
}
return null;
})}
{showIsStreaming && (
<div className="w-full col gap-2">
<Skeleton className="w-3/5 h-4" />
<Skeleton className="w-4/5 h-4" />
<Skeleton className="w-2/5 h-4" />
</div>
)}
</div>
</div>
{!isLast && (
<div className="w-full shrink-0 pl-8 mt-4">
<div className="h-px bg-border" />
</div>
)}
</div>
);
},
);
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
if (!enabled) {
return null;
}
return (
<Accordion type="single" collapsible>
<Card>
<AccordionItem value={'json'}>
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
<span className="flex-1">Show JSON result</span>
</AccordionTrigger>
<AccordionContent className="p-2">
<Syntax
wrapLines
language="json"
code={JSON.stringify(json, null, 2)}
/>
</AccordionContent>
</AccordionItem>
</Card>
</Accordion>
);
}

View File

@@ -0,0 +1,80 @@
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
import type { UIMessage } from 'ai';
import { Loader2Icon } from 'lucide-react';
import { useEffect } from 'react';
import { ProjectLink } from '../links';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { ScrollArea } from '../ui/scroll-area';
import { ChatMessage } from './chat-message';
export function ChatMessages({
messages,
debug,
status,
isLimited,
}: {
messages: UIMessage[];
debug: boolean;
status: 'submitted' | 'streaming' | 'ready' | 'error';
isLimited: boolean;
}) {
const { messagesRef, scrollRef, visibilityRef, scrollToBottom } =
useScrollAnchor();
useEffect(() => {
scrollToBottom();
}, []);
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === 'user') {
scrollToBottom();
}
}, [messages]);
return (
<ScrollArea className="h-full" ref={scrollRef}>
<div ref={messagesRef} className="p-8 col gap-2">
{messages.map((m, index) => {
return (
<ChatMessage
key={m.id}
message={m}
isStreaming={status === 'streaming'}
isLast={index === messages.length - 1}
debug={debug}
/>
);
})}
{status === 'submitted' && (
<div className="card p-4 center-center max-w-xl pl-8">
<Loader2Icon className="w-4 h-4 animate-spin" />
</div>
)}
{isLimited && (
<div className="max-w-xl pl-8 mt-8">
<Alert variant={'warning'}>
<AlertTitle>Upgrade your account</AlertTitle>
<AlertDescription>
<p>
To keep using this feature you need to upgrade your account.
</p>
<p>
<ProjectLink
href="/settings/organization?tab=billing"
className="font-medium underline"
>
Visit Billing
</ProjectLink>{' '}
to upgrade.
</p>
</AlertDescription>
</Alert>
</div>
)}
<div className="h-20 p-4 w-full" />
<div className="w-full h-px" ref={visibilityRef} />
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,85 @@
import { pushModal } from '@/modals';
import type {
IChartInputAi,
IChartRange,
IChartType,
IInterval,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IChartInputAi; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
const [range, setRange] = useState<IChartRange>(props.report.range);
const [interval, setInterval] = useState<IInterval>(props.report.interval);
const report = {
...props.report,
lineType: 'linear' as const,
chartType,
startDate: range === 'custom' ? startDate : null,
endDate: range === 'custom' ? endDate : null,
range,
interval,
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
>
Save report
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { ChatForm } from '@/components/chat/chat-form';
import { ChatMessages } from '@/components/chat/chat-messages';
import { useAppContext } from '@/hooks/use-app-context';
import { useChat } from '@ai-sdk/react';
import type { IServiceOrganization } from '@openpanel/db';
import type { UIMessage } from 'ai';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { toast } from 'sonner';
const getErrorMessage = (error: Error) => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || error.message;
} catch (e) {
return error.message;
}
};
export default function Chat({
initialMessages,
projectId,
organization,
}: {
initialMessages?: UIMessage[];
projectId: string;
organization: IServiceOrganization;
}) {
const context = useAppContext();
const { messages, input, handleInputChange, handleSubmit, status, append } =
useChat({
onError(error) {
const message = getErrorMessage(error);
toast.error(message);
},
api: `${context.apiUrl}/ai/chat?projectId=${projectId}`,
initialMessages: (initialMessages ?? []) as any,
fetch: (url, options) => {
return fetch(url, {
...options,
credentials: 'include',
mode: 'cors',
});
},
});
const [debug] = useQueryState('debug', parseAsBoolean.withDefault(false));
const isLimited = Boolean(
messages.length > 5 &&
(organization.isCanceled ||
organization.isTrial ||
organization.isWillBeCanceled ||
organization.isExceeded ||
organization.isExpired),
);
return (
<div className="h-screen w-full col relative">
<ChatMessages
messages={messages}
debug={debug}
status={status}
isLimited={isLimited}
/>
<ChatForm
handleSubmit={handleSubmit}
input={input}
handleInputChange={handleInputChange}
append={append}
projectId={projectId}
isLimited={isLimited}
/>
</div>
);
}