diff --git a/backend/app.py b/backend/app.py index 01436ce6374ecb4a190e2243a9a6c6c6027c6bd9..06db7d65e6edd9947b356375b2b59e360a79b891 100644 --- a/backend/app.py +++ b/backend/app.py @@ -14,6 +14,7 @@ from web import web from areas import users from areas import apps from areas import auth +from areas import resources from areas import roles from areas import tags from cliapp import cliapp diff --git a/backend/areas/resources/__init__.py b/backend/areas/resources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7318f9e29ab05497740653b77334ad4c34c85a5b --- /dev/null +++ b/backend/areas/resources/__init__.py @@ -0,0 +1,2 @@ +from .resources import * +from .resources_service import * diff --git a/backend/areas/resources/resources.py b/backend/areas/resources/resources.py new file mode 100644 index 0000000000000000000000000000000000000000..c44b4010698c3426644f5a198b2713c22fbfc6ef --- /dev/null +++ b/backend/areas/resources/resources.py @@ -0,0 +1,18 @@ +from flask import jsonify, request +from flask_cors import cross_origin +from flask_expects_json import expects_json +from flask_jwt_extended import get_jwt, jwt_required + +from areas import api_v1 +from helpers.auth_guard import admin_required + +from .resources_service import ResourcesService + + +@api_v1.route("/resources", methods=["GET"]) +@jwt_required() +@cross_origin() +@admin_required() +def get_resources(): + res = ResourcesService.get_resources() + return jsonify(res) diff --git a/backend/areas/resources/resources_service.py b/backend/areas/resources/resources_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1b5238d641e4e5bab2db1dfbbc9c928ede3b0126 --- /dev/null +++ b/backend/areas/resources/resources_service.py @@ -0,0 +1,104 @@ +# from config import KRATOS_ADMIN_URL + +# from database import db + +# from kubernetes.client import CustomObjectsApi +# import re +import requests + +from flask import current_app +# import helpers.kubernetes + + +class ResourcesService: + @classmethod + def get_resources(cls): + # custom = CustomObjectsApi() + # raw_nodes = custom.list_cluster_custom_object('metrics.k8s.io', 'v1beta1', 'nodes') + # raw_pods = custom.list_cluster_custom_object('metrics.k8s.io', 'v1beta1', 'pods') + # nodes = [] + # for node in raw_nodes["items"]: + # nodes.append({ + # "name": node["metadata"]["name"], + # "cpu_raw": node["usage"]["cpu"], + # "cpu": cls.parse_cpu(node["usage"]["cpu"]), + # "memory_raw": node["usage"]["memory"], + # "memory_used": cls.parse_memory(node["usage"]["memory"]), + # }) + cores = cls.get_prometheus('machine_cpu_cores') + return { + # Number of cores in use. So average usage times number of cores. + "cpu": cores * cls.get_prometheus('1 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[8m])))', 'float'), + "cpu_total": cores, + # "memory_raw": node["usage"]["memory"], + # "memory_used": cls.parse_memory(node["usage"]["memory"]), + "memory_total": cls.get_prometheus('machine_memory_bytes'), + "memory_available": cls.get_prometheus('node_memory_MemAvailable_bytes'), + "disk_free": cls.get_prometheus('node_filesystem_free_bytes{mountpoint="/"}'), + "disk_total": cls.get_prometheus('node_filesystem_size_bytes{mountpoint="/"}'), + } + + # @staticmethod + # def parse_cpu(s): + # result = re.match(r"^(\d+)([mun]?)$", s) + # if result is None: + # raise Exception("cpu data does not match known patterns") + # number = result.group(1) + # suffix = result.group(2) + # multipliers = {"": 1, "m": 1e-3, "u": 1e-6, "n": 1e-9} + # return (int(number) * multipliers[suffix]) + + # @staticmethod + # def parse_memory(s): + # result = re.match(r"^(\d+)(|Ki|Mi|Gi)$", s) + # if result is None: + # raise Exception("memory data does not match known patterns") + # number = result.group(1) + # suffix = result.group(2) + # multipliers = {"": 1, "Ki": 1024, "Mi": 1024*1024, "Gi": 1024*1024*1024} + # return (int(number) * multipliers[suffix]) + + @staticmethod + def get_prometheus(query, cast='int'): + try: + params = { + "query": query, + } + result = requests.get("http://kube-prometheus-stack-prometheus:9090/api/v1/query", params=params) + current_app.logger.info(query) + current_app.logger.info(result.json()) + value = result.json()["data"]["result"][0]["value"][1] + except AttributeError: + return None + if cast == 'float': + converted = float(value) + else: + converted = int(value) + return converted + + # "pods": { + # "apiVersion": "metrics.k8s.io/v1beta1", + # "items": [ + # { + # "containers": [ + # { + # "name": "traffic-manager", + # "usage": { + # "cpu": "2839360n", + # "memory": "24696Ki" + # } + # } + # ], + # "metadata": { + # "creationTimestamp": "2023-11-30T15:10:10Z", + # "labels": { + # "app": "traffic-manager", + # "pod-template-hash": "5cd7cc7fd6", + # "telepresence": "manager" + # }, + # "name": "traffic-manager-5cd7cc7fd6-mp7td", + # "namespace": "ambassador" + # }, + # "timestamp": "2023-11-30T15:10:00Z", + # "window": "12.942s" + # }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d2d7e1131480e90e50a7d6a249285a1bdce83bb..e9b064f527150ea91098e49e638a920a3544bd97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import { useAuth } from 'src/services/auth'; -import { Dashboard, Users, Login, Apps, AppSingle } from './modules'; +import { Dashboard, Users, Login, Apps, AppSingle, ResourcesDashboard } from './modules'; import { Layout } from './components'; import { LoginCallback } from './modules/login/LoginCallback'; @@ -49,6 +49,9 @@ function App() { <Route path=":slug" element={<AppSingle />} /> <Route index element={<Apps />} /> </Route> + <Route path="/resources" element={<ProtectedRoute />}> + <Route index element={<ResourcesDashboard />} /> + </Route> <Route path="*" element={<Navigate to="/dashboard" />} /> </Routes> </Layout> diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 76619801ba39cc4cc70ff5c60a95037e48253d38..b9227c3c39e6e447d852d82e8a794503f00f7718 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -16,6 +16,7 @@ const navigation = [ { name: 'Dashboard', to: '/dashboard', requiresAdmin: false }, { name: 'Users', to: '/users', requiresAdmin: true }, { name: 'Apps', to: '/apps', requiresAdmin: true }, + { name: 'System resources', to: '/resources', requiresAdmin: true }, ]; function classNames(...classes: any[]) { diff --git a/frontend/src/modules/index.ts b/frontend/src/modules/index.ts index 389ad968f3a1a8123bda1cfb8b4d671ec770de1a..662dbe7ccbe3135317706cd2b35aab1db2bcdf54 100644 --- a/frontend/src/modules/index.ts +++ b/frontend/src/modules/index.ts @@ -1,4 +1,5 @@ export { Login } from './login'; export { Dashboard } from './dashboard'; export { Apps, AppSingle } from './apps'; +export { ResourcesDashboard } from './resources'; export { Users } from './users'; diff --git a/frontend/src/modules/resources/Resources.tsx b/frontend/src/modules/resources/Resources.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2414e92fc64f62cd44acac7e4932ad1c8a75d939 --- /dev/null +++ b/frontend/src/modules/resources/Resources.tsx @@ -0,0 +1,57 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/** + * This page shows system information, in particular resource usage (cpu, + * memory, disk). + * + * This page is only available for admin users. + */ +import React, { useEffect } from 'react'; +import { ResourceCard } from './components/ResourceCard'; +import { useResources } from 'src/services/resources'; + +const metrics = [ + { + id: 'memory', + title: 'Memory', + }, + { + id: 'cpu', + title: 'CPU', + }, + { + id: 'disk', + title: 'Disk', + }, +]; + +export const ResourcesDashboard: React.FC = () => { + const { resources, loadResources } = useResources(); + window.console.log(resources); + + useEffect(() => { + loadResources(); + }, []); + + return ( + <div className="relative"> + <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow"> + <div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between"> + <h1 className="text-3xl leading-6 font-bold text-gray-900">System resources</h1> + </div> + <div className="flex flex-col"> + <div className="-my-2 sm:-mx-6 lg:-mx-8"> + <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> + <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow"> + <div className=" mt-5 grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-3 mb-10"> + {metrics.map((metric) => ( + <ResourceCard key={metric.id} metric={metric} resources={resources} /> + ))} + </div> + </div> + </div> + </div> + </div> + </div> + </div> + ); +}; diff --git a/frontend/src/modules/resources/components/ResourceCard/ResourceCard.tsx b/frontend/src/modules/resources/components/ResourceCard/ResourceCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f966504b4dddd4a962256f6634f842abe7142ca3 --- /dev/null +++ b/frontend/src/modules/resources/components/ResourceCard/ResourceCard.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import GaugeComponent from 'react-gauge-component'; + +import { Resources } from 'src/services/resources'; + +type ResourceCardProps = { + metric: any; + resources: Resources; +}; + +const format = (id: string) => { + switch (id) { + case 'disk': + return (s: number) => `${s.toFixed(1)} GB`; + case 'memory': + return (s: number) => `${s.toFixed(1)} GB`; + case 'cpu': + return (s: number) => `${s.toFixed(0)}%`; + } +}; +const formatTick = (id: string) => { + switch (id) { + case 'disk': + return (s: number) => `${s.toFixed(1)}`; + case 'memory': + return (s: number) => `${s.toFixed(1)}`; + case 'cpu': + return (s: number) => `${s.toFixed(0)}%`; + } +}; + +const getValue = (id: string, resources: Resources) => { + switch (id) { + case 'disk': + return resources.disk_used; + case 'memory': + return resources.memory_used; + case 'cpu': + return resources.cpu; + } +}; + +const getMax = (id: string, resources: Resources) => { + switch (id) { + case 'disk': + return resources.disk_total; + case 'memory': + return resources.memory_total; + case 'cpu': + return 100; + default: + return 100; + } +}; + +export const ResourceCard = ({ metric, resources }: ResourceCardProps) => { + const max = getMax(metric.id, resources); + return ( + <> + <div + className="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-100 mb-4 md:mb-0 flex flex-col justify-between relative" + key={metric.id} + > + <div className="px-4 pt-4 pb-2"> + <div className="mr-4 flex items-center"> + <div className="flex flex-col justify-between gap-1 items-center"> + <h2 className="text-xl leading-8 font-bold">{metric.title}</h2> + <GaugeComponent + value={getValue(metric.id, resources)} + marginInPercent={{ + top: 0.03, + bottom: 0.03, + left: 0.15, + right: 0.15, + }} + maxValue={max} + arc={{ + subArcs: [ + { limit: max * 0.7, color: 'green' }, + { limit: max * 0.85, color: 'orange' }, + { limit: max, color: 'red' }, + ], + }} + labels={{ + valueLabel: { + formatTextValue: format(metric.id), + style: { + fill: 'black', + textShadow: 'none', + }, + }, + tickLabels: { + defaultTickValueConfig: { + formatTextValue: formatTick(metric.id), + }, + }, + }} + style={{ + width: '350px', + }} + /> + </div> + </div> + </div> + </div> + </> + ); +}; diff --git a/frontend/src/modules/resources/components/ResourceCard/index.ts b/frontend/src/modules/resources/components/ResourceCard/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8332706100a64d41465628548e552ad9ea418e2 --- /dev/null +++ b/frontend/src/modules/resources/components/ResourceCard/index.ts @@ -0,0 +1 @@ +export { ResourceCard } from './ResourceCard'; diff --git a/frontend/src/modules/resources/components/index.ts b/frontend/src/modules/resources/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8332706100a64d41465628548e552ad9ea418e2 --- /dev/null +++ b/frontend/src/modules/resources/components/index.ts @@ -0,0 +1 @@ +export { ResourceCard } from './ResourceCard'; diff --git a/frontend/src/modules/resources/index.ts b/frontend/src/modules/resources/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d5067f20dd08ee09a09c15ed5d49078d3a7e43b --- /dev/null +++ b/frontend/src/modules/resources/index.ts @@ -0,0 +1 @@ +export { ResourcesDashboard } from './Resources'; diff --git a/frontend/src/redux/store.ts b/frontend/src/redux/store.ts index 598fcfdef1f26db0068e9f1574b4974698c81ff3..06dcc4ea9272d084f8a5ea2f8d8c0d0f4952c18c 100644 --- a/frontend/src/redux/store.ts +++ b/frontend/src/redux/store.ts @@ -7,6 +7,7 @@ import { reducer as authReducer } from 'src/services/auth'; import usersReducer from 'src/services/users/redux/reducers'; import appsReducer from 'src/services/apps/redux/reducers'; +import resourcesReducer from 'src/services/resources/redux/reducers'; import sysInfoReducer from 'src/services/sysInfo/redux/reducers'; import { State } from './types'; @@ -20,6 +21,7 @@ const appReducer = combineReducers<State>({ auth: authReducer, users: usersReducer, apps: appsReducer, + resources: resourcesReducer, sysInfo: sysInfoReducer, }); diff --git a/frontend/src/redux/types.ts b/frontend/src/redux/types.ts index c958fc6c056f9db5cc008cc317e3feff611327d9..ea751c19a151585a6092888b59a7c7c2a14b2921 100644 --- a/frontend/src/redux/types.ts +++ b/frontend/src/redux/types.ts @@ -3,6 +3,7 @@ import { Store } from 'redux'; import { AuthState } from 'src/services/auth/redux'; import { UsersState } from 'src/services/users/redux'; import { AppsState } from 'src/services/apps/redux'; +import { ResourcesState } from 'src/services/resources/redux'; import { SysInfoState } from 'src/services/sysInfo/redux/types'; export interface AppStore extends Store, State {} @@ -11,5 +12,6 @@ export interface State { auth: AuthState; users: UsersState; apps: AppsState; + resources: ResourcesState; sysInfo: SysInfoState; } diff --git a/frontend/src/services/resources/hooks/index.ts b/frontend/src/services/resources/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb7960da006eedcdb09a67196ffaec07ebc97b7e --- /dev/null +++ b/frontend/src/services/resources/hooks/index.ts @@ -0,0 +1 @@ +export { useResources } from './use-resources'; diff --git a/frontend/src/services/resources/hooks/use-resources.ts b/frontend/src/services/resources/hooks/use-resources.ts new file mode 100644 index 0000000000000000000000000000000000000000..96490973e501b2bfc8c79a0cc59f8c473b891cff --- /dev/null +++ b/frontend/src/services/resources/hooks/use-resources.ts @@ -0,0 +1,17 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { fetchResources } from '../redux'; +import { getResources } from '../redux/selectors'; + +export function useResources() { + const dispatch = useDispatch(); + const resources = useSelector(getResources); + + function loadResources() { + return dispatch(fetchResources()); + } + + return { + resources, + loadResources, + }; +} diff --git a/frontend/src/services/resources/index.ts b/frontend/src/services/resources/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2f15d5e4727b05904516d0cde4f09d66d313dd7 --- /dev/null +++ b/frontend/src/services/resources/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export { reducer } from './redux'; +export { useResources } from './hooks'; diff --git a/frontend/src/services/resources/redux/actions.ts b/frontend/src/services/resources/redux/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..c38ef283a3566691fc482b383acabc21be3eef51 --- /dev/null +++ b/frontend/src/services/resources/redux/actions.ts @@ -0,0 +1,23 @@ +import { Dispatch } from 'redux'; +import { performApiCall } from 'src/services/api'; +import { transformResources } from '../transformations'; + +export enum ResourcesActionTypes { + FETCH_RESOURCES = 'resources/fetch_resources', +} + +export const fetchResources = () => async (dispatch: Dispatch<any>) => { + try { + const { data } = await performApiCall({ + path: '/resources', + method: 'GET', + }); + + dispatch({ + type: ResourcesActionTypes.FETCH_RESOURCES, + payload: transformResources(data), + }); + } catch (err) { + console.error(err); + } +}; diff --git a/frontend/src/services/resources/redux/index.ts b/frontend/src/services/resources/redux/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8f7fcabc47f606bb62be3218b6c4514ffbffb50 --- /dev/null +++ b/frontend/src/services/resources/redux/index.ts @@ -0,0 +1,4 @@ +export * from './actions'; +export { default as reducer } from './reducers'; +export { getResources } from './selectors'; +export * from './types'; diff --git a/frontend/src/services/resources/redux/reducers.ts b/frontend/src/services/resources/redux/reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..069898e3baa23dd74a54218b2109593aef4356d1 --- /dev/null +++ b/frontend/src/services/resources/redux/reducers.ts @@ -0,0 +1,19 @@ +import { ResourcesActionTypes } from './actions'; + +const initialResourcesState: any = { + resources: {}, +}; + +const resourcesReducer = (state: any = initialResourcesState, action: any) => { + switch (action.type) { + case ResourcesActionTypes.FETCH_RESOURCES: + return { + ...state, + resources: action.payload, + }; + default: + return state; + } +}; + +export default resourcesReducer; diff --git a/frontend/src/services/resources/redux/selectors.ts b/frontend/src/services/resources/redux/selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..196bb950fa8409e8e69ea7f29dbdbee6acd866d4 --- /dev/null +++ b/frontend/src/services/resources/redux/selectors.ts @@ -0,0 +1,3 @@ +import { State } from 'src/redux'; + +export const getResources = (state: State) => state.resources.resources; diff --git a/frontend/src/services/resources/redux/types.ts b/frontend/src/services/resources/redux/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ea8625048afdfece4e9a7680a8ee1f570558ece --- /dev/null +++ b/frontend/src/services/resources/redux/types.ts @@ -0,0 +1,5 @@ +import { Resources } from '../types'; + +export interface ResourcesState { + resources: Resources; +} diff --git a/frontend/src/services/resources/transformations.ts b/frontend/src/services/resources/transformations.ts new file mode 100644 index 0000000000000000000000000000000000000000..498167297f5d10268dddc7381d22aafb841ecbf0 --- /dev/null +++ b/frontend/src/services/resources/transformations.ts @@ -0,0 +1,11 @@ +import { Resources } from './types'; + +export const transformResources = (response: any): Resources => { + return { + cpu: (response.cpu / response.cpu_total) * 100, + memory_used: (response.memory_total - response.memory_available) / 1e9, + memory_total: response.memory_total / 1e9, + disk_used: (response.disk_total - response.disk_free) / 1e9, + disk_total: response.disk_total / 1e9, + }; +}; diff --git a/frontend/src/services/resources/types.ts b/frontend/src/services/resources/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce5991cf55a2ac425ae52e9894351e605d30aab6 --- /dev/null +++ b/frontend/src/services/resources/types.ts @@ -0,0 +1,7 @@ +export interface Resources { + cpu: number; + memory_used: number; + memory_total: number; + disk_used: number; + disk_total: number; +}