diff --git a/app/Data/Server/Eloquent/ServerUsagesData.php b/app/Data/Server/Eloquent/ServerUsagesData.php index 228f8825..e044f11a 100644 --- a/app/Data/Server/Eloquent/ServerUsagesData.php +++ b/app/Data/Server/Eloquent/ServerUsagesData.php @@ -7,6 +7,6 @@ use Spatie\LaravelData\Data; class ServerUsagesData extends Data { public function __construct( - public int $bandwidth_usage + public int $bandwidth ) {} } diff --git a/app/Services/Servers/ServerDetailService.php b/app/Services/Servers/ServerDetailService.php index 91f52695..2b808f85 100644 --- a/app/Services/Servers/ServerDetailService.php +++ b/app/Services/Servers/ServerDetailService.php @@ -27,7 +27,7 @@ class ServerDetailService 'description' => $server->description, 'status' => $server->status, 'usages' => [ - 'bandwidth_usage' => $server->bandwidth_usage, + 'bandwidth' => $server->bandwidth_usage, ], 'limits' => [ 'cpu' => $server->cpu, diff --git a/app/Transformers/Client/ServerTransformer.php b/app/Transformers/Client/ServerTransformer.php index d4d5cae6..676509c0 100644 --- a/app/Transformers/Client/ServerTransformer.php +++ b/app/Transformers/Client/ServerTransformer.php @@ -25,7 +25,7 @@ class ServerTransformer extends TransformerAbstract 'status' => $server->status, 'node_id' => $server->node_id, 'usages' => [ - 'bandwidth_usage' => $server->usages->bandwidth_usage, + 'bandwidth' => $server->usages->bandwidth, ], 'limits' => [ 'cpu' => $server->limits->cpu, diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index b30595b8..b5aa72a7 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -18,7 +18,7 @@ export interface Server { status: EloquentStatus node_id: number usages: { - bandwidthUsage: number // bytes + bandwidth: number // bytes } limits: { cpu: number @@ -56,7 +56,7 @@ export const rawDataToServerObject = (data: FractalResponseData): Server => ({ : null : null, usages: { - bandwidthUsage: data.usages.bandwidth_usage, + bandwidth: data.usages.bandwidth, }, limits: { cpu: data.limits.cpu, diff --git a/resources/scripts/assets/css/tailwind.css b/resources/scripts/assets/css/tailwind.css index 751c3f7b..dfc18bce 100644 --- a/resources/scripts/assets/css/tailwind.css +++ b/resources/scripts/assets/css/tailwind.css @@ -3,7 +3,7 @@ @tailwind utilities; body { - @apply bg-stone-100 dark:bg-primary dark:text-stone-400; + @apply bg-[#fafafa] dark:bg-primary dark:text-stone-400; } .description { @@ -38,6 +38,10 @@ body { @apply border-[#eaeaea] dark:border-[#333333] hover:dark:border-white; } +.h5 { + @apply text-base font-semibold text-black dark:text-white; +} + .fade-enter, .fade-exit, .fade-appear { diff --git a/resources/scripts/components/ThemeProvider.tsx b/resources/scripts/components/ThemeProvider.tsx index 4daa6f61..c4e7b7b2 100644 --- a/resources/scripts/components/ThemeProvider.tsx +++ b/resources/scripts/components/ThemeProvider.tsx @@ -1,9 +1,7 @@ import { useStoreState } from '@/state' -import { - createEmotionCache, - MantineProvider, -} from '@mantine/core' +import { createEmotionCache, MantineProvider } from '@mantine/core' import { ReactNode, useEffect } from 'react' +import { NotificationsProvider } from '@mantine/notifications' const emotionCache = createEmotionCache({ key: 'mantine', @@ -32,7 +30,7 @@ const ThemeProvider = ({ children }: Props) => { colorScheme: theme === 'dark' ? 'dark' : 'light', }} > - {children} + {children} ) } diff --git a/resources/scripts/components/dashboard/ServerCard.tsx b/resources/scripts/components/dashboard/ServerCard.tsx index 0f8c2c69..880602ec 100644 --- a/resources/scripts/components/dashboard/ServerCard.tsx +++ b/resources/scripts/components/dashboard/ServerCard.tsx @@ -24,7 +24,7 @@ const ServerCard = ({ server }: Props) => { const disk = useMemo(() => formatBytes(server.limits.disk, 0), [server]) return ( - +
{getInitials(server.name, ' ', 2)} diff --git a/resources/scripts/components/elements/Card.tsx b/resources/scripts/components/elements/Card.tsx new file mode 100644 index 00000000..f0256548 --- /dev/null +++ b/resources/scripts/components/elements/Card.tsx @@ -0,0 +1,9 @@ + +import styled from '@emotion/styled' +import tw from 'twin.macro' + +const Card = styled.div` +${tw`border border-[#eaeaea] dark:border-[#333333] p-6 bg-white dark:bg-black rounded shadow-light dark:shadow-none`} +` + +export default Card \ No newline at end of file diff --git a/resources/scripts/components/elements/navigation/NavigationBar.tsx b/resources/scripts/components/elements/navigation/NavigationBar.tsx index ea8a325a..1b067f76 100644 --- a/resources/scripts/components/elements/navigation/NavigationBar.tsx +++ b/resources/scripts/components/elements/navigation/NavigationBar.tsx @@ -126,7 +126,7 @@ const NavigationBar = ({ routes, breadcrumb }: Props) => {
div { ${tw`border-[#eaeaea] dark:border-[#333333] p-6`} @@ -39,14 +48,13 @@ export const StatRow = styled.div` const ServerDetailsBlock = () => { const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid) - const memoryLimit = ServerContext.useStoreState( - (state) => state.server.data!.limits.memory - ) + const server = ServerContext.useStoreState((state) => state.server.data!) const status = ServerContext.useStoreState((state) => state.status.data) const getStatus = ServerContext.useStoreActions( (actions) => actions.status.getStatus ) const isUpdating = useRef(true) + const notify = useNotify() useEffect(() => { isUpdating.current = true @@ -54,7 +62,19 @@ const ServerDetailsBlock = () => { const update = async () => { if (!isUpdating.current) return - await getStatus(uuid) + try { + await getStatus(uuid) + } catch { + notify({ + title: 'Error', + message: 'Failed to update server status. Retrying in 5 seconds.', + color: 'red', + }) + + setTimeout(update, 5000) + + return + } setTimeout(update, 1000) } @@ -69,7 +89,7 @@ const ServerDetailsBlock = () => { const memory = useMemo( () => ({ used: formatBytes(status?.memory || 0), - total: formatBytes(memoryLimit), + total: formatBytes(server.limits.memory), }), [status] ) @@ -79,54 +99,123 @@ const ServerDetailsBlock = () => { [status] ) + const bandwidth = useMemo( + () => ({ + used: formatBytes(server.usages.bandwidth), + total: server.limits.bandwidth + ? formatBytes(server.limits.bandwidth) + : undefined, + percentage: server.limits.bandwidth + ? Math.floor( + (server.usages.bandwidth / server.limits.bandwidth) * 10000 + ) / 100 + : 0, + }), + [server] + ) + + const cpuGraph = useChartTickLabel('CPU', 100, '%', 2) + const memoryGraph = useChartTickLabel( + 'Memory', + memory.total.size, + memory.total.unit, + 2 + ) + + useEffect(() => { + if (status) { + cpuGraph.push(Math.floor(status.cpu * 10000) / 100) + memoryGraph.push( + formatBytes(status.memory, 2, memory.total.unit as Sizes).size + ) + } + }, [status]) + return ( -
+
{!status ? ( - + ) : ( - -
-

Server State

-
-
-
+ <> + +
+

Server State

+
+
+
+
+

+ {capitalize(status.state)} +

-

- {capitalize(status.state)} -

-
-
-

CPU Usage

-

{Math.floor(status.cpu * 100)}%

-
-
-

Memory Usage

-
-

- {memory.used.size} {memory.used.unit} -

-

- / {memory.total.size} {memory.total.unit} -

+
+

CPU Usage

+

{Math.floor(status.cpu * 100)}%

-
-
-

Uptime

-
-

- {Math.floor(uptime.time)} -

-

- {uptime.unit} -

+
+

Memory Usage

+
+

+ {memory.used.size} {memory.used.unit} +

+

+ / {memory.total.size} {memory.total.unit} +

+
-
- +
+

Uptime

+
+

+ {Math.floor(uptime.time)} +

+

+ {uptime.unit} +

+
+
+ + + +
Bandwidth Usage
+
+

+ {Math.floor(bandwidth.percentage)} +

+ +
+ + {bandwidth.used.size} {bandwidth.used.unit} /{' '} + {bandwidth.total + ? `${bandwidth.total.size} ${bandwidth.total.unit}` + : 'unlimited'} + +
+ + +
CPU
+ +
+ +
Memory
+ +
+ )}
) diff --git a/resources/scripts/util/chart.ts b/resources/scripts/util/chart.ts new file mode 100644 index 00000000..754c5279 --- /dev/null +++ b/resources/scripts/util/chart.ts @@ -0,0 +1,224 @@ +import { + Chart as ChartJS, + ChartData, + ChartDataset, + ChartOptions, + Filler, + LinearScale, + LineElement, + PointElement, +} from 'chart.js' +import { DeepPartial } from 'ts-essentials' +import { useEffect, useState } from 'react' +import { deepmerge, deepmergeCustom } from 'deepmerge-ts' +import { theme } from 'twin.macro' +import { hexToRgba } from '@/util/helpers' +import { useStoreState } from '@/state' + +ChartJS.register(LineElement, PointElement, Filler, LinearScale) + +const options: ChartOptions<'line'> = { + responsive: true, + animation: false, + plugins: { + legend: { display: false }, + title: { display: false }, + tooltip: { enabled: false }, + }, + layout: { + padding: 0, + }, + scales: { + x: { + min: 0, + max: 19, + type: 'linear', + grid: { + display: false, + drawBorder: false, + }, + ticks: { + display: false, + }, + }, + y: { + min: 0, + type: 'linear', + grid: { + display: true, + color: theme('colors.stone.300'), + drawBorder: false, + }, + ticks: { + display: true, + count: 3, + color: theme('colors.stone.900'), + font: { + family: theme('fontFamily.sans'), + size: 11, + weight: '400', + }, + }, + }, + }, + elements: { + point: { + radius: 0, + }, + line: { + tension: 0.15, + }, + }, +} + +function getOptions( + opts?: DeepPartial> | undefined +): ChartOptions<'line'> { + return deepmerge(options, opts || {}) +} + +type ChartDatasetCallback = ( + value: ChartDataset<'line'>, + index: number +) => ChartDataset<'line'> + +function getEmptyData( + label: string, + sets = 1, + callback?: ChartDatasetCallback | undefined +): ChartData<'line'> { + const next = callback || ((value) => value) + + return { + labels: Array(20) + .fill(0) + .map((_, index) => index), + datasets: Array(sets) + .fill(0) + .map((_, index) => + next( + { + fill: true, + label, + data: Array(20).fill(-5), + borderColor: theme('colors.blue.400'), + backgroundColor: hexToRgba(theme('colors.blue.700'), 0.5), + }, + index + ) + ), + } +} + +const merge = deepmergeCustom({ mergeArrays: false }) + +interface UseChartOptions { + sets: number + options?: DeepPartial> | number | undefined + callback?: ChartDatasetCallback | undefined +} + +function useChart(label: string, opts?: UseChartOptions) { + const themeMode = useStoreState((state) => state.settings.data?.theme) + + const [options, setOptions] = useState( + getOptions( + typeof opts?.options === 'number' + ? { scales: { y: { min: 0, suggestedMax: opts.options } } } + : opts?.options + ) + ) + + useEffect(() => { + if (themeMode === 'dark') { + setOptions( + merge(options, { + scales: { + y: { + grid: { + color: theme('colors.stone.700'), + }, + ticks: { + color: theme('colors.stone.300'), + }, + }, + }, + }) + ) + } else { + setOptions( + merge(options, { + scales: { + y: { + grid: { + color: theme('colors.stone.300'), + }, + ticks: { + color: theme('colors.stone.900'), + }, + }, + }, + }) + ) + } + }, [themeMode]) + + const [data, setData] = useState( + getEmptyData(label, opts?.sets || 1, opts?.callback) + ) + + const push = (items: number | null | (number | null)[]) => + setData((state) => + merge(state, { + datasets: (Array.isArray(items) ? items : [items]).map( + (item, index) => ({ + ...state.datasets[index], + data: state.datasets[index].data + .slice(1) + .concat( + typeof item === 'number' ? Number(item.toFixed(2)) : item + ), + }) + ), + }) + ) + + const clear = () => + setData((state) => + merge(state, { + datasets: state.datasets.map((value) => ({ + ...value, + data: Array(20).fill(-5), + })), + }) + ) + + return { props: { data, options }, push, clear, setOptions } +} + +function useChartTickLabel( + label: string, + max: number, + tickLabel: string, + roundTo?: number +) { + return useChart(label, { + sets: 1, + options: { + scales: { + y: { + suggestedMax: max, + ticks: { + callback(value) { + return `${ + roundTo ? Number(value).toFixed(roundTo) : value + }${tickLabel}` + }, + }, + }, + }, + }, + }) +} + +export { useChart, useChartTickLabel, getOptions, getEmptyData } diff --git a/resources/scripts/util/helpers.ts b/resources/scripts/util/helpers.ts index 0f9f4c78..4faf9693 100644 --- a/resources/scripts/util/helpers.ts +++ b/resources/scripts/util/helpers.ts @@ -29,7 +29,7 @@ export interface FormattedBytes { unit: string } -export type Sizes = 'B' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB' +export type Sizes = 'B' | 'KiB' | 'MiB' | 'GiB' | 'TiB' | 'PiB' | 'EiB' | 'ZiB' | 'YiB' export function formatBytes( bytes: number, @@ -47,7 +47,7 @@ export function formatBytes( return { size, - unit: sizes[i], + unit: sizes[i] as Sizes, } } @@ -73,4 +73,16 @@ export const convertTimeToSmallest = (seconds: number) => { time: seconds / (bestUnit[0] as number), unit: bestUnit[1] as string, } +} + +export const hexToRgba = (hex: string, alpha = 1): string => { + // noinspection RegExpSimplifiable + if (!/#?([a-fA-F0-9]{2}){3}/.test(hex)) { + return hex; + } + + // noinspection RegExpSimplifiable + const [r, g, b] = hex.match(/[a-fA-F0-9]{2}/g)!.map((v) => parseInt(v, 16)); + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; } \ No newline at end of file diff --git a/resources/scripts/util/useNotify.ts b/resources/scripts/util/useNotify.ts new file mode 100644 index 00000000..8d2c0c38 --- /dev/null +++ b/resources/scripts/util/useNotify.ts @@ -0,0 +1,12 @@ +import { NotificationProps, showNotification } from '@mantine/notifications' + +const useNotify = () => { + return (props: Omit) => showNotification({ + ...props, + style: { + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + }, + }) +} + +export default useNotify diff --git a/tailwind.config.js b/tailwind.config.js index 4f0aa1d1..ab2502e2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -15,11 +15,14 @@ module.exports = { theme: { extend: { colors: { - primary: '#111111' + primary: '#111111', }, fontFamily: { sans: ['Inter', ...defaultTheme.fontFamily.sans], }, + boxShadow: { + 'light': '0 4px 4px 0 rgba(0,0,0,.02)' + } }, },