feat: add new tianji dashboard which has more custom feature

This commit is contained in:
moonrailgun 2023-11-12 23:49:02 +08:00
parent 383a8af304
commit eed6f33d7b
17 changed files with 522 additions and 162 deletions

View File

@ -66,6 +66,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-easy-sort": "^1.5.3", "react-easy-sort": "^1.5.3",
"react-grid-layout": "1.4.2",
"react-resizable": "^3.0.5",
"react-router": "^6.15.0", "react-router": "^6.15.0",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0",
"request-ip": "^3.3.0", "request-ip": "^3.3.0",
@ -99,6 +101,8 @@
"@types/ping": "^0.4.2", "@types/ping": "^0.4.2",
"@types/react": "^18.2.21", "@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-grid-layout": "^1.3.5",
"@types/react-resizable": "^3.0.7",
"@types/request-ip": "^0.0.38", "@types/request-ip": "^0.0.38",
"@types/swagger-ui-express": "^4.1.5", "@types/swagger-ui-express": "^4.1.5",
"@types/tar": "^6.1.5", "@types/tar": "^6.1.5",

View File

@ -139,6 +139,12 @@ dependencies:
react-easy-sort: react-easy-sort:
specifier: ^1.5.3 specifier: ^1.5.3
version: 1.5.3(react-dom@18.2.0)(react@18.2.0) version: 1.5.3(react-dom@18.2.0)(react@18.2.0)
react-grid-layout:
specifier: 1.4.2
version: 1.4.2(react-dom@18.2.0)(react@18.2.0)
react-resizable:
specifier: ^3.0.5
version: 3.0.5(react-dom@18.2.0)(react@18.2.0)
react-router: react-router:
specifier: ^6.15.0 specifier: ^6.15.0
version: 6.15.0(react@18.2.0) version: 6.15.0(react@18.2.0)
@ -234,6 +240,12 @@ devDependencies:
'@types/react-dom': '@types/react-dom':
specifier: ^18.2.7 specifier: ^18.2.7
version: 18.2.7 version: 18.2.7
'@types/react-grid-layout':
specifier: ^1.3.5
version: 1.3.5
'@types/react-resizable':
specifier: ^3.0.7
version: 3.0.7
'@types/request-ip': '@types/request-ip':
specifier: ^0.0.38 specifier: ^0.0.38
version: 0.0.38 version: 0.0.38
@ -2199,6 +2211,18 @@ packages:
'@types/react': 18.2.21 '@types/react': 18.2.21
dev: true dev: true
/@types/react-grid-layout@1.3.5:
resolution: {integrity: sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==}
dependencies:
'@types/react': 18.2.21
dev: true
/@types/react-resizable@3.0.7:
resolution: {integrity: sha512-V4N7/xDUME+cxKya/A73MmFrHofTupVdE45boRxeA8HL4Q5pJh3AuG0FWCEy2GB84unIMSRISyEAS/GHWum9EQ==}
dependencies:
'@types/react': 18.2.21
dev: true
/@types/react@18.2.21: /@types/react@18.2.21:
resolution: {integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==} resolution: {integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==}
dependencies: dependencies:
@ -2794,6 +2818,11 @@ packages:
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
dev: false dev: false
/clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
dev: false
/clsx@2.0.0: /clsx@2.0.0:
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -3647,6 +3676,10 @@ packages:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: false dev: false
/fast-equals@4.0.3:
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
dev: false
/fast-fifo@1.3.2: /fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
dev: false dev: false
@ -6007,6 +6040,18 @@ packages:
scheduler: 0.23.0 scheduler: 0.23.0
dev: false dev: false
/react-draggable@4.4.6(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
dependencies:
clsx: 1.2.1
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-easy-sort@1.5.3(react-dom@18.2.0)(react@18.2.0): /react-easy-sort@1.5.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-d5DVqmlqGfpRcE2blMkZ/H8AvsY6KZmUG5nOeUrV5INWMbqDIXTRBkkBnJvNOQT6mupAjl4wokgQcur5zRMYxw==} resolution: {integrity: sha512-d5DVqmlqGfpRcE2blMkZ/H8AvsY6KZmUG5nOeUrV5INWMbqDIXTRBkkBnJvNOQT6mupAjl4wokgQcur5zRMYxw==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -6020,6 +6065,22 @@ packages:
tslib: 2.0.1 tslib: 2.0.1
dev: false dev: false
/react-grid-layout@1.4.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-LLOZogtw5XNHbdCquKQRG/Dspjyfelk+kE9DKRbLUl3UArFRQu/IiH+aPcjh+wSkSHUjf+Rv32ueEYigbGzRLQ==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
dependencies:
clsx: 2.0.0
fast-equals: 4.0.3
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-draggable: 4.4.6(react-dom@18.2.0)(react@18.2.0)
react-resizable: 3.0.5(react-dom@18.2.0)(react@18.2.0)
resize-observer-polyfill: 1.5.1
dev: false
/react-is@16.13.1: /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false dev: false
@ -6033,6 +6094,18 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/react-resizable@3.0.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==}
peerDependencies:
react: '>= 16.3'
dependencies:
prop-types: 15.8.1
react: 18.2.0
react-draggable: 4.4.6(react-dom@18.2.0)(react@18.2.0)
transitivePeerDependencies:
- react-dom
dev: false
/react-resize-detector@7.1.2(react-dom@18.2.0)(react@18.2.0): /react-resize-detector@7.1.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==} resolution: {integrity: sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==}
peerDependencies: peerDependencies:

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Workspace" ADD COLUMN "dashboardLayout" JSON;

View File

@ -26,6 +26,7 @@ model Workspace {
id String @id @unique @default(cuid()) @db.VarChar(30) id String @id @unique @default(cuid()) @db.VarChar(30)
name String @db.VarChar(100) name String @db.VarChar(100)
dashboardOrder String[] dashboardOrder String[]
dashboardLayout Json? @db.Json
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)

View File

@ -1,6 +1,6 @@
import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom'; import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom';
import { Layout } from './pages/Layout'; import { Layout } from './pages/Layout';
import { Dashboard } from './pages/Dashboard'; import { DashboardPage } from './pages/Dashboard';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { Servers } from './pages/Servers'; import { Servers } from './pages/Servers';
@ -26,7 +26,7 @@ export const AppRoutes: React.FC = React.memo(() => {
<Routes> <Routes>
{info ? ( {info ? (
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/monitor/*" element={<MonitorPage />} /> <Route path="/monitor/*" element={<MonitorPage />} />
<Route path="/website/*" element={<WebsitePage />} /> <Route path="/website/*" element={<WebsitePage />} />
<Route path="/servers" element={<Servers />} /> <Route path="/servers" element={<Servers />} />

View File

@ -0,0 +1,56 @@
import { Button, Dropdown, Space } from 'antd';
import React from 'react';
import { trpc } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user';
import { useDashboardStore } from '../../store/dashboard';
import { DownOutlined } from '@ant-design/icons';
export const DashboardItemAddButton: React.FC = React.memo(() => {
const workspaceId = useCurrentWorkspaceId();
const { data: websites = [], isLoading } = trpc.website.all.useQuery({
workspaceId,
});
const { addItem } = useDashboardStore();
return (
<div>
<Dropdown
trigger={['click']}
disabled={isLoading}
menu={{
items: [
{
key: 'website',
label: 'website',
children: websites.map((website) => ({
key: `website#${website.id}`,
label: website.name,
children: [
{
key: `website#${website.id}#overview`,
label: 'overview',
onClick: () => {
addItem(
'websiteOverview',
website.id,
`${website.name}'s Overview`
);
},
},
],
})),
},
],
}}
>
<Button type="primary" size="large" className="w-32">
<Space>
<span>Add</span>
<DownOutlined />
</Space>
</Button>
</Dropdown>
</div>
);
});
DashboardItemAddButton.displayName = 'DashboardItemAddButton';

View File

@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import { DashboardGrid } from './Grid';
import { DashboardItemAddButton } from './AddButton';
import { defaultBlankLayouts, useDashboardStore } from '../../store/dashboard';
import { useEvent } from '../../hooks/useEvent';
import { Layouts } from 'react-grid-layout';
import { Button, Empty, message } from 'antd';
import { DateFilter } from '../DateFilter';
import { trpc } from '../../api/trpc';
import { useCurrentWorkspace, useCurrentWorkspaceId } from '../../store/user';
export const Dashboard: React.FC = React.memo(() => {
const { isEditMode, switchEditMode, layouts, items } = useDashboardStore();
const mutation = trpc.workspace.saveDashboardLayout.useMutation();
const workspaceId = useCurrentWorkspaceId();
const workspace = useCurrentWorkspace();
useEffect(() => {
// Init on mount
const { items = [], layouts = defaultBlankLayouts } =
workspace.dashboardLayout ?? {};
useDashboardStore.setState({
items,
layouts,
});
}, []);
const handleChangeLayouts = useEvent((layouts: Layouts) => {
useDashboardStore.setState({
layouts,
});
});
const handleSaveDashboardLayout = useEvent(async () => {
await mutation.mutateAsync({
workspaceId,
dashboardLayout: {
layouts,
items,
},
});
switchEditMode();
message.success('Layout saved success');
});
return (
<div className="py-4">
<div className="flex gap-2 justify-end">
{isEditMode ? (
<>
<DashboardItemAddButton />
<Button
className="w-32"
size="large"
loading={mutation.isLoading}
disabled={mutation.isLoading}
onClick={handleSaveDashboardLayout}
>
Done
</Button>
</>
) : (
<>
<DateFilter />
<Button
className="w-32"
type="primary"
size="large"
onClick={switchEditMode}
>
Edit
</Button>
</>
)}
</div>
<DashboardGrid
layouts={layouts}
onChangeLayouts={handleChangeLayouts}
items={items}
isEditMode={isEditMode}
/>
{items.length === 0 && (
<Empty description="You have not dashboard item yet, please enter edit mode and add you item." />
)}
</div>
);
});
Dashboard.displayName = 'Dashboard';

View File

@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Layouts, Responsive, WidthProvider } from 'react-grid-layout';
import clsx from 'clsx';
import { DashboardGridItem } from './items';
import { DashboardItem } from '../../store/dashboard';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
const ResponsiveGridLayout = WidthProvider(Responsive);
interface DashboardGridProps {
isEditMode: boolean;
items: DashboardItem[];
layouts: Layouts;
onChangeLayouts: (layouts: Layouts) => void;
}
export const DashboardGrid: React.FC<DashboardGridProps> = React.memo(
(props) => {
const { layouts, onChangeLayouts, items, isEditMode } = props;
return (
<ResponsiveGridLayout
className={clsx('layout', isEditMode && 'select-none')}
layouts={layouts}
rowHeight={50}
isDraggable={isEditMode}
isResizable={isEditMode}
breakpoints={{ lg: 1200, md: 768, sm: 0 }}
cols={{ lg: 4, md: 3, sm: 2 }}
onLayoutChange={(currentLayout, allLayouts) => {
onChangeLayouts(allLayouts);
}}
>
{items.map((item) => (
<div key={item.key}>
<DashboardGridItem item={item} />
</div>
))}
</ResponsiveGridLayout>
);
}
);
DashboardGrid.displayName = 'DashboardGrid';

View File

@ -0,0 +1,49 @@
import React from 'react';
import { trpc } from '../../../api/trpc';
import { useCurrentWorkspaceId } from '../../../store/user';
import { Loading } from '../../Loading';
import { NotFoundTip } from '../../NotFoundTip';
import { WebsiteOverview } from '../../website/WebsiteOverview';
import { Button } from 'antd';
import { useNavigate } from 'react-router';
import { ArrowRightOutlined } from '@ant-design/icons';
export const WebsiteOverviewItem: React.FC<{
websiteId: string;
}> = React.memo((props) => {
const workspaceId = useCurrentWorkspaceId();
const navigate = useNavigate();
const { data: websiteInfo, isLoading } = trpc.website.info.useQuery({
workspaceId,
websiteId: props.websiteId,
});
if (isLoading) {
return <Loading />;
}
if (!websiteInfo) {
return <NotFoundTip />;
}
return (
<WebsiteOverview
website={websiteInfo}
actions={
<>
<Button
type="primary"
size="large"
onClick={() => {
navigate(`/website/${websiteInfo.id}`);
}}
>
View Details <ArrowRightOutlined />
</Button>
</>
}
/>
);
});
WebsiteOverviewItem.displayName = 'WebsiteOverviewItem';

View File

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { DashboardItem, useDashboardStore } from '../../../store/dashboard';
import { WebsiteOverviewItem } from './WebsiteOverviewItem';
import { NotFoundTip } from '../../NotFoundTip';
import { Button, Card } from 'antd';
import React from 'react';
import { DeleteOutlined } from '@ant-design/icons';
import { useEvent } from '../../../hooks/useEvent';
interface DashboardGridItemProps {
item: DashboardItem;
}
export const DashboardGridItem: React.FC<DashboardGridItemProps> = React.memo(
(props) => {
const { isEditMode, removeItem } = useDashboardStore();
const { key, id, title, type } = props.item;
const inner = useMemo(() => {
if (type === 'websiteOverview') {
return <WebsiteOverviewItem websiteId={id} />;
} else {
return <NotFoundTip />;
}
}, [id, type]);
const handleDelete = useEvent(
(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
console.log('e', e, key);
e.stopPropagation();
removeItem(key);
}
);
return (
<Card
className="h-full w-full overflow-auto"
title={title}
extra={
isEditMode && (
<Button
shape="circle"
icon={<DeleteOutlined />}
onClick={handleDelete}
/>
)
}
>
{inner}
</Card>
);
}
);
DashboardGridItem.displayName = 'DashboardGridItem';

View File

@ -233,7 +233,7 @@ export const StatsChart: React.FC<{
formatter: (text) => formatDateWithUnit(text, props.unit), formatter: (text) => formatDateWithUnit(text, props.unit),
}, },
}, },
} as ColumnConfig), } satisfies ColumnConfig),
[props.data, props.unit] [props.data, props.unit]
); );

View File

@ -1,153 +1,7 @@
import React, { Fragment, useMemo, useState } from 'react'; import React from 'react';
import { ArrowRightOutlined, EditOutlined } from '@ant-design/icons'; import { Dashboard } from '../components/dashboard/Dashboard';
import { Button, Divider, Empty } from 'antd';
import { WebsiteOverview } from '../components/website/WebsiteOverview';
import { useCurrentWorkspace } from '../store/user';
import { Loading } from '../components/Loading';
import { useWorspaceWebsites } from '../api/model/website';
import { NoWorkspaceTip } from '../components/NoWorkspaceTip';
import { useNavigate } from 'react-router';
import { useEvent } from '../hooks/useEvent';
import arrayMove from 'array-move';
import SortableList, { SortableItem } from 'react-easy-sort';
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '../api/trpc';
import { Link } from 'react-router-dom';
import { DateFilter } from '../components/DateFilter';
export const Dashboard: React.FC = React.memo(() => { export const DashboardPage: React.FC = React.memo(() => {
const workspace = useCurrentWorkspace(); return <Dashboard />;
const navigate = useNavigate();
const [isEditLayout, setIsEditLayout] = useState(false);
const { isLoading, websiteList, handleSortEnd } = useDashboardWebsiteList();
if (!workspace) {
return <NoWorkspaceTip />;
}
if (isLoading) {
return <Loading />;
}
return (
<div className="py-4">
<div className="h-20 flex items-center">
<div className="text-2xl flex-1">Dashboard</div>
<div className="flex gap-4">
{!isEditLayout && <DateFilter />}
{websiteList.length !== 0 && (
<Button
icon={<EditOutlined />}
size="large"
onClick={() => setIsEditLayout((state) => !state)}
>
{isEditLayout ? 'Done' : 'Edit'}
</Button>
)}
</div>
</div>
{isEditLayout ? (
<SortableList
className="space-y-2"
lockAxis="y"
onSortEnd={handleSortEnd}
>
{websiteList.map((website) => (
<SortableItem key={website.id}>
<div className="overflow-hidden h-14 w-full border border-black border-opacity-20 flex justify-center items-center rounded-lg bg-white cursor-move">
{website.name}
</div>
</SortableItem>
))}
</SortableList>
) : (
<div>
{websiteList.length === 0 && (
<Empty
description={
<div>
<div>There is no website has been created</div>
<Link to="/settings/websites">
<Button>Add webiste</Button>
</Link>
</div>
}
/>
)}
{websiteList.map((website, i) => (
<Fragment key={website.id}>
{i !== 0 && <Divider />}
<WebsiteOverview
website={website}
actions={
<>
<Button
type="primary"
size="large"
onClick={() => {
navigate(`/website/${website.id}`);
}}
>
View Details <ArrowRightOutlined />
</Button>
</>
}
/>
</Fragment>
))}
</div>
)}
</div>
);
}); });
Dashboard.displayName = 'Dashboard'; Dashboard.displayName = 'Dashboard';
function useDashboardWebsiteList() {
const workspace = useCurrentWorkspace();
const workspaceId = workspace.id;
const { isLoading, websites } = useWorspaceWebsites(workspaceId);
const [dashboardOrder, setDashboardOrder] = useState(
workspace.dashboardOrder
);
const updateDashboardOrderMutation =
trpc.workspace.updateDashboardOrder.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const websiteList = useMemo(
() =>
websites.sort((a, b) => {
const aIndex = dashboardOrder.findIndex((item) => item === a.id);
const bIndex = dashboardOrder.findIndex((item) => item === b.id);
// In both cases, if in the sorted list, they are sorted according to the sorted list.
// If not in the sorted list, put it first
return aIndex - bIndex;
}),
[websites, dashboardOrder]
);
const handleSortEnd = useEvent((oldIndex: number, newIndex: number) => {
const newOrder = arrayMove(
websiteList.map((w) => w.id),
oldIndex,
newIndex
);
setDashboardOrder(newOrder);
updateDashboardOrderMutation.mutate({
workspaceId,
dashboardOrder: newOrder,
});
});
return {
isLoading,
websiteList,
handleSortEnd,
};
}

View File

@ -0,0 +1,83 @@
import { create } from 'zustand';
import { Layouts, Layout } from 'react-grid-layout';
import { mapValues, without } from 'lodash';
import { v1 as uuid } from 'uuid';
export type DashboardItemType =
| 'websiteOverview'
| 'websiteEvent'
| 'monitorHealthBar'
| 'monitorStatus'
| 'monitorChart'
| 'monitorEvent'
| 'serverStatus';
export interface DashboardItem {
key: string; // match with layout, not equal
id: string;
title: string;
type: DashboardItemType;
}
interface DashboardState {
isEditMode: boolean;
switchEditMode: () => void;
layouts: Layouts;
items: DashboardItem[];
addItem: (type: DashboardItemType, id: string, title: string) => void;
removeItem: (key: string) => void;
}
export const defaultBlankLayouts = {
lg: [],
};
export const useDashboardStore = create<DashboardState>((set, get) => ({
isEditMode: false,
layouts: defaultBlankLayouts,
items: [],
switchEditMode: () => {
set({ isEditMode: !get().isEditMode });
},
addItem: (type: DashboardItemType, id: string, title: string) => {
const key = uuid();
set((state) => {
return {
layouts: mapValues(state.layouts, (layout) => [
...layout,
{ ...defaultItemLayout[type], i: key },
]),
items: [
...state.items,
{
key,
id,
title,
type,
},
],
};
});
},
removeItem: (key: string) => {
set((state) => {
return {
layouts: mapValues(state.layouts, (layout) =>
layout.filter((l) => l.i !== key)
),
items: state.items.filter((item) => item.key !== key),
};
});
},
}));
export const defaultItemLayout: Record<DashboardItemType, Omit<Layout, 'i'>> = {
websiteOverview: { x: Infinity, y: Infinity, w: 4, h: 12 },
websiteEvent: { x: 0, y: 0, w: 4, h: 2 },
monitorHealthBar: { x: 0, y: 0, w: 4, h: 2 },
monitorStatus: { x: 0, y: 0, w: 4, h: 2 },
monitorChart: { x: 0, y: 0, w: 4, h: 2 },
monitorEvent: { x: 0, y: 0, w: 4, h: 2 },
serverStatus: { x: 0, y: 0, w: 4, h: 2 },
};

View File

@ -10,10 +10,16 @@ export const jsonFieldSchema = z.union([
z.number(), z.number(),
]); ]);
export const workspaceDashboardLayoutSchema = z
.object({
layouts: z.record(z.string(), z.array(z.any())),
items: z.array(z.any()),
})
.nullable();
export const workspaceSchema = z.object({ export const workspaceSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
dashboardOrder: z.array(z.string()),
}); });
export const userInfoSchema = z.object({ export const userInfoSchema = z.object({
@ -23,7 +29,12 @@ export const userInfoSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
deletedAt: z.date().nullable(), deletedAt: z.date().nullable(),
currentWorkspace: workspaceSchema.nullable(), currentWorkspace: z.intersection(
workspaceSchema.nullable(),
z.object({
dashboardLayout: workspaceDashboardLayoutSchema,
})
),
workspaces: z.array( workspaces: z.array(
z.object({ z.object({
role: z.string(), role: z.string(),

View File

@ -3,6 +3,7 @@ import bcryptjs from 'bcryptjs';
import { ROLES, SYSTEM_ROLES } from '../utils/const'; import { ROLES, SYSTEM_ROLES } from '../utils/const';
import { jwtVerify } from '../middleware/auth'; import { jwtVerify } from '../middleware/auth';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { Prisma } from '@prisma/client';
async function hashPassword(password: string) { async function hashPassword(password: string) {
return await bcryptjs.hash(password, 10); return await bcryptjs.hash(password, 10);
@ -30,6 +31,7 @@ const createUserSelect = {
id: true, id: true,
name: true, name: true,
dashboardOrder: true, dashboardOrder: true,
dashboardLayout: true,
}, },
}, },
workspaces: { workspaces: {
@ -39,12 +41,11 @@ const createUserSelect = {
select: { select: {
id: true, id: true,
name: true, name: true,
dashboardOrder: true,
}, },
}, },
}, },
}, },
}; } satisfies Prisma.UserSelect;
/** /**
* Create User * Create User

View File

@ -40,6 +40,27 @@ export const websiteRouter = router({
return count; return count;
}), }),
all: workspaceProcedure
.meta({
openapi: {
method: 'GET',
path: `/workspace/{workspaceId}/website/all`,
tags: [OPENAPI_TAG.WEBSITE],
protect: true,
},
})
.output(z.array(websiteInfoSchema))
.query(async ({ input }) => {
const { workspaceId } = input;
const websites = await prisma.website.findMany({
where: {
workspaceId,
},
});
return websites;
}),
info: workspaceProcedure info: workspaceProcedure
.meta( .meta(
buildWebsiteOpenapi({ buildWebsiteOpenapi({

View File

@ -1,6 +1,7 @@
import { router, workspaceOwnerProcedure } from '../trpc'; import { router, workspaceOwnerProcedure } from '../trpc';
import { z } from 'zod'; import { z } from 'zod';
import { prisma } from '../../model/_client'; import { prisma } from '../../model/_client';
import { workspaceDashboardLayoutSchema } from '../../model/_schema';
export const workspaceRouter = router({ export const workspaceRouter = router({
updateDashboardOrder: workspaceOwnerProcedure updateDashboardOrder: workspaceOwnerProcedure
@ -21,4 +22,22 @@ export const workspaceRouter = router({
}, },
}); });
}), }),
saveDashboardLayout: workspaceOwnerProcedure
.input(
z.object({
dashboardLayout: workspaceDashboardLayoutSchema,
})
)
.mutation(async ({ input }) => {
const { workspaceId, dashboardLayout } = input;
await prisma.workspace.update({
where: {
id: workspaceId,
},
data: {
dashboardLayout,
},
});
}),
}); });