diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2024-07-23 13:18:54 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-07-23 13:18:54 +0100 |
| commit | f8c963c7d668fc57680f25413f20bc207d4bf64a (patch) | |
| tree | c7f952ddc0220cded24f5447d03b3bff46fb1d45 /ui/src | |
| parent | fix(themes): Restore default theme, refactor (#2294) (diff) | |
| download | atuin-f8c963c7d668fc57680f25413f20bc207d4bf64a.zip | |
feat(gui): clean up home page, fix a few bugs (#2304)
* wip home screen changes
* more
* adjust
* fixes and things
* patch runbook pty check
Diffstat (limited to 'ui/src')
| -rw-r--r-- | ui/src/App.tsx | 39 | ||||
| -rw-r--r-- | ui/src/components/history/HistoryRow.tsx | 35 | ||||
| -rw-r--r-- | ui/src/components/home/QuickActions.tsx | 1 | ||||
| -rw-r--r-- | ui/src/components/runbooks/List.tsx | 15 | ||||
| -rw-r--r-- | ui/src/components/runbooks/editor/Editor.tsx | 74 | ||||
| -rw-r--r-- | ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx | 20 | ||||
| -rw-r--r-- | ui/src/components/ui/card.tsx | 79 | ||||
| -rw-r--r-- | ui/src/components/ui/chart.tsx | 363 | ||||
| -rw-r--r-- | ui/src/main.tsx | 2 | ||||
| -rw-r--r-- | ui/src/pages/Home.tsx | 253 | ||||
| -rw-r--r-- | ui/src/state/models.ts | 4 | ||||
| -rw-r--r-- | ui/src/state/store.ts | 8 |
12 files changed, 730 insertions, 163 deletions
diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5963d31e..361a6fea 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,34 +1,13 @@ import "./App.css"; import { open } from "@tauri-apps/plugin-shell"; -import { useState, ReactElement, useEffect } from "react"; +import { useState, ReactElement } from "react"; import { useStore } from "@/state/store"; import { Toaster } from "@/components/ui/toaster"; -import { - SettingsIcon, - CircleHelpIcon, - KeyRoundIcon, - LogOutIcon, -} from "lucide-react"; +import { KeyRoundIcon } from "lucide-react"; import { Icon } from "@iconify/react"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; - -import { - HomeIcon, - ClockIcon, - WrenchScrewdriverIcon, -} from "@heroicons/react/24/outline"; - -import { ChevronRightSquare } from "lucide-react"; - -import Logo from "./assets/logo-light.svg"; - -function classNames(...classes: any) { - return classes.filter(Boolean).join(" "); -} - import Home from "./pages/Home.tsx"; import History from "./pages/History.tsx"; import Dotfiles from "./pages/Dotfiles.tsx"; @@ -41,7 +20,6 @@ import { Button, ScrollShadow, Spacer, - Tooltip, Dropdown, DropdownItem, DropdownMenu, @@ -49,19 +27,10 @@ import { DropdownTrigger, Modal, ModalContent, - ModalHeader, - ModalBody, - ModalFooter, useDisclosure, - Checkbox, - Input, - Link, } from "@nextui-org/react"; -import { cn } from "@/lib/utils"; -import { sectionItems } from "@/components/Sidebar/sidebar-items"; import Sidebar, { SidebarItem } from "@/components/Sidebar"; import icon from "@/assets/icon.svg"; -import iconText from "@/assets/logo-light.svg"; import { logout } from "./state/client.ts"; enum Section { @@ -89,8 +58,8 @@ function App() { // I think hashrouter may work, but I'd rather avoiding thinking of them as // pages const [section, setSection] = useState(Section.Home); - const user = useStore((state) => state.user); - const refreshUser = useStore((state) => state.refreshUser); + const user = useStore((state: any) => state.user); + const refreshUser = useStore((state: any) => state.refreshUser); const { isOpen, onOpen, onOpenChange } = useDisclosure(); const navigation: SidebarItem[] = [ diff --git a/ui/src/components/history/HistoryRow.tsx b/ui/src/components/history/HistoryRow.tsx index 98d271fb..4d893e61 100644 --- a/ui/src/components/history/HistoryRow.tsx +++ b/ui/src/components/history/HistoryRow.tsx @@ -11,6 +11,7 @@ import "prismjs/components/prism-bash"; import Drawer from "../Drawer"; import HistoryInspect from "./HistoryInspect"; +import { cn } from "@/lib/utils"; function msToTime(ms: number) { let milliseconds = parseInt(ms.toFixed(1)); @@ -26,25 +27,31 @@ function msToTime(ms: number) { else return days + " Days"; } -export default function HistoryRow({ h }: any) { +export default function HistoryRow({ h, compact }: any) { return ( <li key={h.id} - className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6" + className={cn( + "relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6", + { "py-5": !compact }, + { "py-1": compact }, + )} > <div className="flex min-w-0 gap-x-4"> - <div className="flex flex-col justify-center"> - <p className="flex text-xs text-gray-500 justify-center"> - {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( - DateTime.TIME_WITH_SECONDS, - )} - </p> - <p className="flex text-xs mt-1 text-gray-400 justify-center"> - {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( - DateTime.DATE_SHORT, - )} - </p> - </div> + {!compact && ( + <div className="flex flex-col justify-center"> + <p className="flex text-xs text-gray-500 justify-center"> + {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( + DateTime.TIME_WITH_SECONDS, + )} + </p> + <p className="flex text-xs mt-1 text-gray-400 justify-center"> + {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( + DateTime.DATE_SHORT, + )} + </p> + </div> + )} <div className="min-w-0 flex-col justify-center truncate"> <Highlight theme={themes.github} diff --git a/ui/src/components/home/QuickActions.tsx b/ui/src/components/home/QuickActions.tsx new file mode 100644 index 00000000..a22e4493 --- /dev/null +++ b/ui/src/components/home/QuickActions.tsx @@ -0,0 +1 @@ +export default function QuickActions() {} diff --git a/ui/src/components/runbooks/List.tsx b/ui/src/components/runbooks/List.tsx index 72c1b3b3..024bcfd1 100644 --- a/ui/src/components/runbooks/List.tsx +++ b/ui/src/components/runbooks/List.tsx @@ -1,12 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { - Input, Button, ButtonGroup, - Card, - CardBody, - CardHeader, - Divider, Tooltip, Listbox, ListboxItem, @@ -46,13 +41,13 @@ const NoteSidebar = () => { <div className="overflow-y-auto flex-grow"> <Listbox hideSelectedIcon - items={runbooks.map((runbook) => { + items={runbooks.map((runbook: any): any => { return [runbook, runbookInfo[runbook.id]]; })} variant="flat" aria-label="Runbook list" selectionMode="single" - selectedKeys={[currentRunbook]} + selectedKeys={currentRunbook ? [currentRunbook] : []} itemClasses={{ base: "data-[selected=true]:bg-gray-200" }} topContent={ <ButtonGroup className="z-20"> @@ -74,7 +69,7 @@ const NoteSidebar = () => { </ButtonGroup> } > - {([runbook, info]) => ( + {([runbook, info]: [Runbook, { ptys: number }]) => ( <ListboxItem key={runbook.id} onPress={() => { @@ -124,7 +119,7 @@ const NoteSidebar = () => { <div className="text-xs text-gray-500"> <em> {DateTime.fromJSDate(runbook.updated).toLocaleString( - DateTime.DATETIME_SIMPLE, + DateTime.DATETIME_SHORT, )} </em> </div> diff --git a/ui/src/components/runbooks/editor/Editor.tsx b/ui/src/components/runbooks/editor/Editor.tsx index 98a6a282..bbf594d8 100644 --- a/ui/src/components/runbooks/editor/Editor.tsx +++ b/ui/src/components/runbooks/editor/Editor.tsx @@ -1,37 +1,47 @@ import { useEffect, useMemo, useState } from "react"; -import "@blocknote/core/fonts/inter.css"; -import "@blocknote/mantine/style.css"; import "./index.css"; import { Spinner } from "@nextui-org/react"; +// Errors, but it all works fine and is there. Maybe missing ts defs? +// I'll figure it out later import { + // @ts-ignore BlockNoteSchema, + // @ts-ignore BlockNoteEditor, + // @ts-ignore defaultBlockSpecs, + // @ts-ignore filterSuggestionItems, + // @ts-ignore insertOrUpdateBlock, } from "@blocknote/core"; -import "@blocknote/core/fonts/inter.css"; - import { + //@ts-ignore SuggestionMenuController, + // @ts-ignore AddBlockButton, + // @ts-ignore getDefaultReactSlashMenuItems, - useCreateBlockNote, + // @ts-ignore SideMenu, + // @ts-ignore SideMenuController, } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/core/fonts/inter.css"; +import "@blocknote/mantine/style.css"; + import { Code } from "lucide-react"; import { useDebounceCallback } from "usehooks-ts"; import RunBlock from "@/components/runbooks/editor/blocks/RunBlock"; import { DeleteBlock } from "@/components/runbooks/editor/ui/DeleteBlockButton"; -import { useStore } from "@/state/store"; +import { AtuinState, useStore } from "@/state/store"; import Runbook from "@/state/runbooks/runbook"; // Our schema with block specs, which contain the configs and implementations for blocks @@ -60,8 +70,10 @@ const insertRun = (editor: typeof schema.BlockNoteEditor) => ({ }); export default function Editor() { - const runbookId = useStore((store) => store.currentRunbook); - const refreshRunbooks = useStore((store) => store.refreshRunbooks); + const runbookId = useStore((store: AtuinState) => store.currentRunbook); + const refreshRunbooks = useStore( + (store: AtuinState) => store.refreshRunbooks, + ); let [runbook, setRunbook] = useState<Runbook | null>(null); useEffect(() => { @@ -76,43 +88,43 @@ export default function Editor() { fetchRunbook(); }, [runbookId]); - const editor = useMemo(() => { - if (!runbook) { - return undefined; - } - - if (runbook.content) { - return BlockNoteEditor.create({ - initialContent: JSON.parse(runbook.content), - schema, - }); - } - - return BlockNoteEditor.create({ schema }); - }, [runbook]); - const onChange = async () => { if (!runbook) return; console.log("saved!"); runbook.name = fetchName(); - runbook.content = JSON.stringify(editor.document); + if (editor) runbook.content = JSON.stringify(editor.document); await runbook.save(); - await refreshRunbooks(); + refreshRunbooks(); }; const debouncedOnChange = useDebounceCallback(onChange, 1000); + const editor = useMemo(() => { + if (!runbook) return undefined; + if (runbook.content) { + return BlockNoteEditor.create({ + initialContent: JSON.parse(runbook.content), + schema, + }); + } + + return BlockNoteEditor.create({ schema }); + }, [runbook]); + const fetchName = (): string => { // Infer the title from the first text block + if (!editor) return "Untitled"; let blocks = editor.document; for (const block of blocks) { if (block.type == "heading" || block.type == "paragraph") { if (block.content.length == 0) continue; + // @ts-ignore if (block.content[0].text.length == 0) continue; + // @ts-ignore return block.content[0].text; } } @@ -120,6 +132,14 @@ export default function Editor() { return "Untitled"; }; + if (!runbook) { + return ( + <div className="flex w-full h-full flex-col justify-center items-center"> + <Spinner /> + </div> + ); + } + if (editor === undefined) { return ( <div className="flex w-full h-full flex-col justify-center items-center"> @@ -139,7 +159,7 @@ export default function Editor() { > <SuggestionMenuController triggerCharacter={"/"} - getItems={async (query) => + getItems={async (query: any) => filterSuggestionItems( [...getDefaultReactSlashMenuItems(editor), insertRun(editor)], query, @@ -148,7 +168,7 @@ export default function Editor() { /> <SideMenuController - sideMenu={(props) => ( + sideMenu={(props: any) => ( <SideMenu {...props}> <AddBlockButton {...props} /> <DeleteBlock {...props} /> diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx index 15653611..b3a96166 100644 --- a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx +++ b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +// @ts-ignore import { createReactBlockSpec } from "@blocknote/react"; import "./index.css"; @@ -48,7 +48,7 @@ const RunBlock = ({ ], ); - const isRunning = pty !== null; + const isRunning = pty !== null && pty !== ""; const handleToggle = async (event: any | null) => { if (event) event.stopPropagation(); @@ -63,21 +63,21 @@ const RunBlock = ({ cleanupPtyTerm(pty); if (onStop) onStop(pty); - decRunbookPty(currentRunbook); + if (currentRunbook) decRunbookPty(currentRunbook); } if (!isRunning) { let pty = await invoke<string>("pty_open"); if (onRun) onRun(pty); - incRunbookPty(currentRunbook); + if (currentRunbook) incRunbookPty(currentRunbook); let val = !value.endsWith("\n") ? value + "\r\n" : value; await invoke("pty_write", { pid: pty, data: val }); } }; - const handleCmdEnter = (view) => { + const handleCmdEnter = () => { handleToggle(null); return true; }; @@ -145,7 +145,7 @@ export default createReactBlockSpec( default: "bash", }, code: { default: "" }, - pty: { default: null }, + pty: { default: "" }, }, content: "none", }, @@ -154,19 +154,21 @@ export default createReactBlockSpec( render: ({ block, editor, code, type }) => { const onInputChange = (val: string) => { editor.updateBlock(block, { + // @ts-ignore props: { ...block.props, code: val }, }); }; const onRun = (pty: string) => { editor.updateBlock(block, { + // @ts-ignore props: { ...block.props, pty: pty }, }); }; - const onStop = (pty: string) => { - editor.updateBlock(block, { - props: { ...block.props, pty: null }, + const onStop = (_pty: string) => { + editor?.updateBlock(block, { + props: { ...block.props, pty: "" }, }); }; diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx new file mode 100644 index 00000000..afa13ecf --- /dev/null +++ b/ui/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "rounded-lg border bg-card text-card-foreground shadow-sm", + className + )} + {...props} + /> +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h3 + ref={ref} + className={cn( + "text-2xl font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <p + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/ui/src/components/ui/chart.tsx b/ui/src/components/ui/chart.tsx new file mode 100644 index 00000000..a21d77ee --- /dev/null +++ b/ui/src/components/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext<ChartContextProps | null>(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => ` +${prefix} [data-chart=${id}] { +${colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color + return color ? ` --color-${key}: ${color};` : null + }) + .join("\n")} +} +` + ) + .join("\n"), + }} + /> + ) +} + +const ChartTooltip = RechartsPrimitive.Tooltip + +const ChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & + React.ComponentProps<"div"> & { + hideLabel?: boolean + hideIndicator?: boolean + indicator?: "line" | "dot" | "dashed" + nameKey?: string + labelKey?: string + } +>( + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref + ) => { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null + } + + const [item] = payload + const key = `${labelKey || item.dataKey || item.name || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label + + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ) + } + + if (!value) { + return null + } + + return <div className={cn("font-medium", labelClassName)}>{value}</div> + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]) + + if (!active || !payload?.length) { + return null + } + + const nestLabel = payload.length === 1 && indicator !== "dot" + + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + > + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = color || item.payload.fill || item.color + + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + } + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ) + })} + </div> + </div> + ) + } +) +ChartTooltipContent.displayName = "ChartTooltip" + +const ChartLegend = RechartsPrimitive.Legend + +const ChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { + hideIcon?: boolean + nameKey?: string + } +>( + ( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref + ) => { + const { config } = useChart() + + if (!payload?.length) { + return null + } + + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className + )} + > + {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + )} + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ) + })} + </div> + ) + } +) +ChartLegendContent.displayName = "ChartLegend" + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string +) { + if (typeof payload !== "object" || payload === null) { + return undefined + } + + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined + + let configLabelKey: string = key + + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config] +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 96d570a3..5fddc82f 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { NextUIProvider, Spacer } from "@nextui-org/react"; +import { NextUIProvider } from "@nextui-org/react"; import App from "./App"; import "./styles.css"; diff --git a/ui/src/pages/Home.tsx b/ui/src/pages/Home.tsx index 0f0b5dcf..2e93a893 100644 --- a/ui/src/pages/Home.tsx +++ b/ui/src/pages/Home.tsx @@ -2,32 +2,94 @@ import React, { useEffect } from "react"; import { formatRelative } from "date-fns"; import { Tooltip as ReactTooltip } from "react-tooltip"; -import { useStore } from "@/state/store"; +import { AtuinState, useStore } from "@/state/store"; import { useToast } from "@/components/ui/use-toast"; import { ToastAction } from "@/components/ui/toast"; import { invoke } from "@tauri-apps/api/core"; +import { + Card, + CardHeader, + CardBody, + Listbox, + ListboxItem, +} from "@nextui-org/react"; + +import { + Bar, + BarChart, + CartesianGrid, + LabelList, + XAxis, + YAxis, +} from "recharts"; +import { ChartConfig, ChartContainer } from "@/components/ui/chart"; + +import { Clock, Terminal } from "lucide-react"; import ActivityCalendar from "react-activity-calendar"; +import HistoryRow from "@/components/history/HistoryRow"; +import { ShellHistory } from "@/state/models"; -function Stats({ stats }: any) { +function StatCard({ name, stat }: any) { return ( - <div> - <dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3"> - {stats.map((item: any) => ( - <div - key={item.name} - className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6" - > - <dt className="truncate text-sm font-medium text-gray-500"> - {item.name} - </dt> - <dd className="mt-1 text-xl font-semibold tracking-tight text-gray-900"> - {item.stat} - </dd> - </div> - ))} - </dl> - </div> + <Card shadow="sm"> + <CardHeader> + <h3 className="uppercase text-gray-500">{name}</h3> + </CardHeader> + <CardBody> + <h2 className="font-bold text-xl">{stat}</h2> + </CardBody> + </Card> + ); +} + +function TopChart({ chartData }: any) { + const chartConfig = { + command: { + label: "Command", + color: "#c4edde", + }, + } satisfies ChartConfig; + + return ( + <ChartContainer config={chartConfig} className="max-h-72"> + <BarChart + accessibilityLayer + data={chartData} + layout="vertical" + margin={{ + right: 16, + }} + > + <CartesianGrid horizontal={false} /> + <YAxis + dataKey="command" + type="category" + tickLine={false} + tickMargin={10} + axisLine={false} + tickFormatter={(value) => value.slice(0, 3)} + hide + /> + <XAxis dataKey="count" type="number" hide /> + <Bar dataKey="count" layout="vertical" fill="#c4edde" radius={4}> + <LabelList + dataKey="command" + position="insideLeft" + offset={8} + className="fill-[--color-label]" + fontSize={12} + /> + <LabelList + dataKey="count" + position="right" + offset={8} + className="fill-foreground" + fontSize={12} + /> + </Bar> + </BarChart> + </ChartContainer> ); } @@ -62,14 +124,22 @@ const explicitTheme = { }; export default function Home() { - const homeInfo = useStore((state) => state.homeInfo); - const user = useStore((state) => state.user); - const calendar = useStore((state) => state.calendar); - const weekStart = useStore((state) => state.weekStart); + const homeInfo = useStore((state: AtuinState) => state.homeInfo); + const user = useStore((state: AtuinState) => state.user); + const calendar = useStore((state: AtuinState) => state.calendar); + const runbooks = useStore((state: AtuinState) => state.runbooks); + const weekStart = useStore((state: AtuinState) => state.weekStart); - const refreshHomeInfo = useStore((state) => state.refreshHomeInfo); - const refreshUser = useStore((state) => state.refreshUser); - const refreshCalendar = useStore((state) => state.refreshCalendar); + const refreshHomeInfo = useStore( + (state: AtuinState) => state.refreshHomeInfo, + ); + const refreshUser = useStore((state: AtuinState) => state.refreshUser); + const refreshCalendar = useStore( + (state: AtuinState) => state.refreshCalendar, + ); + const refreshRunbooks = useStore( + (state: AtuinState) => state.refreshRunbooks, + ); const { toast } = useToast(); @@ -77,6 +147,9 @@ export default function Home() { refreshHomeInfo(); refreshUser(); refreshCalendar(); + refreshRunbooks(); + + console.log(homeInfo); let setup = async () => { let installed = await invoke("is_cli_installed"); @@ -125,49 +198,97 @@ export default function Home() { } return ( - <div className="w-full flex-1 flex-col p-4"> - <div className="p-10"> + <div className="w-full flex-1 flex-col p-4 overflow-y-auto"> + <div className="pl-10"> <Header name={user.username} /> + </div> + <div className="p-10 grid grid-cols-4 gap-4"> + <StatCard + name="Last Sync" + stat={ + (homeInfo.lastSyncTime && + formatRelative(homeInfo.lastSyncTime, new Date())) || + "Never" + } + /> + <StatCard + name="Total Commands" + stat={homeInfo.historyCount.toLocaleString()} + /> + <StatCard + name="Total Runbooks" + stat={runbooks.length.toLocaleString()} + /> + <StatCard + name="Other Records" + stat={homeInfo.recordCount - homeInfo.historyCount} + /> - <div className="pt-10"> - <Stats - stats={[ - { - name: "Last Sync", - stat: - (homeInfo.lastSyncTime && - formatRelative(homeInfo.lastSyncTime, new Date())) || - "Never", - }, - { - name: "Total history records", - stat: homeInfo.historyCount.toLocaleString(), - }, - { - name: "Other records", - stat: homeInfo.recordCount - homeInfo.historyCount, - }, - ]} - /> - </div> + <Card shadow="sm" className="col-span-3"> + <CardHeader> + <h2 className="uppercase text-gray-500">Activity graph</h2> + </CardHeader> + <CardBody> + <ActivityCalendar + hideTotalCount + theme={explicitTheme} + data={calendar} + weekStart={weekStart as any} + renderBlock={(block, activity) => + React.cloneElement(block, { + "data-tooltip-id": "react-tooltip", + "data-tooltip-html": `${activity.count} commands on ${activity.date}`, + }) + } + /> + <ReactTooltip id="react-tooltip" /> + </CardBody> + </Card> - <div className="pt-10 flex justify-around"> - <ActivityCalendar - theme={explicitTheme} - data={calendar} - weekStart={weekStart as any} - renderBlock={(block, activity) => - React.cloneElement(block, { - "data-tooltip-id": "react-tooltip", - "data-tooltip-html": `${activity.count} commands on ${activity.date}`, - }) - } - labels={{ - totalCount: "{{count}} history records in the last year", - }} - /> - <ReactTooltip id="react-tooltip" /> - </div> + <Card shadow="sm"> + <CardHeader> + <h2 className="uppercase text-gray-500">Quick actions </h2> + </CardHeader> + + <CardBody> + <Listbox variant="flat" aria-label="Quick actions"> + <ListboxItem + key="new-runbook" + description="Create an executable runbook" + startContent={<Terminal />} + > + New runbook + </ListboxItem> + <ListboxItem + key="shell-history" + description="Search and explore shell history" + startContent={<Clock />} + > + Shell History + </ListboxItem> + </Listbox> + </CardBody> + </Card> + + <Card shadow="sm" className="col-span-2"> + <CardHeader> + <h2 className="uppercase text-gray-500">Recent commands</h2> + </CardHeader> + <CardBody> + {homeInfo.recentCommands?.map((i: ShellHistory) => { + return <HistoryRow compact h={i} />; + })} + </CardBody> + </Card> + + <Card shadow="sm" className="col-span-2"> + <CardHeader> + <h2 className="uppercase text-gray-500">Top commands</h2> + </CardHeader> + <CardBody> + <TopChart chartData={homeInfo.topCommands} /> + </CardBody> + </Card> </div> </div> ); diff --git a/ui/src/state/models.ts b/ui/src/state/models.ts index c1d97f4b..4ca79006 100644 --- a/ui/src/state/models.ts +++ b/ui/src/state/models.ts @@ -18,12 +18,16 @@ export interface HomeInfo { historyCount: number; recordCount: number; lastSyncTime: Date | null; + recentCommands: ShellHistory[]; + topCommands: ShellHistory[]; } export const DefaultHomeInfo: HomeInfo = { historyCount: 0, recordCount: 0, lastSyncTime: new Date(), + recentCommands: [], + topCommands: [], }; export class ShellHistory { diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts index c6d3c152..39ee0096 100644 --- a/ui/src/state/store.ts +++ b/ui/src/state/store.ts @@ -58,7 +58,7 @@ export interface AtuinState { setCurrentRunbook: (id: String) => void; setPtyTerm: (pty: string, terminal: any) => void; - newPtyTerm: (pty: string, runbook: string) => TerminalData; + newPtyTerm: (pty: string) => TerminalData; cleanupPtyTerm: (pty: string) => void; terminals: { [pty: string]: TerminalData }; @@ -125,11 +125,17 @@ let state = (set: any, get: any): AtuinState => ({ refreshHomeInfo: () => { invoke("home_info") .then((res: any) => { + console.log(res); set({ homeInfo: { historyCount: res.history_count, recordCount: res.record_count, lastSyncTime: (res.last_sync && parseISO(res.last_sync)) || null, + recentCommands: res.recent_commands, + topCommands: res.top_commands.map((top: any) => ({ + command: top[0], + count: top[1], + })), }, }); }) |
