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": "^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",
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
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)
|
||||||
|
|
||||||
|
@ -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 />} />
|
||||||
|
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),
|
formatter: (text) => formatDateWithUnit(text, props.unit),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as ColumnConfig),
|
} satisfies ColumnConfig),
|
||||||
[props.data, props.unit]
|
[props.data, props.unit]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
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(),
|
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(),
|
||||||
|
@ -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
|
||||||
|
@ -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({
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user