feat: add new tianji dashboard which has more custom feature
This commit is contained in:
parent
383a8af304
commit
eed6f33d7b
@ -66,6 +66,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-easy-sort": "^1.5.3",
|
||||
"react-grid-layout": "1.4.2",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router": "^6.15.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"request-ip": "^3.3.0",
|
||||
@ -99,6 +101,8 @@
|
||||
"@types/ping": "^0.4.2",
|
||||
"@types/react": "^18.2.21",
|
||||
"@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/swagger-ui-express": "^4.1.5",
|
||||
"@types/tar": "^6.1.5",
|
||||
|
@ -139,6 +139,12 @@ dependencies:
|
||||
react-easy-sort:
|
||||
specifier: ^1.5.3
|
||||
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:
|
||||
specifier: ^6.15.0
|
||||
version: 6.15.0(react@18.2.0)
|
||||
@ -234,6 +240,12 @@ devDependencies:
|
||||
'@types/react-dom':
|
||||
specifier: ^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':
|
||||
specifier: ^0.0.38
|
||||
version: 0.0.38
|
||||
@ -2199,6 +2211,18 @@ packages:
|
||||
'@types/react': 18.2.21
|
||||
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:
|
||||
resolution: {integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==}
|
||||
dependencies:
|
||||
@ -2794,6 +2818,11 @@ packages:
|
||||
wrap-ansi: 7.0.0
|
||||
dev: false
|
||||
|
||||
/clsx@1.2.1:
|
||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/clsx@2.0.0:
|
||||
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
|
||||
engines: {node: '>=6'}
|
||||
@ -3647,6 +3676,10 @@ packages:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
dev: false
|
||||
|
||||
/fast-equals@4.0.3:
|
||||
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
|
||||
dev: false
|
||||
|
||||
/fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
dev: false
|
||||
@ -6007,6 +6040,18 @@ packages:
|
||||
scheduler: 0.23.0
|
||||
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):
|
||||
resolution: {integrity: sha512-d5DVqmlqGfpRcE2blMkZ/H8AvsY6KZmUG5nOeUrV5INWMbqDIXTRBkkBnJvNOQT6mupAjl4wokgQcur5zRMYxw==}
|
||||
engines: {node: '>=16'}
|
||||
@ -6020,6 +6065,22 @@ packages:
|
||||
tslib: 2.0.1
|
||||
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:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
dev: false
|
||||
@ -6033,6 +6094,18 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
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):
|
||||
resolution: {integrity: sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==}
|
||||
peerDependencies:
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Workspace" ADD COLUMN "dashboardLayout" JSON;
|
@ -26,6 +26,7 @@ model Workspace {
|
||||
id String @id @unique @default(cuid()) @db.VarChar(30)
|
||||
name String @db.VarChar(100)
|
||||
dashboardOrder String[]
|
||||
dashboardLayout Json? @db.Json
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './pages/Layout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { Login } from './pages/Login';
|
||||
import { SettingsPage } from './pages/Settings';
|
||||
import { Servers } from './pages/Servers';
|
||||
@ -26,7 +26,7 @@ export const AppRoutes: React.FC = React.memo(() => {
|
||||
<Routes>
|
||||
{info ? (
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/monitor/*" element={<MonitorPage />} />
|
||||
<Route path="/website/*" element={<WebsitePage />} />
|
||||
<Route path="/servers" element={<Servers />} />
|
||||
|
56
src/client/components/dashboard/AddButton.tsx
Normal file
56
src/client/components/dashboard/AddButton.tsx
Normal 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';
|
90
src/client/components/dashboard/Dashboard.tsx
Normal file
90
src/client/components/dashboard/Dashboard.tsx
Normal 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';
|
43
src/client/components/dashboard/Grid.tsx
Normal file
43
src/client/components/dashboard/Grid.tsx
Normal 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';
|
@ -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';
|
53
src/client/components/dashboard/items/index.tsx
Normal file
53
src/client/components/dashboard/items/index.tsx
Normal 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';
|
@ -233,7 +233,7 @@ export const StatsChart: React.FC<{
|
||||
formatter: (text) => formatDateWithUnit(text, props.unit),
|
||||
},
|
||||
},
|
||||
} as ColumnConfig),
|
||||
} satisfies ColumnConfig),
|
||||
[props.data, props.unit]
|
||||
);
|
||||
|
||||
|
@ -1,153 +1,7 @@
|
||||
import React, { Fragment, useMemo, useState } from 'react';
|
||||
import { ArrowRightOutlined, EditOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
import React from 'react';
|
||||
import { Dashboard } from '../components/dashboard/Dashboard';
|
||||
|
||||
export const Dashboard: React.FC = React.memo(() => {
|
||||
const workspace = useCurrentWorkspace();
|
||||
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>
|
||||
);
|
||||
export const DashboardPage: React.FC = React.memo(() => {
|
||||
return <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,
|
||||
};
|
||||
}
|
||||
|
83
src/client/store/dashboard.ts
Normal file
83
src/client/store/dashboard.ts
Normal 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 },
|
||||
};
|
@ -10,10 +10,16 @@ export const jsonFieldSchema = z.union([
|
||||
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({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
dashboardOrder: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const userInfoSchema = z.object({
|
||||
@ -23,7 +29,12 @@ export const userInfoSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
deletedAt: z.date().nullable(),
|
||||
currentWorkspace: workspaceSchema.nullable(),
|
||||
currentWorkspace: z.intersection(
|
||||
workspaceSchema.nullable(),
|
||||
z.object({
|
||||
dashboardLayout: workspaceDashboardLayoutSchema,
|
||||
})
|
||||
),
|
||||
workspaces: z.array(
|
||||
z.object({
|
||||
role: z.string(),
|
||||
|
@ -3,6 +3,7 @@ import bcryptjs from 'bcryptjs';
|
||||
import { ROLES, SYSTEM_ROLES } from '../utils/const';
|
||||
import { jwtVerify } from '../middleware/auth';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
async function hashPassword(password: string) {
|
||||
return await bcryptjs.hash(password, 10);
|
||||
@ -30,6 +31,7 @@ const createUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
dashboardOrder: true,
|
||||
dashboardLayout: true,
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
@ -39,12 +41,11 @@ const createUserSelect = {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
dashboardOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
/**
|
||||
* Create User
|
||||
|
@ -40,6 +40,27 @@ export const websiteRouter = router({
|
||||
|
||||
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
|
||||
.meta(
|
||||
buildWebsiteOpenapi({
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { router, workspaceOwnerProcedure } from '../trpc';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../model/_client';
|
||||
import { workspaceDashboardLayoutSchema } from '../../model/_schema';
|
||||
|
||||
export const workspaceRouter = router({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user