From 211a249476725766955705f27c1e24a7715cc248 Mon Sep 17 00:00:00 2001 From: Atsuki Hasegawa <58581243+atsuki44@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:10:38 +0900 Subject: [PATCH] feat: admin page --- core/morph/api/app.py | 17 ++- core/morph/frontend/template/package.json | 3 + core/morph/frontend/template/src/App.css | 48 ------- .../frontend/template/src/admin/AdminPage.tsx | 5 + .../src/admin/common/useResourcesQuery.ts | 37 ++++++ .../src/admin/common/useScheduledJobsQuery.ts | 32 +++++ .../src/admin/common/utils/useQuery.ts | 36 ++++++ .../src/admin/datapipeline/DataPipeline.tsx | 32 +++++ .../template/src/admin/datapipeline/Flow.tsx | 83 ++++++++++++ .../src/admin/datapipeline/ResourceDetail.tsx | 120 ++++++++++++++++++ .../src/admin/datapipeline/ResourceNode.tsx | 79 ++++++++++++ .../template/src/assets/icons/database.svg | 1 + .../template/src/assets/icons/python.svg | 1 + core/morph/frontend/template/src/main.tsx | 31 +++-- .../frontend/template/src/page-skeleton.tsx | 10 ++ 15 files changed, 468 insertions(+), 67 deletions(-) delete mode 100644 core/morph/frontend/template/src/App.css create mode 100644 core/morph/frontend/template/src/admin/AdminPage.tsx create mode 100644 core/morph/frontend/template/src/admin/common/useResourcesQuery.ts create mode 100644 core/morph/frontend/template/src/admin/common/useScheduledJobsQuery.ts create mode 100644 core/morph/frontend/template/src/admin/common/utils/useQuery.ts create mode 100644 core/morph/frontend/template/src/admin/datapipeline/DataPipeline.tsx create mode 100644 core/morph/frontend/template/src/admin/datapipeline/Flow.tsx create mode 100644 core/morph/frontend/template/src/admin/datapipeline/ResourceDetail.tsx create mode 100644 core/morph/frontend/template/src/admin/datapipeline/ResourceNode.tsx create mode 100644 core/morph/frontend/template/src/assets/icons/database.svg create mode 100644 core/morph/frontend/template/src/assets/icons/python.svg diff --git a/core/morph/api/app.py b/core/morph/api/app.py index b87a191..e683755 100644 --- a/core/morph/api/app.py +++ b/core/morph/api/app.py @@ -127,9 +127,7 @@ async def handle_other_error(_, exc): @app.get("/", response_model=None) async def index(inertia: InertiaDep) -> InertiaResponse: - return await inertia.render( - "index", - ) + return await inertia.render("index", {"showAdminPage": is_local_dev_mode}) @app.get( @@ -142,11 +140,18 @@ async def health_check(): app.include_router(router) +@app.get("/morph", response_model=None) +async def morph(inertia: InertiaDep) -> InertiaResponse: + + if is_local_dev_mode: + return await inertia.render("morph", {"showAdminPage": True}) + + return await inertia.render("404", {"showAdminPage": False}) + + @app.get("/{full_path:path}", response_model=None) async def subpages(full_path: str, inertia: InertiaDep) -> InertiaResponse: - return await inertia.render( - full_path, - ) + return await inertia.render(full_path, {"showAdminPage": is_local_dev_mode}) handler = Mangum(app) diff --git a/core/morph/frontend/template/package.json b/core/morph/frontend/template/package.json index ef98b3b..40eeee7 100644 --- a/core/morph/frontend/template/package.json +++ b/core/morph/frontend/template/package.json @@ -9,6 +9,9 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@dagrejs/dagre": "^1.1.4", + "@xyflow/react": "^12.4.1", + "tailwind-variants": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/core/morph/frontend/template/src/App.css b/core/morph/frontend/template/src/App.css deleted file mode 100644 index d25e139..0000000 --- a/core/morph/frontend/template/src/App.css +++ /dev/null @@ -1,48 +0,0 @@ -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -.crop { - width: 6em; - object-fit: cover; - object-position: left; -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/core/morph/frontend/template/src/admin/AdminPage.tsx b/core/morph/frontend/template/src/admin/AdminPage.tsx new file mode 100644 index 0000000..f42580a --- /dev/null +++ b/core/morph/frontend/template/src/admin/AdminPage.tsx @@ -0,0 +1,5 @@ +import { DataPipeline } from "./datapipeline/DataPipeline"; + +export const AdminPage = () => { + return ; +}; diff --git a/core/morph/frontend/template/src/admin/common/useResourcesQuery.ts b/core/morph/frontend/template/src/admin/common/useResourcesQuery.ts new file mode 100644 index 0000000..f1581c8 --- /dev/null +++ b/core/morph/frontend/template/src/admin/common/useResourcesQuery.ts @@ -0,0 +1,37 @@ +import { useQuery } from "./utils/useQuery"; + +type Resource = { + alias: string; + path: string; + connection?: string | null; + output_paths: string[]; + public?: boolean | null; + output_type?: string | null; + data_requirements?: string[] | null; +}; + +type GetResourcesResponse = { + resources: Resource[]; +}; + +const getResources = async () => { + const response = await fetch("/cli/resource"); + + if (!response.ok) { + throw await response.json(); + } + + const data = await response.json(); + + if (data.error) { + throw data.error; + } + + return data as GetResourcesResponse; +}; + +const useResourcesQuery = () => { + return useQuery(getResources); +}; + +export { type Resource, useResourcesQuery }; diff --git a/core/morph/frontend/template/src/admin/common/useScheduledJobsQuery.ts b/core/morph/frontend/template/src/admin/common/useScheduledJobsQuery.ts new file mode 100644 index 0000000..52eff98 --- /dev/null +++ b/core/morph/frontend/template/src/admin/common/useScheduledJobsQuery.ts @@ -0,0 +1,32 @@ +import { useQuery } from "./utils/useQuery"; + +type ScheduledJob = { + cron: string; + is_enabled?: boolean; + timezone?: string; + variables?: Record; +}; + +type GetScheduledJobsResponse = Record; + +const getScheduledJobs = async () => { + const response = await fetch("/cli/morph-project/scheduled-jobs"); + + if (!response.ok) { + throw await response.json(); + } + + const data = await response.json(); + + if (data.error) { + throw data.error; + } + + return data as GetScheduledJobsResponse; +}; + +const useScheduledJobsQuery = () => { + return useQuery(getScheduledJobs); +}; + +export { type ScheduledJob, useScheduledJobsQuery }; diff --git a/core/morph/frontend/template/src/admin/common/utils/useQuery.ts b/core/morph/frontend/template/src/admin/common/utils/useQuery.ts new file mode 100644 index 0000000..c2695cb --- /dev/null +++ b/core/morph/frontend/template/src/admin/common/utils/useQuery.ts @@ -0,0 +1,36 @@ +import React from "react"; + +type QueryResult = + | { + status: "loading"; + } + | { + status: "success"; + data: T; + } + | { + status: "error"; + error: unknown; + }; + +export const useQuery = (fetcher: () => T): QueryResult> => { + const [result, setResult] = React.useState>>({ + status: "loading", + }); + + React.useEffect(() => { + const init = async () => { + try { + const data = await fetcher(); + + setResult({ status: "success", data }); + } catch (error) { + setResult({ status: "error", error }); + } + }; + + init(); + }, [fetcher]); + + return result; +}; diff --git a/core/morph/frontend/template/src/admin/datapipeline/DataPipeline.tsx b/core/morph/frontend/template/src/admin/datapipeline/DataPipeline.tsx new file mode 100644 index 0000000..abc84a3 --- /dev/null +++ b/core/morph/frontend/template/src/admin/datapipeline/DataPipeline.tsx @@ -0,0 +1,32 @@ +import { ReactFlowProvider } from "@xyflow/react"; +import { useResourcesQuery } from "../common/useResourcesQuery"; + +import "@xyflow/react/dist/style.css"; +import { Flow } from "./Flow"; +import { ResourceDetail } from "./ResourceDetail"; + +export const DataPipeline = () => { + const resources = useResourcesQuery(); + + if (resources.status === "loading") { + return null; + } + + if (resources.status === "error") { + throw resources.error; + } + + return ( + + {/* TODO: Refactor height calculation, avoid hardcoded values */} +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/core/morph/frontend/template/src/admin/datapipeline/Flow.tsx b/core/morph/frontend/template/src/admin/datapipeline/Flow.tsx new file mode 100644 index 0000000..6601869 --- /dev/null +++ b/core/morph/frontend/template/src/admin/datapipeline/Flow.tsx @@ -0,0 +1,83 @@ +import { ReactFlow, Background, Node, Edge } from "@xyflow/react"; +import dagre from "@dagrejs/dagre"; +import { Resource } from "../common/useResourcesQuery"; + +import "@xyflow/react/dist/style.css"; +import { ResourceNode } from "./ResourceNode"; + +const nodeTypes = { + resource: ResourceNode, +}; + +export const Flow = ({ resources }: { resources: Resource[] }) => { + const nodes = convertResourcesToNodes(resources); + const edges = convertResourcesToEdges(resources); + + return ( + + + + ); +}; + +const convertResourcesToNodes = (resources: Resource[]): Node[] => { + const WIDTH = 200; + const HEIGHT = 40; + + const graph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + + graph.setGraph({ rankdir: "TB", ranksep: HEIGHT, nodesep: WIDTH / 2 }); + + resources.forEach((resource) => + graph.setNode(resource.alias, { + width: WIDTH, + height: HEIGHT, + }) + ); + + resources.forEach((resource) => { + resource.data_requirements?.forEach((parentAlias) => { + graph.setEdge(parentAlias, resource.alias); + }); + }); + + dagre.layout(graph); + + const nodes: ResourceNode[] = resources.map((resource) => { + return { + id: resource.alias, + type: "resource", + position: { + x: graph.node(resource.alias).x, + y: graph.node(resource.alias).y, + }, + width: WIDTH, + height: HEIGHT, + connectable: false, + draggable: false, + handles: [], + data: { resource }, + }; + }); + + return nodes; +}; + +const convertResourcesToEdges = (resources: Resource[]): Edge[] => { + return resources.reduce((edges, resource) => { + const addedEdges = + resource.data_requirements?.map((parentAlias) => ({ + id: `${parentAlias}-${resource.alias}`, + source: parentAlias, + target: resource.alias, + })) ?? []; + + return [...edges, ...addedEdges]; + }, [] as Edge[]); +}; diff --git a/core/morph/frontend/template/src/admin/datapipeline/ResourceDetail.tsx b/core/morph/frontend/template/src/admin/datapipeline/ResourceDetail.tsx new file mode 100644 index 0000000..1574b1e --- /dev/null +++ b/core/morph/frontend/template/src/admin/datapipeline/ResourceDetail.tsx @@ -0,0 +1,120 @@ +import { Node, useOnSelectionChange } from "@xyflow/react"; +import React from "react"; +import { useScheduledJobsQuery } from "../common/useScheduledJobsQuery"; +import { Resource } from "../common/useResourcesQuery"; + +export const ResourceDetail = ({ resources }: { resources: Resource[] }) => { + const [selectedAlias, setSelectedAlias] = React.useState(null); + + const onChange = React.useCallback(({ nodes }: { nodes: Node[] }) => { + if (nodes.length === 0) { + setSelectedAlias(null); + } else { + setSelectedAlias(nodes[0].id); + } + }, []); + + useOnSelectionChange({ + onChange, + }); + + if (!selectedAlias) { + return ( +
+

Select cell to view details

+
+ ); + } + + return ( +
+

{selectedAlias}

+ + +
+ ); +}; + +const BasicInfo = ({ + alias, + resources, +}: { + alias: string; + resources: Resource[]; +}) => { + const resource = resources.find((r) => r.alias === alias); + + if (!resource) { + return

Something went wrong

; + } + + return ( +
+

+ Defined at {resource.path} +

+
+ ); +}; + +const ScheduledJobs = ({ alias }: { alias: string }) => { + const scheduledJobs = useScheduledJobsQuery(); + + if (scheduledJobs.status === "loading") { + return null; + } + + if (scheduledJobs.status === "error") { + return

Something went wrong

; + } + + const scheduledJobsForAlias = scheduledJobs.data[alias]?.schedules ?? []; + + return ( +
+

Scheduled Jobs

+ {scheduledJobsForAlias.length === 0 && ( +

No scheduled jobs

+ )} + {scheduledJobsForAlias.map((job, i) => ( +
+

{`${job.cron} (${ + job.timezone ?? "UTC" + })`}

+ + {job.variables ? ( +
+ + Variables + + + + + + + + + + {Object.entries(job.variables).map(([key, value]) => ( + + + + + ))} + +
KeyValue
{key}{String(value)}
+
+ ) : ( + + No variables + + )} + + {i < scheduledJobsForAlias.length - 1 && ( +
+ )} +
+ ))} +
+ ); +}; diff --git a/core/morph/frontend/template/src/admin/datapipeline/ResourceNode.tsx b/core/morph/frontend/template/src/admin/datapipeline/ResourceNode.tsx new file mode 100644 index 0000000..048d6d3 --- /dev/null +++ b/core/morph/frontend/template/src/admin/datapipeline/ResourceNode.tsx @@ -0,0 +1,79 @@ +import { tv } from "tailwind-variants"; +import { Resource } from "../common/useResourcesQuery"; +import { + Handle, + Node, + NodeProps, + Position, + useOnSelectionChange, +} from "@xyflow/react"; +import React from "react"; +import databaseIcon from "../../assets/icons/database.svg"; +import pythonIcon from "../../assets/icons/python.svg"; + +export type ResourceNode = Node< + { + resource: Resource; + }, + "resource" +>; + +const useSelected = (id: string) => { + const [selected, setSelected] = React.useState(false); + + useOnSelectionChange({ + onChange: React.useCallback(({ nodes }) => { + setSelected(nodes.some((node) => node.id === id)); + }, []), + }); + + return selected; +}; + +const ResourceNode = ({ id, data }: NodeProps) => { + const { resource } = data; + + const selected = useSelected(id); + + return ( +
+ + {resource.alias} + + +
+ ); +}; + +const Icon = ({ resource }: { resource: Resource }) => { + if (resource.path.endsWith(".py")) { + return ; + } + + if (resource.path.endsWith(".sql")) { + return ; + } + + return null; +}; + +const card = tv({ + base: "bg-gray-200 p-2 rounded-md flex align-center justify-center gap-2", + variants: { + selected: { + true: "outline outline-gray-400", + }, + }, +}); + +export { ResourceNode }; diff --git a/core/morph/frontend/template/src/assets/icons/database.svg b/core/morph/frontend/template/src/assets/icons/database.svg new file mode 100644 index 0000000..0458bd5 --- /dev/null +++ b/core/morph/frontend/template/src/assets/icons/database.svg @@ -0,0 +1 @@ + diff --git a/core/morph/frontend/template/src/assets/icons/python.svg b/core/morph/frontend/template/src/assets/icons/python.svg new file mode 100644 index 0000000..e0e096a --- /dev/null +++ b/core/morph/frontend/template/src/assets/icons/python.svg @@ -0,0 +1 @@ + diff --git a/core/morph/frontend/template/src/main.tsx b/core/morph/frontend/template/src/main.tsx index 04b0a97..6f807d6 100644 --- a/core/morph/frontend/template/src/main.tsx +++ b/core/morph/frontend/template/src/main.tsx @@ -6,6 +6,8 @@ import { PageSkeleton } from "./page-skeleton.tsx"; import "@use-morph/components/css"; import { MDXComponents } from "mdx/types"; import { customMDXComponents } from "./custom-mdx-components.tsx"; +import { AdminPage } from "./admin/AdminPage.tsx"; +import { ErrorPage } from "./error-page.tsx"; type MDXProps = { children?: React.ReactNode; @@ -49,22 +51,25 @@ const routes = Object.entries(pages).map(([filePath, module]) => { }); document.addEventListener("DOMContentLoaded", () => { - createInertiaApp({ + createInertiaApp<{ showAdminPage: boolean }>({ resolve: (name) => { - if (name === "404") { - return import("./error-page.tsx").then((module) => module.ErrorPage); - } const pageModule = pages[`../../src/pages/${name}.mdx`]; - if (!pageModule) { - return import("./error-page.tsx").then((module) => module.ErrorPage); - } - - const Page = pageModule.default; - - const WrappedComponent: React.FC = () => ( - - + const WrappedComponent: React.FC<{ showAdminPage: boolean }> = ({ + showAdminPage, + }) => ( + + {name === "morph" ? ( + + ) : pageModule ? ( + + ) : ( + + )} ); diff --git a/core/morph/frontend/template/src/page-skeleton.tsx b/core/morph/frontend/template/src/page-skeleton.tsx index e1592b3..372d75e 100644 --- a/core/morph/frontend/template/src/page-skeleton.tsx +++ b/core/morph/frontend/template/src/page-skeleton.tsx @@ -5,6 +5,7 @@ import { DropdownMenuContent, DropdownMenuItem, Button, + DropdownMenuSeparator, } from "@use-morph/components"; import { ErrorBoundary, FallbackProps } from "react-error-boundary"; import { Link } from "@inertiajs/react"; @@ -22,6 +23,7 @@ function fallbackRender({ error }: FallbackProps) { type PageSkeletonProps = React.PropsWithChildren<{ routes: Array<{ path: string; title: string }>; title: string; + showAdminPage: boolean; }>; export const PageSkeleton: React.FC = (props) => { @@ -56,6 +58,14 @@ export const PageSkeleton: React.FC = (props) => { {route.title} ))} + {props.showAdminPage && ( + <> + + + Admin Page + + + )}
{props.title}