Skip to content

Commit

Permalink
πŸ’„ style: Add Cache, Metadata, FeatureFlag Viewer to DevPanel (#5764)
Browse files Browse the repository at this point in the history
* ✨ feat: Add Chache DevTool

* πŸ’„ style: Update Dev Panel style

* ✨ feat: Add seo debug

* ✨ feat: Add Feature Flag

* πŸ’„ style: Update DevTool

* πŸ’„ style: Update style

* πŸ’„ style: Update style
  • Loading branch information
canisminor1990 authored Feb 5, 2025
1 parent 80fd5a8 commit db4e9c7
Show file tree
Hide file tree
Showing 24 changed files with 1,072 additions and 299 deletions.
33 changes: 33 additions & 0 deletions src/features/DevPanel/CacheViewer/DataTable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import { RefreshCw } from 'lucide-react';
import { memo } from 'react';

import Header from '../../features/Header';
import Table from '../../features/Table';
import { useCachePanelContext } from '../cacheProvider';

const DataTable = memo(() => {
const { entries, isLoading, refreshData } = useCachePanelContext();
return (
<>
<Header
actions={[
{
icon: RefreshCw,
onClick: () => refreshData(),
title: 'Refresh',
},
]}
title="Cache Entries"
/>
<Table
columns={['url', 'headers.content-type', 'body', 'kind', 'tags', 'revalidate', 'timestamp']}
dataSource={entries}
loading={isLoading}
/>
</>
);
});

export default DataTable;
64 changes: 64 additions & 0 deletions src/features/DevPanel/CacheViewer/cacheProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client';

import { usePathname } from 'next/navigation';
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
useTransition,
} from 'react';

import { getCacheFiles } from './getCacheEntries';
import type { NextCacheFileData } from './schema';

interface CachePanelContextProps {
entries: NextCacheFileData[];
isLoading: boolean;
refreshData: () => void;
setEntries: (value: NextCacheFileData[]) => void;
}

const CachePanelContext = createContext<CachePanelContextProps>({
entries: [],
isLoading: false,
refreshData: () => {},
setEntries: () => {},
});

export const useCachePanelContext = () => useContext(CachePanelContext);

export const CachePanelContextProvider = (
props: PropsWithChildren<{
entries: NextCacheFileData[];
}>,
) => {
const [isLoading, startTransition] = useTransition();
const [entries, setEntries] = useState(props.entries);
const pathname = usePathname();

const refreshData = () => {
startTransition(async () => {
const files = await getCacheFiles();
setEntries(files ?? []);
});
};

useEffect(() => {
refreshData();
}, [pathname]);

return (
<CachePanelContext.Provider
value={{
entries,
isLoading,
refreshData,
setEntries,
}}
>
{props.children}
</CachePanelContext.Provider>
);
};
52 changes: 52 additions & 0 deletions src/features/DevPanel/CacheViewer/getCacheEntries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use server';

import { existsSync, promises } from 'node:fs';
import pMap from 'p-map';
import { ZodError } from 'zod';

import { type NextCacheFileData, nextCacheFileSchema } from './schema';

const cachePath = '.next/cache/fetch-cache';

export const getCacheFiles = async (): Promise<NextCacheFileData[]> => {
if (!existsSync(cachePath)) {
return [];
}
const files = await promises.readdir(cachePath);
let result: NextCacheFileData[] = (await pMap(files, async (file) => {
// ignore tags-manifest file
if (/manifest/.test(file)) return false;
try {
const fileContent = await promises.readFile(`${cachePath}/${file}`).catch((err) => {
throw new Error(`Error reading file ${file}`, {
cause: err,
});
});

const fileStats = await promises.stat(`${cachePath}/${file}`).catch((err) => {
throw new Error(`Error reading file ${file}`, {
cause: err,
});
});

const jsonData = JSON.parse(fileContent.toString());

return nextCacheFileSchema.parse({
...jsonData,
id: file,
timestamp: new Date(fileStats.birthtime),
});
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues;
console.error(`File ${file} do not match the schema`, issues);
}
console.error(`Error parsing ${file}`);
return false;
}
})) as NextCacheFileData[];

result = result.filter(Boolean) as NextCacheFileData[];

return result.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
};
25 changes: 25 additions & 0 deletions src/features/DevPanel/CacheViewer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Empty } from 'antd';
import { Center } from 'react-layout-kit';

import DataTable from './DataTable';
import { CachePanelContextProvider } from './cacheProvider';
import { getCacheFiles } from './getCacheEntries';

const CacheViewer = async () => {
const files = await getCacheFiles();

if (!files || files.length === 0)
return (
<Center height={'80%'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
);

return (
<CachePanelContextProvider entries={files}>
<DataTable />
</CachePanelContextProvider>
);
};

export default CacheViewer;
49 changes: 49 additions & 0 deletions src/features/DevPanel/CacheViewer/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { z } from 'zod';

const unstableCacheFileSchema = z.object({
data: z.object({
body: z.string(),
headers: z.object({}).transform(() => null),
status: z.number(),
url: z.literal(''),
}),
kind: z.union([z.literal('FETCH'), z.unknown()]),
revalidate: z.number().optional(),
tags: z.array(z.string()).optional().default([]),
});

const fetchCacheFileSchema = z.object({
data: z.object({
body: z.string(),
headers: z.record(z.string(), z.string()),
status: z.number(),
url: z.string().url(),
}),
id: z.string(),
kind: z.union([z.literal('FETCH'), z.unknown()]),
revalidate: z.number().optional(),
tags: z.array(z.string()).optional().default([]),
});

const atou = (str: string, type: string) => {
if (type.startsWith('image/')) return `data:${type};base64,${str}`;
return Buffer.from(str, 'base64').toString();
};
export const nextCacheFileSchema = z
.union([unstableCacheFileSchema, fetchCacheFileSchema])
.transform((item) => {
const { data, ...cacheEntry } = item;
const body =
data.url !== ''
? atou(data.body, data.headers ? data.headers['content-type'] : '')
: data.body;
return {
...cacheEntry,
...data,
body,
timestamp: data.headers?.date ? new Date(data.headers?.date) : new Date(),
url: data.url === '' ? 'unstable_cache' : data.url,
};
});

export type NextCacheFileData = z.infer<typeof nextCacheFileSchema>;
93 changes: 93 additions & 0 deletions src/features/DevPanel/FeatureFlagViewer/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client';

import { Form, Highlighter } from '@lobehub/ui';
import { Switch } from 'antd';
import { createStyles } from 'antd-style';
import { snakeCase } from 'lodash-es';
import { ListRestartIcon } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';

import { DEFAULT_FEATURE_FLAGS } from '@/config/featureFlags';

import Header from '../features/Header';

const useStyles = createStyles(({ css, token, prefixCls }) => ({
container: css`
* {
font-family: ${token.fontFamilyCode};
font-size: 12px;
}
.${prefixCls}-form-item {
padding-block: 4px !important;
}
`,
}));

const FeatureFlagForm = memo<{ flags: any }>(({ flags }) => {
const { styles } = useStyles();
const [data, setData] = useState(flags);
const [form] = Form.useForm();

const output = useMemo(
() =>
Object.entries(data).map(([key, value]) => {
const flag = snakeCase(key);
// @ts-ignore
if (DEFAULT_FEATURE_FLAGS[flag] === value) return false;
if (value === true) return `+${flag}`;
return `-${flag}`;
}),
[data],
);

return (
<>
<Header
actions={[
{
icon: ListRestartIcon,
onClick: () => {
form.resetFields();
setData(flags);
},
title: 'Reset',
},
]}
title={'Feature Flag Env'}
/>
<Flexbox
className={styles.container}
height={'100%'}
paddingInline={16}
style={{ overflow: 'auto', position: 'relative' }}
width={'100%'}
>
<Form
form={form}
initialValues={flags}
itemMinWidth={'max(75%,240px)'}
items={Object.keys(flags).map((key) => {
return {
children: <Switch size={'small'} />,
label: snakeCase(key),
minWidth: undefined,
name: key,
valuePropName: 'checked',
};
})}
itemsType={'flat'}
onValuesChange={(_, v) => setData(v)}
variant={'pure'}
/>
</Flexbox>
<Highlighter
language={'env'}
style={{ flex: 'none', fontSize: 12 }}
wrap
>{`FEATURE_FLAGS="${output.filter(Boolean).join(',')}"`}</Highlighter>
</>
);
});

export default FeatureFlagForm;
11 changes: 11 additions & 0 deletions src/features/DevPanel/FeatureFlagViewer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getServerFeatureFlagsValue } from '@/config/featureFlags';

import FeatureFlagForm from './Form';

const FeatureFlagViewer = () => {
const serverFeatureFlags = getServerFeatureFlagsValue();

return <FeatureFlagForm flags={serverFeatureFlags} />;
};

export default FeatureFlagViewer;
Loading

0 comments on commit db4e9c7

Please sign in to comment.