aboutsummaryrefslogtreecommitdiffstats
path: root/ui/src
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2024-07-23 13:18:54 +0100
committerGitHub <noreply@github.com>2024-07-23 13:18:54 +0100
commitf8c963c7d668fc57680f25413f20bc207d4bf64a (patch)
treec7f952ddc0220cded24f5447d03b3bff46fb1d45 /ui/src
parentfix(themes): Restore default theme, refactor (#2294) (diff)
downloadatuin-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.tsx39
-rw-r--r--ui/src/components/history/HistoryRow.tsx35
-rw-r--r--ui/src/components/home/QuickActions.tsx1
-rw-r--r--ui/src/components/runbooks/List.tsx15
-rw-r--r--ui/src/components/runbooks/editor/Editor.tsx74
-rw-r--r--ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx20
-rw-r--r--ui/src/components/ui/card.tsx79
-rw-r--r--ui/src/components/ui/chart.tsx363
-rw-r--r--ui/src/main.tsx2
-rw-r--r--ui/src/pages/Home.tsx253
-rw-r--r--ui/src/state/models.ts4
-rw-r--r--ui/src/state/store.ts8
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],
+ })),
},
});
})