Add server state fetching

This commit is contained in:
Eric Wang
2022-11-06 02:38:15 +00:00
parent a4fd0eff4e
commit 1aba32bbb5
18 changed files with 301 additions and 213 deletions

View File

@@ -4,17 +4,24 @@ namespace Convoy\Http\Controllers\Client\Servers;
use Convoy\Http\Controllers\ApplicationApiController;
use Convoy\Models\Server;
use Convoy\Repositories\Proxmox\Server\ProxmoxServerRepository;
use Convoy\Services\Servers\ServerDetailService;
use Convoy\Transformers\Client\ServerStatusTransformer;
use Convoy\Transformers\Client\ServerTransformer;
class ServerController extends ApplicationApiController
{
public function __construct(private ServerDetailService $service)
public function __construct(private ServerDetailService $detailService, private ProxmoxServerRepository $repository)
{
}
public function index(Server $server)
{
return fractal($this->service->getByEloquent($server), new ServerTransformer)->respond();
return fractal($this->detailService->getByEloquent($server), new ServerTransformer)->respond();
}
public function status(Server $server)
{
return fractal()->item($this->repository->setServer($server)->getStatus(), new ServerStatusTransformer)->respond();
}
}

View File

@@ -38,43 +38,12 @@ class AuthenticateServerAccess
try {
$server->validateCurrentState();
if ($request->routeIs(['servers.show.building', 'servers.show.suspended'])) {
return redirect()->route('servers.show', $server);
}
} catch (ServerStateConflictException $exception) {
if ($server->isInstalling() && !$request->routeIs('servers.show.building')) {
//throw $exception; // for v3
return redirect()->route('servers.show.building', $server->id);
}
if ($server->isSuspended() && !$request->routeIs('servers.show.suspended')) {
//throw $exception; // for v3
return redirect()->route('servers.show.suspended', $server->id);
}
if ($request->routeIs(['servers.show.building', 'servers.show.suspended'])) {
if ($request->routeIs('api:client:servers.show')) {
return $next($request);
}
throw $exception;
/* if (!$request->routeIs(['servers.show.building'])) {
if ($server->isSuspended() && !$request->routeIs('servers.show.suspended')) {
//throw $exception; // for v3
return redirect()->route('servers.show.suspended', $server->id);
}
if ($server->isInstalling()) {
//throw $exception; // for v3
return redirect()->route('servers.show.building', $server->id);
}
// TODO: users can still view building screen on accident if the server is suspended and vice versa
if (!$user->root_admin || !$request->routeIs($this->except) && !$request->routeIs(['servers.show.suspended', 'servers.show.building'])) {
throw $exception;
}
} */
}
return $next($request);

View File

@@ -0,0 +1,23 @@
<?php
namespace Convoy\Transformers\Client;
use League\Fractal\TransformerAbstract;
class ServerStatusTransformer extends TransformerAbstract
{
/**
* A Fractal transformer.
*
* @return array
*/
public function transform(array $data)
{
return [
'state' => array_get($data, 'status'),
'uptime' => array_get($data, 'uptime'),
'cpu' => array_get($data, 'cpu'),
'memory' => array_get($data, 'mem'),
];
}
}

View File

@@ -30,7 +30,6 @@
"webmozart/assert": "^1.11"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.7",
"fakerphp/faker": "^1.9.1",
"laravel/breeze": "^1.10",
"laravel/pint": "^1.2",

152
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "05cf11cf918a00051cc054e8ffc75170",
"content-hash": "cb91f4b0758f1e0941967192c7c84c2c",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -6778,90 +6778,6 @@
}
],
"packages-dev": [
{
"name": "barryvdh/laravel-debugbar",
"version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-debugbar.git",
"reference": "3372ed65e6d2039d663ed19aa699956f9d346271"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/3372ed65e6d2039d663ed19aa699956f9d346271",
"reference": "3372ed65e6d2039d663ed19aa699956f9d346271",
"shasum": ""
},
"require": {
"illuminate/routing": "^7|^8|^9",
"illuminate/session": "^7|^8|^9",
"illuminate/support": "^7|^8|^9",
"maximebf/debugbar": "^1.17.2",
"php": ">=7.2.5",
"symfony/finder": "^5|^6"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"orchestra/testbench-dusk": "^5|^6|^7",
"phpunit/phpunit": "^8.5|^9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.6-dev"
},
"laravel": {
"providers": [
"Barryvdh\\Debugbar\\ServiceProvider"
],
"aliases": {
"Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar"
}
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Barryvdh\\Debugbar\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "PHP Debugbar integration for Laravel",
"keywords": [
"debug",
"debugbar",
"laravel",
"profiler",
"webprofiler"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-debugbar/issues",
"source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.7.0"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2022-07-11T09:26:42+00:00"
},
{
"name": "composer/class-map-generator",
"version": "1.0.0",
@@ -7452,72 +7368,6 @@
},
"time": "2022-09-28T13:13:22+00:00"
},
{
"name": "maximebf/debugbar",
"version": "v1.18.1",
"source": {
"type": "git",
"url": "https://github.com/maximebf/php-debugbar.git",
"reference": "ba0af68dd4316834701ecb30a00ce9604ced3ee9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/ba0af68dd4316834701ecb30a00ce9604ced3ee9",
"reference": "ba0af68dd4316834701ecb30a00ce9604ced3ee9",
"shasum": ""
},
"require": {
"php": "^7.1|^8",
"psr/log": "^1|^2|^3",
"symfony/var-dumper": "^2.6|^3|^4|^5|^6"
},
"require-dev": {
"phpunit/phpunit": "^7.5.20 || ^9.4.2",
"twig/twig": "^1.38|^2.7|^3.0"
},
"suggest": {
"kriswallsmith/assetic": "The best way to manage assets",
"monolog/monolog": "Log using Monolog",
"predis/predis": "Redis storage"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.18-dev"
}
},
"autoload": {
"psr-4": {
"DebugBar\\": "src/DebugBar/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maxime Bouroumeau-Fuseau",
"email": "maxime.bouroumeau@gmail.com",
"homepage": "http://maximebf.com"
},
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Debug bar in the browser for php application",
"homepage": "https://github.com/maximebf/php-debugbar",
"keywords": [
"debug",
"debugbar"
],
"support": {
"issues": "https://github.com/maximebf/php-debugbar/issues",
"source": "https://github.com/maximebf/php-debugbar/tree/v1.18.1"
},
"time": "2022-03-31T14:55:54+00:00"
},
{
"name": "mockery/mockery",
"version": "1.5.1",

View File

@@ -37,7 +37,7 @@ const http: AxiosInstance = axios.create({
})
http.interceptors.request.use((req) => {
if (!req.url?.endsWith('/resources')) {
if (!req.url?.endsWith('/status')) {
store.getActions().progress.startContinuous()
}
@@ -46,7 +46,7 @@ http.interceptors.request.use((req) => {
http.interceptors.response.use(
(resp) => {
if (!resp.request?.url?.endsWith('/resources')) {
if (!resp.request?.url?.endsWith('/status')) {
store.getActions().progress.setComplete()
}

View File

@@ -0,0 +1,18 @@
import http from '@/api/http';
export type ServerState = 'running' | 'stopped'
export interface ServerStatus {
state: ServerState
uptime: number
cpu: number
memory: number
}
export default (uuid: string): Promise<ServerStatus> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/status`)
.then(({ data: { data } }) => resolve(data))
.catch(reject)
})
};

View File

@@ -15,7 +15,7 @@ ${tw`dark:text-stone-400 text-stone-500 text-xs`}
`
const Dd = styled.dt`
${tw`text-sm font-semibold`}
${tw`text-sm font-medium`}
`
const ServerCard = ({ server }: Props) => {

View File

@@ -98,7 +98,7 @@ const NavigationBar = ({ routes, breadcrumb }: Props) => {
className='w-7 h-7 dark:invert'
alt='Convoy logo'
/>
<h1 className='font-bold text-lg dark:text-white'>Convoy</h1>
<h1 className='font-semibold text-lg dark:text-white'>Convoy</h1>
</Link>
{breadcrumb && (
<>

View File

@@ -0,0 +1,18 @@
import PageContentBlock, { PageContentBlockProps } from '@/components/elements/PageContentBlock';
import { ServerContext } from '@/state/server';
interface Props extends PageContentBlockProps {
title: string;
}
const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => {
const name = ServerContext.useStoreState((state) => state.server.data!.name);
return (
<PageContentBlock title={`${title} | ${name}`} {...props}>
{children}
</PageContentBlock>
);
};
export default ServerContentBlock;

View File

@@ -0,0 +1,135 @@
import { ServerContext } from '@/state/server'
import { capitalize, convertTimeToSmallest, formatBytes } from '@/util/helpers'
import styled from '@emotion/styled'
import { Skeleton } from '@mantine/core'
import { useEffect, useMemo, useRef } from 'react'
import tw from 'twin.macro'
export const StatRow = styled.div`
${tw`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 border border-[#eaeaea] dark:border-[#333333] shadow dark:shadow-none rounded-lg bg-white dark:bg-black`}
&>div {
${tw`border-[#eaeaea] dark:border-[#333333] p-6`}
}
& > div > p {
${tw`font-semibold text-sm text-black dark:text-stone-400`}
}
& > div:not(:last-child) {
${tw`md:border-r border-[#eaeaea] dark:border-[#333333]`}
}
& > div:nth-of-type(-n + 2) {
${tw`border-b lg:border-b-0`}
}
& > div:nth-of-type(2) {
${tw`md:border-r-0 lg:border-r`}
}
& > div:nth-of-type(3) {
${tw`border-b md:border-b-0`}
}
& > div > p:nth-of-type(2) {
${tw`text-2xl font-semibold mt-1 dark:text-white`}
}
`
const ServerDetailsBlock = () => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid)
const memoryLimit = ServerContext.useStoreState(
(state) => state.server.data!.limits.memory
)
const status = ServerContext.useStoreState((state) => state.status.data)
const getStatus = ServerContext.useStoreActions(
(actions) => actions.status.getStatus
)
const isUpdating = useRef(true)
useEffect(() => {
isUpdating.current = true
const update = async () => {
if (!isUpdating.current) return
await getStatus(uuid)
setTimeout(update, 1000)
}
update()
return () => {
isUpdating.current = false
}
}, [])
const memory = useMemo(
() => ({
used: formatBytes(status?.memory || 0),
total: formatBytes(memoryLimit),
}),
[status]
)
const uptime = useMemo(
() => convertTimeToSmallest(status?.uptime || 0),
[status]
)
return (
<div>
{!status ? (
<Skeleton className='w-full !h-[421px] md:!h-[211px] lg:!h-[106px]' />
) : (
<StatRow>
<div>
<p>Server State</p>
<div className='flex space-x-2 items-center mt-1'>
<div className='grid place-items-center h-full'>
<div
className={`w-3 h-3 rounded-full ${
status.state === 'running' ? 'bg-green-500' : 'bg-red-500'
}`}
></div>
</div>
<p className='text-2xl font-semibold dark:text-white'>
{capitalize(status.state)}
</p>
</div>
</div>
<div>
<p>CPU Usage</p>
<p>{Math.floor(status.cpu * 100)}%</p>
</div>
<div>
<p>Memory Usage</p>
<div className='flex space-x-2 items-end mt-1'>
<p className='text-2xl font-semibold dark:text-white'>
{memory.used.size} {memory.used.unit}
</p>
<p className='text-sm font-semibold description mb-[0.3rem]'>
/ {memory.total.size} {memory.total.unit}
</p>
</div>
</div>
<div>
<p>Uptime</p>
<div className='flex space-x-2 items-end mt-1'>
<p className='text-2xl font-semibold dark:text-white'>
{Math.floor(uptime.time)}
</p>
<p className='text-sm font-semibold description mb-[0.3rem]'>
{uptime.unit}
</p>
</div>
</div>
</StatRow>
)}
</div>
)
}
export default ServerDetailsBlock

View File

@@ -1,9 +1,12 @@
import PageContentBlock from '@/components/elements/PageContentBlock'
import ServerDetailsBlock from '@/components/servers/overview/ServerDetailsBlock'
import ServerContentBlock from '@/components/servers/ServerContentBlock'
const ServerOverviewContainer = () => {
return <PageContentBlock title='Overview'>
<div>Overview</div>
</PageContentBlock>
return (
<ServerContentBlock title='Overview'>
<ServerDetailsBlock />
</ServerContentBlock>
)
}
export default ServerOverviewContainer
export default ServerOverviewContainer

View File

@@ -1,17 +1,20 @@
import { httpErrorToHuman } from '@/api/http'
import NavigationBar from '@/components/elements/navigation/NavigationBar'
import { NotFound, ServerError } from '@/components/elements/ScreenBlock'
import ScreenBlock, {
NotFound,
ServerError,
} from '@/components/elements/ScreenBlock'
import Spinner from '@/components/elements/Spinner'
import routes from '@/routers/routes'
import { ServerContext } from '@/state/server'
import { useEffect, useState } from 'react'
import {
matchPath,
Route,
Routes,
useLocation,
useMatch,
} from 'react-router-dom'
import { ArrowPathIcon, NoSymbolIcon } from '@heroicons/react/24/outline'
const navRoutes = [
{
@@ -21,7 +24,6 @@ const navRoutes = [
]
const ServerRouter = () => {
const location = useLocation()
const match = useMatch('/servers/:id')
const [error, setError] = useState<string>()
const server = ServerContext.useStoreState((state) => state.server.data)
@@ -55,17 +57,27 @@ const ServerRouter = () => {
<Spinner />
)
) : (
<Routes>
{routes.server.map((route) => (
<Route
key={route.path}
path={route.path}
element={<route.component />}
/>
))}
<>
{server.status === 'suspended' && (
<ScreenBlock center icon={NoSymbolIcon} message='This server is suspended. Contact your provider or system administrator for help.' title='Suspended' />
)}
{server.status === 'installing' && (
<ScreenBlock center icon={ArrowPathIcon} message='Your server is being reinstalled. This can take from 1-15 minutes.' title='Installing' />
)}
{server.status === null || server.status === undefined ? (
<Routes>
{routes.server.map((route) => (
<Route
key={route.path}
path={route.path}
element={<route.component />}
/>
))}
<Route path={'*'} element={<NotFound full />} />
</Routes>
<Route path={'*'} element={<NotFound full />} />
</Routes>
) : ''}
</>
)}
</>
)

View File

@@ -1,5 +1,7 @@
import getServer, { Server } from '@/api/server/getServer'
import getStatus, { ServerStatus } from '@/api/server/getStatus'
import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy'
import isEqual from 'react-fast-compare'
export interface ServerDataStore {
data?: Server
@@ -10,7 +12,9 @@ export interface ServerDataStore {
const server: ServerDataStore = {
data: undefined,
setServer: action((state, payload) => {
state.data = payload
if (!isEqual(payload, state.data)) {
state.data = payload
}
}),
getServer: thunk(async (actions, uuid) => {
const server = await getServer(uuid)
@@ -19,14 +23,38 @@ const server: ServerDataStore = {
}),
}
export interface ServerStatusStore {
data?: ServerStatus
getStatus: Thunk<ServerStatusStore, string>
setStatus: Action<ServerStatusStore, ServerStatus>
}
const status: ServerStatusStore = {
data: undefined,
getStatus: thunk(async (actions, uuid) => {
const status = await getStatus(uuid)
actions.setStatus(status)
}),
setStatus: action((state, payload) => {
if (!isEqual(payload, state.data)) {
state.data = payload
}
})
}
interface ServerStore {
server: ServerDataStore
status: ServerStatusStore
clearServerState: Action<ServerStore>
}
export const ServerContext = createContextStore<ServerStore>({
server: server,
server,
status,
clearServerState: action((state) => {
state.server.data = undefined
state.status.data = undefined
}),
})

View File

@@ -49,4 +49,28 @@ export function formatBytes(
size,
unit: sizes[i],
}
}
export const capitalize = (word: string) => {
return word.charAt(0).toUpperCase() + word.slice(1)
}
export const convertTimeToSmallest = (seconds: number) => {
const units = [
[1, 'seconds'],
[60, 'minutes'],
[60 * 60, 'hours'],
[60 * 60 * 24, 'days'],
]
let bestUnit = units[0]
for (const unit of units) {
if (seconds >= unit[0]) {
bestUnit = unit
}
}
return {
time: seconds / (bestUnit[0] as number),
unit: bestUnit[1] as string,
}
}

View File

@@ -9,7 +9,7 @@
<link rel="icon" href="favicon.svg" sizes="any" type="image/svg+xml">
<!-- Fonts -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<!-- Inject Data -->
@if(!is_null(Auth::user()))

View File

@@ -14,5 +14,7 @@ Route::group([
AuthenticateServerAccess::class,
],
], function () {
Route::get('/', [Client\Servers\ServerController::class, 'index']);
Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:servers.show');
Route::get('/status', [Client\Servers\ServerController::class, 'status']);
});

View File

@@ -18,7 +18,7 @@ module.exports = {
primary: '#111111'
},
fontFamily: {
sans: ['Nunito', ...defaultTheme.fontFamily.sans],
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
},
},