Initial implementation
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
target
|
||||||
1311
Cargo.lock
generated
Normal file
1311
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "stats_server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.6.9", features = ["macros", "ws"] }
|
||||||
|
serde_json = "1.0.93"
|
||||||
|
sysinfo = "0.30.11"
|
||||||
|
tokio = { version = "1.25.0", features = ["full"] }
|
||||||
|
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "stats_server"
|
||||||
|
path = "src/stats-server/main.rs"
|
||||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
// other rules...
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||||
|
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||||
30
biome.json
Normal file
30
biome.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.7.1/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": ["src/stats-server", "dist"],
|
||||||
|
"ignoreUnknown": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"lineWidth": 120
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"jsxQuoteStyle": "single",
|
||||||
|
"arrowParentheses": "asNeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SysMon Web</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="root"></main>
|
||||||
|
<script type="module" src="/src/client/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "sysmon-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host & cargo run",
|
||||||
|
"build": "tsc && vite build && cargo build --release",
|
||||||
|
"preview": "vite preview --host & cargo run --release"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.32.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"recharts": "^2.12.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "1.7.1",
|
||||||
|
"@types/node": "^20.12.8",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
|
"@typescript-eslint/parser": "^7.2.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
public/favicon.svg
Normal file
21
public/favicon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="c" x2="0" y1="543.8" y2="503.8" gradientTransform="translate(-389 -504)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#002b36"/>
|
||||||
|
<stop offset="1" stop-color="#073642"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="b" x1="0" x2="0" y1="44" y2="12" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2aa198"/>
|
||||||
|
<stop offset="1" stop-color="#268bd2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="a" x1="9" x2="13" y1="15" y2="21.9" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#fff"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient xlink:href="#a" id="e" x1="9" x2="13" y1="15" y2="21.9" gradientTransform="translate(-4 -4)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
<linearGradient xlink:href="#b" id="d" x1="-42" x2="-42" y1="44" y2="18" gradientTransform="translate(56 -4)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#c)" d="M5.9 0A5.9 5.9 0 0 0 0 5.9v28.2C0 37.4 2.6 40 5.9 40h28.2c3.3 0 5.9-2.6 5.9-5.9V5.9C40 2.6 37.4 0 34.1 0H5.9z"/>
|
||||||
|
<path fill="url(#d)" d="M9 12c-5 0-5 5-9 10v12.1A5.86 5.86 0 0 0 2.58 39h34.84A5.86 5.86 0 0 0 40 34.1V31s-6-9-9-10c-4-1-6 4-11 3-4-1-6-12-11-12z"/>
|
||||||
|
<path fill="url(#e)" fill-rule="evenodd" d="M9 12H8c-5 1-5 8-10 11v1c5-3 5-10 10-11 7-1 6 9 11 12 6 3 10-7 18 3 2 2 3 4 5 4v-1c-2 0-3-2-5-4-8-10-12 0-18-3-5-3-4-12-10-12"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
26
src/client/App.css
Normal file
26
src/client/App.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#root {
|
||||||
|
background-color: var(--color-neutral1);
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: calc(50svh - 16px);
|
||||||
|
padding: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: var(--color-neutral0);
|
||||||
|
padding: 16px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, max-content) 1fr;
|
||||||
|
}
|
||||||
45
src/client/App.tsx
Normal file
45
src/client/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
||||||
|
import './App.css';
|
||||||
|
import { fetchDynamicData, fetchStaticData } from './api';
|
||||||
|
import { Cpu } from './components/chart-cards/cpu';
|
||||||
|
import { Disks } from './components/chart-cards/disks';
|
||||||
|
import { Memory } from './components/chart-cards/memory';
|
||||||
|
import { Network } from './components/chart-cards/network';
|
||||||
|
import { Static } from './components/chart-cards/static';
|
||||||
|
import { Temps } from './components/chart-cards/temps';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const Main = () => {
|
||||||
|
const staticQuery = useQuery({
|
||||||
|
queryKey: ['static'],
|
||||||
|
queryFn: fetchStaticData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dynamicQuery = useQuery({
|
||||||
|
queryKey: ['dynamic'],
|
||||||
|
queryFn: fetchDynamicData,
|
||||||
|
refetchInterval: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = staticQuery.isLoading || dynamicQuery.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
!isLoading && (
|
||||||
|
<>
|
||||||
|
<Static />
|
||||||
|
<Cpu />
|
||||||
|
<Memory />
|
||||||
|
<Network />
|
||||||
|
<Disks />
|
||||||
|
<Temps />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const App = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Main />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
17
src/client/api.ts
Normal file
17
src/client/api.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const getApiUrl = (path: string) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.port = '3001';
|
||||||
|
url.pathname = path;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchStaticData = async (): Promise<StaticData> => {
|
||||||
|
const response = await fetch(getApiUrl('/api/static'));
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDynamicData = async (): Promise<DynamicData> => {
|
||||||
|
const response = await fetch(getApiUrl('/api/dynamic'));
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
44
src/client/components/chart-cards/cpu.tsx
Normal file
44
src/client/components/chart-cards/cpu.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChartCard } from './index';
|
||||||
|
|
||||||
|
export const Cpu = () => {
|
||||||
|
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
|
||||||
|
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||||
|
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dynamicData) {
|
||||||
|
setHistory(history => {
|
||||||
|
const newHistory = history.slice(1);
|
||||||
|
newHistory.push(dynamicData.cpu_usage);
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dynamicData]);
|
||||||
|
|
||||||
|
const total_cpus = useMemo(() => history.at(-1)?.length ?? 0, [history]);
|
||||||
|
|
||||||
|
if (!staticData || !dynamicData) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
!!total_cpus && (
|
||||||
|
<ChartCard
|
||||||
|
title='CPU'
|
||||||
|
subtitle={
|
||||||
|
<h3>
|
||||||
|
{staticData.cpu.brand}
|
||||||
|
<small>{` (${total_cpus} threads)`}</small>
|
||||||
|
</h3>
|
||||||
|
}
|
||||||
|
domain={[0, 100]}
|
||||||
|
formatOptions={{ prefix: false, units: '%' }}
|
||||||
|
data={history}
|
||||||
|
total={total_cpus}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/client/components/chart-cards/disks.tsx
Normal file
37
src/client/components/chart-cards/disks.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ChartCard } from './index';
|
||||||
|
|
||||||
|
export const Disks = () => {
|
||||||
|
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||||
|
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dynamicData) {
|
||||||
|
setHistory(history => {
|
||||||
|
const newHistory = history.slice(1);
|
||||||
|
newHistory.push([dynamicData.disks.read, dynamicData.disks.write]);
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dynamicData]);
|
||||||
|
|
||||||
|
if (!dynamicData) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title='Disk activity'
|
||||||
|
legend={{
|
||||||
|
values: [dynamicData.disks.read, dynamicData.disks.write],
|
||||||
|
labels: ['Read', 'Write'],
|
||||||
|
}}
|
||||||
|
hueOffset={120}
|
||||||
|
formatOptions={{ units: 'B/s' }}
|
||||||
|
data={history}
|
||||||
|
total={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/client/components/chart-cards/index.tsx
Normal file
66
src/client/components/chart-cards/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Legend, type LegendProps } from '@/components/legend';
|
||||||
|
import { getFillColor, getStrokeColor } from '@/utils/colors';
|
||||||
|
import { type FormatOptions, formatValue } from '@/utils/format';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
|
||||||
|
import type { AxisDomain } from 'recharts/types/util/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
legend?: LegendProps;
|
||||||
|
formatOptions?: FormatOptions;
|
||||||
|
domain?: AxisDomain;
|
||||||
|
hueOffset?: number;
|
||||||
|
data: number[][];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChartCard = ({ data, domain, legend, hueOffset = 0, title, subtitle, formatOptions, total }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className='chart-card'>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
|
||||||
|
{subtitle}
|
||||||
|
|
||||||
|
{legend && <Legend hueOffset={hueOffset} formatOptions={formatOptions} {...legend} />}
|
||||||
|
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<AreaChart
|
||||||
|
width={500}
|
||||||
|
height={300}
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
bottom: -16,
|
||||||
|
left: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} stroke='var(--color-neutral1)' />
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
domain={domain}
|
||||||
|
stroke='var(--color-neutral1)'
|
||||||
|
tick={{ fill: 'var(--color-neutral2)' }}
|
||||||
|
tickFormatter={value => formatValue(value, formatOptions)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{Array.from({ length: total }).map((_, index) => (
|
||||||
|
// biome-ignore lint/correctness/useJsxKeyInIterable: order irrelevant
|
||||||
|
<Area
|
||||||
|
isAnimationActive={false}
|
||||||
|
type='monotone'
|
||||||
|
dataKey={index}
|
||||||
|
fill={getFillColor(((index * 360) / total + hueOffset) % 360)}
|
||||||
|
stroke={getStrokeColor(((index * 360) / total + hueOffset) % 360)}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<XAxis tick={false} stroke='var(--color-neutral0)' strokeWidth={2} transform='translate(0 1)' />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/client/components/chart-cards/memory.tsx
Normal file
37
src/client/components/chart-cards/memory.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ChartCard } from './index';
|
||||||
|
|
||||||
|
export const Memory = () => {
|
||||||
|
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
|
||||||
|
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||||
|
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dynamicData) {
|
||||||
|
setHistory(history => {
|
||||||
|
const newHistory = history.slice(1);
|
||||||
|
newHistory.push([dynamicData.mem_usage, dynamicData.swap_usage]);
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dynamicData]);
|
||||||
|
|
||||||
|
if (!staticData || !dynamicData) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title='Memory'
|
||||||
|
legend={{
|
||||||
|
values: [staticData.total_memory, staticData.total_swap],
|
||||||
|
labels: ['Total memory', 'Total swap'],
|
||||||
|
}}
|
||||||
|
formatOptions={{ units: 'B' }}
|
||||||
|
data={history}
|
||||||
|
total={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/client/components/chart-cards/network.tsx
Normal file
37
src/client/components/chart-cards/network.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ChartCard } from './index';
|
||||||
|
|
||||||
|
export const Network = () => {
|
||||||
|
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||||
|
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dynamicData) {
|
||||||
|
setHistory(history => {
|
||||||
|
const newHistory = history.slice(1);
|
||||||
|
newHistory.push([dynamicData.network.down, dynamicData.network.up]);
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dynamicData]);
|
||||||
|
|
||||||
|
if (!dynamicData) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title='Network'
|
||||||
|
legend={{
|
||||||
|
values: [dynamicData.network.down, dynamicData.network.up],
|
||||||
|
labels: ['Down', 'Up'],
|
||||||
|
}}
|
||||||
|
hueOffset={60}
|
||||||
|
formatOptions={{ units: 'B/s' }}
|
||||||
|
data={history}
|
||||||
|
total={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
src/client/components/chart-cards/static.tsx
Normal file
63
src/client/components/chart-cards/static.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useAnimationFrame } from '@/hooks/use-animation-frame';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const formatUptime = (value: number) => {
|
||||||
|
const seconds = String(Math.floor(value % 60)).padStart(2, '0');
|
||||||
|
const minutes = String(Math.floor((value / 60) % 60)).padStart(2, '0');
|
||||||
|
const hours = String(Math.floor((value / (60 * 60)) % 24)).padStart(2, '0');
|
||||||
|
const days = Math.floor((value / (60 * 60 * 24)) % 365);
|
||||||
|
const years = Math.floor(value / (60 * 60 * 24 * 365));
|
||||||
|
|
||||||
|
let formatted = '';
|
||||||
|
|
||||||
|
if (years >= 1) {
|
||||||
|
formatted += `${years}y `;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days >= 1 || years >= 1) {
|
||||||
|
formatted += `${days}d `;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatted}${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Uptime = ({ boot_time }: Pick<StaticData, 'boot_time'>) => {
|
||||||
|
const [uptime, setUptime] = useState(formatUptime(Date.now() / 1000 - boot_time));
|
||||||
|
const lastUpdate = useRef(0);
|
||||||
|
|
||||||
|
useAnimationFrame(dt => {
|
||||||
|
lastUpdate.current += dt;
|
||||||
|
if (lastUpdate.current > 1000) {
|
||||||
|
setUptime(formatUptime(Date.now() / 1000 - boot_time));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<small>Uptime</small>
|
||||||
|
<h3>{uptime}</h3>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Static = () => {
|
||||||
|
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
staticData && (
|
||||||
|
<div>
|
||||||
|
<h2>{staticData.host_name}</h2>
|
||||||
|
|
||||||
|
<small>OS</small>
|
||||||
|
<h3>
|
||||||
|
{staticData.name} {staticData.os_version}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<small>Kernel</small>
|
||||||
|
<h3>{staticData.kernel_version}</h3>
|
||||||
|
{staticData.boot_time && <Uptime boot_time={staticData.boot_time} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/client/components/chart-cards/temps.tsx
Normal file
39
src/client/components/chart-cards/temps.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChartCard } from './index';
|
||||||
|
|
||||||
|
export const Temps = () => {
|
||||||
|
const { data: staticData } = useQuery<StaticData>({ queryKey: ['static'] });
|
||||||
|
const { data: dynamicData } = useQuery<DynamicData>({ queryKey: ['dynamic'] });
|
||||||
|
const [history, setHistory] = useState<number[][]>(new Array(150).fill([]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dynamicData) {
|
||||||
|
setHistory(history => {
|
||||||
|
const newHistory = history.slice(1);
|
||||||
|
newHistory.push(dynamicData.temps);
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dynamicData]);
|
||||||
|
|
||||||
|
const total_sensors = useMemo(() => history.at(-1)?.length ?? 0, [history]);
|
||||||
|
|
||||||
|
if (!staticData || !dynamicData) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title='Temperatures'
|
||||||
|
legend={{
|
||||||
|
values: dynamicData.temps,
|
||||||
|
labels: staticData.components,
|
||||||
|
}}
|
||||||
|
formatOptions={{ units: 'ºC' }}
|
||||||
|
data={history}
|
||||||
|
total={total_sensors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
src/client/components/legend/index.css
Normal file
19
src/client/components/legend/index.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.legend-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 128px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/client/components/legend/index.tsx
Normal file
21
src/client/components/legend/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { getTextColor } from '@/utils/colors';
|
||||||
|
import { type FormatOptions, formatValue } from '@/utils/format';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
export type LegendProps = {
|
||||||
|
labels: string[];
|
||||||
|
values: number[];
|
||||||
|
formatOptions?: FormatOptions;
|
||||||
|
hueOffset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Legend = ({ labels, values, formatOptions, hueOffset = 0 }: LegendProps) => (
|
||||||
|
<div className='legend-wrapper'>
|
||||||
|
{labels.map((label, index) => (
|
||||||
|
<div key={labels[index]}>
|
||||||
|
<small style={{ color: getTextColor((hueOffset + (360 * index) / values.length) % 360) }}>{label}</small>
|
||||||
|
<h4>{formatValue(values[index], formatOptions)}</h4>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
24
src/client/hooks/use-animation-frame.ts
Normal file
24
src/client/hooks/use-animation-frame.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useAnimationFrame = (callback: (dt: number) => void) => {
|
||||||
|
const requestRef = useRef<number>();
|
||||||
|
const previousTimeRef = useRef<number>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate: FrameRequestCallback = time => {
|
||||||
|
if (previousTimeRef.current !== undefined) {
|
||||||
|
const deltaTime = time - previousTimeRef.current;
|
||||||
|
callback(deltaTime);
|
||||||
|
}
|
||||||
|
previousTimeRef.current = time;
|
||||||
|
requestRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
requestRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (requestRef.current) {
|
||||||
|
cancelAnimationFrame(requestRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [callback]);
|
||||||
|
};
|
||||||
33
src/client/index.css
Normal file
33
src/client/index.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
:root {
|
||||||
|
--color-neutral0: #002b36;
|
||||||
|
--color-neutral1: #073642;
|
||||||
|
--color-neutral2: #586e75;
|
||||||
|
--color-neutral3: #657b83;
|
||||||
|
--color-neutral4: #839496;
|
||||||
|
--color-neutral5: #93a1a1;
|
||||||
|
--color-neutral6: #eee8d5;
|
||||||
|
--color-neutral7: #fdf6e3;
|
||||||
|
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color: var(--color-neutral6);
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
10
src/client/main.tsx
Normal file
10
src/client/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
31
src/client/types.ts
Normal file
31
src/client/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
type StaticData = {
|
||||||
|
boot_time: number;
|
||||||
|
components: string[];
|
||||||
|
cpu: {
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
vendor_id: string;
|
||||||
|
};
|
||||||
|
host_name: string;
|
||||||
|
kernel_version: string;
|
||||||
|
name: string;
|
||||||
|
os_version: string;
|
||||||
|
total_memory: number;
|
||||||
|
total_swap: number;
|
||||||
|
uptime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DynamicData = {
|
||||||
|
cpu_usage: number[];
|
||||||
|
disks: {
|
||||||
|
read: number;
|
||||||
|
write: number;
|
||||||
|
};
|
||||||
|
mem_usage: number;
|
||||||
|
network: {
|
||||||
|
down: number;
|
||||||
|
up: number;
|
||||||
|
};
|
||||||
|
swap_usage: number;
|
||||||
|
temps: number[];
|
||||||
|
};
|
||||||
3
src/client/utils/colors.ts
Normal file
3
src/client/utils/colors.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const getTextColor = (hue: number) => `hsl(${hue} 50 50)`;
|
||||||
|
export const getFillColor = (hue: number) => `hsl(${hue} 50 50 / 10%)`;
|
||||||
|
export const getStrokeColor = (hue: number) => `hsl(${hue} 50 50 / 60%)`;
|
||||||
20
src/client/utils/format.ts
Normal file
20
src/client/utils/format.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const prefixes = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
|
||||||
|
|
||||||
|
export type FormatOptions = { decimals?: number; prefix?: boolean; si?: boolean; units?: string };
|
||||||
|
|
||||||
|
export const formatValue = (value: number, { decimals = 1, prefix = true, si, units = 'B' }: FormatOptions = {}) => {
|
||||||
|
if (!value) {
|
||||||
|
return `0\u2009${units}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prefix) {
|
||||||
|
return `${value}\u2009${units}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = si ? 1000 : 1024;
|
||||||
|
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||||
|
|
||||||
|
return `${Number((value / k ** i).toFixed(Math.max(0, decimals)))}\u2009${prefixes[i]}${
|
||||||
|
i > 0 && !si ? 'i' : ''
|
||||||
|
}${units}`;
|
||||||
|
};
|
||||||
1
src/client/vite-env.d.ts
vendored
Normal file
1
src/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
56
src/stats-server/disks.rs
Normal file
56
src/stats-server/disks.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
pub mod disks {
|
||||||
|
use serde_json::json;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{Read, Seek, SeekFrom},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct DiskUsage {
|
||||||
|
read: u64,
|
||||||
|
write: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskUsage {
|
||||||
|
fn new() -> Self {
|
||||||
|
DiskUsage { read: 0, write: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiskStats {
|
||||||
|
fd: File,
|
||||||
|
prev: DiskUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskStats {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let fd = File::open("/proc/diskstats").unwrap();
|
||||||
|
DiskStats {
|
||||||
|
fd,
|
||||||
|
prev: DiskUsage::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) -> DiskUsage {
|
||||||
|
let mut curr = DiskUsage::new();
|
||||||
|
let mut io_data = String::new();
|
||||||
|
self.fd.read_to_string(&mut io_data).unwrap();
|
||||||
|
for line in io_data.lines() {
|
||||||
|
let fields: Vec<_> = line.split_whitespace().collect();
|
||||||
|
curr.read += fields[5].parse::<u64>().unwrap() * 512 * 8;
|
||||||
|
curr.write += fields[9].parse::<u64>().unwrap() * 512 * 8;
|
||||||
|
}
|
||||||
|
self.fd.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
curr
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff(&mut self) -> serde_json::Value {
|
||||||
|
let curr = self.refresh();
|
||||||
|
let diff = json!({
|
||||||
|
"read": curr.read - self.prev.read,
|
||||||
|
"write": curr.write - self.prev.write,
|
||||||
|
});
|
||||||
|
self.prev = curr;
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/stats-server/main.rs
Normal file
149
src/stats-server/main.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
use axum::{extract::State, http::Response, response::IntoResponse, routing::get, Router, Server};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, Networks, RefreshKind, System};
|
||||||
|
|
||||||
|
use crate::disks::disks::DiskStats;
|
||||||
|
|
||||||
|
mod disks;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
latest: Arc<Mutex<Instant>>,
|
||||||
|
data: Arc<Mutex<Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
AppState {
|
||||||
|
latest: Arc::new(Mutex::new(Instant::now())),
|
||||||
|
data: Arc::new(Mutex::new(json!({}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let app_state = AppState::default();
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/api/dynamic", get(dynamic_sysinfo_get))
|
||||||
|
.route("/api/static", get(static_sysinfo_get))
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
|
// Update system usage in the background
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut sys = System::new();
|
||||||
|
let mut components = Components::new_with_refreshed_list();
|
||||||
|
let mut networks = Networks::new_with_refreshed_list();
|
||||||
|
let mut disk_stats = DiskStats::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = Instant::now();
|
||||||
|
let latest = app_state.latest.lock().unwrap().clone();
|
||||||
|
|
||||||
|
if (now - latest).as_millis() < 10000 {
|
||||||
|
sys.refresh_cpu();
|
||||||
|
sys.refresh_memory();
|
||||||
|
components.refresh();
|
||||||
|
networks.refresh();
|
||||||
|
disk_stats.refresh();
|
||||||
|
|
||||||
|
let cpu_usage: Vec<_> = sys.cpus().iter().map(|cpu| cpu.cpu_usage()).collect();
|
||||||
|
let mem_usage = sys.used_memory();
|
||||||
|
let swap_usage = sys.used_swap();
|
||||||
|
let temps: Vec<_> = components
|
||||||
|
.iter()
|
||||||
|
.map(|component| component.temperature())
|
||||||
|
.collect();
|
||||||
|
let (net_down, net_up) =
|
||||||
|
networks
|
||||||
|
.iter()
|
||||||
|
.fold((0, 0), |(down, up), (_name, network)| {
|
||||||
|
(down + network.received(), up + network.transmitted())
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut data = app_state.data.lock().unwrap();
|
||||||
|
*data = json!({
|
||||||
|
"cpu_usage": cpu_usage,
|
||||||
|
"mem_usage": mem_usage,
|
||||||
|
"swap_usage": swap_usage,
|
||||||
|
"temps": temps,
|
||||||
|
"network": {
|
||||||
|
"down": net_down,
|
||||||
|
"up": net_up
|
||||||
|
},
|
||||||
|
"disks": disk_stats.diff()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let server = Server::bind(&"0.0.0.0:3001".parse().unwrap()).serve(router.into_make_service());
|
||||||
|
let addr = server.local_addr();
|
||||||
|
println!("Listening on {addr}");
|
||||||
|
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn dynamic_sysinfo_get(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let data = state.data.lock().unwrap().clone();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut latest = state.latest.lock().unwrap();
|
||||||
|
*latest = Instant::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.header(axum::http::header::ORIGIN, "*")
|
||||||
|
.header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(data.to_string())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn static_sysinfo_get() -> impl IntoResponse {
|
||||||
|
let sys = System::new_with_specifics(
|
||||||
|
RefreshKind::new()
|
||||||
|
.with_cpu(CpuRefreshKind::everything())
|
||||||
|
.with_memory(MemoryRefreshKind::everything()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let components = Components::new_with_refreshed_list();
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.header(axum::http::header::ORIGIN, "*")
|
||||||
|
.header(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(
|
||||||
|
json!({
|
||||||
|
"boot_time": System::boot_time(),
|
||||||
|
"uptime": System::uptime(),
|
||||||
|
"name": System::name(),
|
||||||
|
"kernel_version": System::kernel_version(),
|
||||||
|
"os_version": System::os_version(),
|
||||||
|
"host_name": System::host_name(),
|
||||||
|
"total_memory": sys.total_memory(),
|
||||||
|
"total_swap": sys.total_swap(),
|
||||||
|
"cpu": {
|
||||||
|
"name": sys.cpus()[0].name(),
|
||||||
|
"vendor_id": sys.cpus()[0].vendor_id(),
|
||||||
|
"brand": sys.cpus()[0].brand(),
|
||||||
|
},
|
||||||
|
"components": components
|
||||||
|
.iter()
|
||||||
|
.map(|component| component.label())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/client/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve('./src/client'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user