Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/__mocks__/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const MockRequestClient = {
listSecretKeys: vi.fn().mockResolvedValue({ keys: [] }),
writeSecret: vi.fn().mockResolvedValue({}),
invokeAiTool: vi.fn().mockResolvedValue({}),
clearCache: vi.fn().mockResolvedValue(null),
getCacheInfo: vi.fn().mockResolvedValue(null),
...overrides,
};
},
Expand Down
216 changes: 216 additions & 0 deletions frontend/src/components/editor/chrome/panels/cache-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { useAtomValue } from "jotai";
import { DatabaseZapIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import { useLocale } from "react-aria";
import { Spinner } from "@/components/icons/spinner";
import { Button } from "@/components/ui/button";
import { ConfirmationButton } from "@/components/ui/confirmation-button";
import { toast } from "@/components/ui/use-toast";
import { cacheInfoAtom } from "@/core/cache/requests";
import { useRequestClient } from "@/core/network/requests";
import { useAsyncData } from "@/hooks/useAsyncData";
import { cn } from "@/utils/cn";
import { formatBytes, formatTime } from "@/utils/formatting";
import { prettyNumber } from "@/utils/numbers";
import { PanelEmptyState } from "./empty-state";

const CachePanel = () => {
const { clearCache, getCacheInfo } = useRequestClient();
const cacheInfo = useAtomValue(cacheInfoAtom);
const [purging, setPurging] = useState(false);
const { locale } = useLocale();

const { isPending, isFetching, refetch } = useAsyncData(async () => {
await getCacheInfo();
// Artificially spin the icon if the request is really fast
await new Promise((resolve) => setTimeout(resolve, 500));
}, []);

const handlePurge = async () => {
try {
setPurging(true);
await clearCache();
toast({
title: "Cache purged",
description: "All cached data has been cleared",
});
// Request updated cache info after purge
refetch();
} catch (err) {
toast({
title: "Error",
description:
err instanceof Error ? err.message : "Failed to purge cache",
variant: "danger",
});
} finally {
setPurging(false);
}
};

// Show spinner only on initial load
if (isPending && !cacheInfo) {
return <Spinner size="medium" centered={true} />;
}

const refreshButton = (
<Button variant="outline" size="sm" onClick={refetch} disabled={isFetching}>
{isFetching ? (
<Spinner size="small" className="w-4 h-4 mr-2" />
) : (
<RefreshCwIcon className="w-4 h-4 mr-2" />
)}
Refresh
</Button>
);

if (!cacheInfo) {
return (
<PanelEmptyState
title="No cache data"
description="Cache information is not available."
icon={<DatabaseZapIcon />}
action={refreshButton}
/>
);
}

const totalHits = cacheInfo.hits;
const totalMisses = cacheInfo.misses;
const totalTime = cacheInfo.time;
const diskTotal = cacheInfo.disk_total;
const diskToFree = cacheInfo.disk_to_free;

const totalRequests = totalHits + totalMisses;
const hitRate = totalRequests > 0 ? (totalHits / totalRequests) * 100 : 0;

// Show empty state if no cache activity
if (totalRequests === 0) {
return (
<PanelEmptyState
title="No cache activity"
description="The cache has not been used yet. Cached functions will appear here once they are executed."
icon={<DatabaseZapIcon />}
action={refreshButton}
/>
);
}

return (
<div className="flex flex-col h-full overflow-auto">
<div className="flex flex-col gap-4 p-4 h-full">
{/* Header with Refresh Button */}
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={refetch}
disabled={isFetching}
>
<RefreshCwIcon
className={cn(
"h-4 w-4 text-muted-foreground hover:text-foreground",
isFetching && "animate-[spin_0.5s]",
)}
/>
</Button>
</div>

{/* Statistics Section */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">Statistics</h3>
<div className="grid grid-cols-2 gap-3">
<StatCard
label="Time saved"
value={formatTime(totalTime, locale)}
description="Total execution time saved"
/>
<StatCard
label="Hit rate"
value={
totalRequests > 0 ? `${prettyNumber(hitRate, locale)}%` : "—"
}
description={`${prettyNumber(totalHits, locale)} hits / ${prettyNumber(totalRequests, locale)} total`}
/>
<StatCard
label="Cache hits"
value={prettyNumber(totalHits, locale)}
description="Successful cache retrievals"
/>
<StatCard
label="Cache misses"
value={prettyNumber(totalMisses, locale)}
description="Cache not found"
/>
</div>
</div>

{/* Storage Section */}
{diskTotal > 0 && (
<div className="space-y-3 pt-2 border-t">
<h3 className="text-sm font-semibold text-foreground">Storage</h3>
<div className="grid grid-cols-1 gap-3">
<StatCard
label="Disk usage"
value={formatBytes(diskTotal, locale)}
description={
diskToFree > 0
? `${formatBytes(diskToFree, locale)} can be freed`
: "Cache storage on disk"
}
/>
</div>
</div>
)}

<div className="my-auto" />

{/* Actions Section */}
<div className="pt-2 border-t">
<ConfirmationButton
title="Purge cache?"
description="This will permanently delete all cached data. This action cannot be undone."
confirmText="Purge"
destructive={true}
onConfirm={handlePurge}
>
<Button
variant="outlineDestructive"
size="xs"
disabled={purging}
className="w-full"
>
{purging ? (
<Spinner size="small" className="w-3 h-3 mr-2" />
) : (
<Trash2Icon className="w-3 h-3 mr-2" />
)}
Purge Cache
</Button>
</ConfirmationButton>
</div>
</div>
</div>
);
};

const StatCard: React.FC<{
label: string;
value: string;
description?: string;
}> = ({ label, value, description }) => {
return (
<div className="flex flex-col gap-1 p-3 rounded-lg border bg-card">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-lg font-semibold">{value}</span>
{description && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</div>
);
};

export default CachePanel;
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export const PanelEmptyState = ({
<div className="mx-6 my-6 flex flex-col gap-2">
<div className="flex flex-row gap-2 items-center">
{icon &&
React.cloneElement(icon, { className: "text-accent-foreground" })}
React.cloneElement(icon, {
className: "text-accent-foreground flex-shrink-0",
})}
<span className="mt-1 text-accent-foreground">{title}</span>
</div>
<span className="text-muted-foreground text-sm">{description}</span>
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/editor/chrome/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BotMessageSquareIcon,
BoxIcon,
DatabaseIcon,
DatabaseZapIcon,
FileTextIcon,
FolderTreeIcon,
FunctionSquareIcon,
Expand Down Expand Up @@ -35,6 +36,7 @@ export type PanelType =
| "scratchpad"
| "chat"
| "agents"
| "cache"
| "secrets"
| "logs";

Expand Down Expand Up @@ -77,6 +79,13 @@ export const PANELS: PanelDescriptor[] = [
tooltip: "Manage packages",
position: "sidebar",
},
{
type: "cache",
Icon: DatabaseZapIcon,
tooltip: "Manage cache",
position: "sidebar",
hidden: !getFeatureFlag("cache_panel"),
},
{
type: "outline",
Icon: ScrollTextIcon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const LazySecretsPanel = React.lazy(() => import("../panels/secrets-panel"));
const LazySnippetsPanel = React.lazy(() => import("../panels/snippets-panel"));
const LazyTracingPanel = React.lazy(() => import("../panels/tracing-panel"));
const LazyVariablePanel = React.lazy(() => import("../panels/variable-panel"));
const LazyCachePanel = React.lazy(() => import("../panels/cache-panel"));

export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
const { isSidebarOpen, isTerminalOpen, selectedPanel } = useChromeState();
Expand Down Expand Up @@ -176,6 +177,7 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
{selectedPanel === "logs" && <LazyLogsPanel />}
{selectedPanel === "tracing" && <LazyTracingPanel />}
{selectedPanel === "secrets" && <LazySecretsPanel />}
{selectedPanel === "cache" && <LazyCachePanel />}
</TooltipProvider>
</Suspense>
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const buttonVariants = cva(
link: "underline-offset-4 hover:underline text-link",
linkDestructive:
"underline-offset-4 hover:underline text-destructive underline-destructive",
outlineDestructive:
"border border-destructive text-destructive hover:bg-destructive/10",
},
size: {
default: "h-10 py-2 px-4",
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/components/ui/confirmation-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/* Copyright 2024 Marimo. All rights reserved. */

import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogDestructiveAction,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "./alert-dialog";

interface ConfirmationButtonProps {
/**
* The button element to trigger the confirmation dialog
*/
children: React.ReactElement;
/**
* Title of the confirmation dialog
*/
title: string;
/**
* Description/message of the confirmation dialog
*/
description: string;
/**
* Callback when the user confirms the action
*/
onConfirm: () => void;
/**
* Text for the confirm button (default: "Continue")
*/
confirmText?: string;
/**
* Text for the cancel button (default: "Cancel")
*/
cancelText?: string;
/**
* Whether to use destructive styling for the confirm button
*/
destructive?: boolean;
}

export const ConfirmationButton: React.FC<ConfirmationButtonProps> = ({
children,
title,
description,
onConfirm,
confirmText = "Continue",
cancelText = "Cancel",
destructive = false,
}) => {
const [open, setOpen] = React.useState(false);

const handleConfirm = () => {
onConfirm();
setOpen(false);
};

const ActionComponent = destructive
? AlertDialogDestructiveAction
: AlertDialogAction;

return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<ActionComponent onClick={handleConfirm}>
{confirmText}
</ActionComponent>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
5 changes: 5 additions & 0 deletions frontend/src/core/cache/requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { atom } from "jotai";
import type { CacheInfoFetched } from "../kernel/messages";

export const cacheInfoAtom = atom<CacheInfoFetched | null>(null);
2 changes: 2 additions & 0 deletions frontend/src/core/config/feature-flag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ExperimentalFeatures {
rtc_v2: boolean;
performant_table_charts: boolean;
chat_modes: boolean;
cache_panel: boolean;
sql_linter: boolean;
external_agents: boolean;
sql_mode: boolean;
Expand All @@ -23,6 +24,7 @@ const defaultValues: ExperimentalFeatures = {
rtc_v2: false,
performant_table_charts: false,
chat_modes: false,
cache_panel: false,
sql_linter: true,
external_agents: import.meta.env.DEV,
sql_mode: false,
Expand Down
Loading
Loading