From e7c662f3052151b9df4f9db854efe7c22bc4fdf5 Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Sun, 17 Mar 2024 18:25:25 +0100 Subject: [PATCH] feature: add inspect folder support --- .../api/stats/[entry]/folders/index+api.ts | 70 +++++++++++ webui/src/app/folders/[path].tsx | 116 ++++++++++++++++++ webui/src/app/index.tsx | 15 +-- webui/src/components/graphs/TreemapGraph.tsx | 34 ++++- 4 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 webui/src/app/api/stats/[entry]/folders/index+api.ts create mode 100644 webui/src/app/folders/[path].tsx diff --git a/webui/src/app/api/stats/[entry]/folders/index+api.ts b/webui/src/app/api/stats/[entry]/folders/index+api.ts new file mode 100644 index 0000000..6f90b2c --- /dev/null +++ b/webui/src/app/api/stats/[entry]/folders/index+api.ts @@ -0,0 +1,70 @@ +import type { ModuleMetadata } from '~/app/api/stats/[entry]/modules/index+api'; +import { getSource } from '~/utils/atlas'; +import { StatsEntry } from '~core/data/types'; + +export type FolderGraphData = { + metadata: { + platform: 'android' | 'ios' | 'web'; + size: number; + modulesCount: number; + folder: string; + }; + data: { + size: number; + modulesCount: number; + modules: ModuleMetadata[]; + }; +}; + +export async function POST(request: Request, params: Record<'entry', string>) { + const folderRef: string | undefined = (await request.json()).path; + if (!folderRef) { + return Response.json( + { error: `Folder ID not provided, expected a "path" property.` }, + { status: 406 } + ); + } + + let entry: StatsEntry; + + try { + entry = await getSource().getEntry(params.entry); + } catch (error: any) { + return Response.json({ error: error.message }, { status: 406 }); + } + + const folder = collectFolderInfo(entry, folderRef); + return folder + ? Response.json(folder) + : Response.json({ error: `Folder "${folderRef}" not found.` }, { status: 404 }); +} + +function collectFolderInfo(entry: StatsEntry, folderRef: string): FolderGraphData | null { + const modules: ModuleMetadata[] = []; + + for (const [moduleRef, module] of entry.modules) { + if (moduleRef.startsWith(folderRef)) { + modules.push({ ...module, source: undefined, output: undefined }); + } + } + + if (!modules.length) { + return null; + } + + const size = modules.reduce((size, module) => size + module.size, 0); + + return { + metadata: { + platform: entry.platform as any, + size, + modulesCount: modules.length, + folder: folderRef, + }, + data: { + modules, + size, + modulesCount: modules.length, + }, + }; +} diff --git a/webui/src/app/folders/[path].tsx b/webui/src/app/folders/[path].tsx new file mode 100644 index 0000000..22d8591 --- /dev/null +++ b/webui/src/app/folders/[path].tsx @@ -0,0 +1,116 @@ +import { useQuery } from '@tanstack/react-query'; +import { useLocalSearchParams } from 'expo-router'; + +import { type FolderGraphData } from '../api/stats/[entry]/folders/index+api'; + +import { Page, PageHeader, PageTitle } from '~/components/Page'; +import { TreemapGraph } from '~/components/graphs/TreemapGraph'; +import { useStatsEntryContext } from '~/providers/stats'; +import { Skeleton } from '~/ui/Skeleton'; +import { Tag } from '~/ui/Tag'; +import { formatFileSize } from '~/utils/formatString'; +import { type PartialStatsEntry } from '~core/data/types'; + +export default function FolderPage() { + const { entryId, entry, entryFilePath } = useStatsEntryContext(); + const { path: absolutePath } = useLocalSearchParams<{ path: string }>(); + const folder = useFolderData(entryId, absolutePath!); + + if (folder.isLoading) { + return ; + } + + if (!folder.data || folder.isError) { + // TODO: improve + return ( +
+

Folder not found

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

+ {entryFilePath(folder.data.metadata.folder)}/ +

+ +
+
+ + +
+
+ ); +} + +function FolderSummary({ + folder, + platform, +}: { + folder: FolderGraphData['metadata']; + platform?: PartialStatsEntry['platform']; +}) { + return ( +
+ {!!platform && ( + <> + + - + + )} + folder + - + + {folder?.modulesCount === 1 + ? `${folder.modulesCount} module` + : `${folder.modulesCount} modules`} + + - + {formatFileSize(folder.size)} +
+ ); +} + +/** Load the folder data from API, by path reference only */ +function useFolderData(entryId: string, path: string) { + return useQuery({ + queryKey: [`module`, entryId, path], + queryFn: async ({ queryKey }) => { + const [_key, entry, path] = queryKey as [string, string, string]; + return fetch(`/api/stats/${entry}/folders`, { + method: 'POST', + body: JSON.stringify({ path }), + }) + .then((res) => (res.ok ? res : Promise.reject(res))) + .then((res) => res.json()); + }, + }); +} + +function FolderPageSkeleton() { + return ( +
+ + + + + + + +
+ +
+
+ ); +} diff --git a/webui/src/app/index.tsx b/webui/src/app/index.tsx index 72c3b35..0580b27 100644 --- a/webui/src/app/index.tsx +++ b/webui/src/app/index.tsx @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query'; -import { useRouter } from 'expo-router'; import type { EntryGraphData } from '~/app/api/stats/[entry]/modules/index+api'; import { Page, PageHeader, PageTitle } from '~/components/Page'; @@ -19,14 +18,6 @@ export default function GraphScreen() { const { filters } = useModuleFilterContext(); const graph = useBundleGraphData(entryId, filters); - const router = useRouter(); - - function onInspectModule(absolutePath: string) { - router.push({ - pathname: '/modules/[path]', - params: { path: absolutePath }, - }); - } return ( @@ -38,11 +29,7 @@ export default function GraphScreen() { - + ); diff --git a/webui/src/components/graphs/TreemapGraph.tsx b/webui/src/components/graphs/TreemapGraph.tsx index 324de5e..af06f83 100644 --- a/webui/src/components/graphs/TreemapGraph.tsx +++ b/webui/src/components/graphs/TreemapGraph.tsx @@ -1,4 +1,5 @@ import * as echarts from 'echarts'; +import { useRouter } from 'expo-router'; import { useMemo } from 'react'; import { Graph } from './Graph'; @@ -7,8 +8,8 @@ import type { ModuleMetadata } from '~/app/api/stats/[entry]/modules/index+api'; import { formatFileSize } from '~/utils/formatString'; type TreemapGraphProps = { + name?: string; modules: ModuleMetadata[]; - onModuleClick: (absolutePath: string) => void; }; const ICON_STRINGS = { @@ -18,6 +19,20 @@ const ICON_STRINGS = { }; export function TreemapGraph(props: TreemapGraphProps) { + const router = useRouter(); + + function onInspectPath(type: 'folder' | 'module', absolutePath: string) { + router.push({ + pathname: type === 'module' ? '/modules/[path]' : '/folders/[path]', + params: { path: absolutePath }, + }); + + console.log('REDIRECTING TO', { + pathname: type === 'module' ? '/modules/[path]' : '/folders/[path]', + params: { path: absolutePath }, + }); + } + const { data, maxDepth, maxNodeModules } = useMemo( () => createModuleTree(props.modules.filter((module) => module.path.startsWith('/'))), [props.modules] @@ -51,8 +66,11 @@ export function TreemapGraph(props: TreemapGraphProps) { onEvents={{ click({ event, data }: any) { const shouldFireClick = event.event.altKey || event.event.ctrlKey || event.event.metaKey; - if (data?.path && shouldFireClick) { - props.onModuleClick(data.path); + if (!shouldFireClick) return; + if (data?.moduleHref) { + onInspectPath('module', data.moduleHref); + } else if (data?.folderHerf) { + onInspectPath('folder', data.folderHerf); } }, }} @@ -120,10 +138,11 @@ export function TreemapGraph(props: TreemapGraphProps) { } } else { // Full bundle + const typeName = !props.name ? 'Bundle' : props.name; components.push( `
${ICON_STRINGS.pkg} - Bundle
+ ${typeName}
100% ` ); @@ -137,7 +156,7 @@ export function TreemapGraph(props: TreemapGraphProps) { series: [ { // roam: 'move', - name: 'Bundle', + name: !props.name ? 'Bundle' : props.name, type: 'treemap', height: '85%', width: '95%', @@ -245,6 +264,7 @@ type TreemapItem = { path: string; value: [number, number]; moduleHref?: string; + folderHerf?: string; tip: string; sizeString: string; ratio: number; @@ -270,6 +290,7 @@ function createModuleTree(paths: ModuleMetadata[]): { } const root: TreemapItem = { + folderHerf: '/', path: '/', children: [], name: '/', @@ -292,10 +313,13 @@ function createModuleTree(paths: ModuleMetadata[]): { parts.forEach((part, index) => { const isLast = index === parts.length - 1; + const pathFull = '/' + parts.slice(0, index + 1).join('/'); + let next = current.children.find((g) => g.path === part); if (!next) { next = { + folderHerf: pathFull, path: part, name: part, children: [],