diff --git a/CHANGELOG.md b/CHANGELOG.md index 049bab93..12716910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,4 @@ Bob Management GUI changelog - Home page, backend (#18) - Node list page, backend (#19) - Home page, frontend (#22) +- Node list page, frontend (#23) diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 319af638..21fb1523 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -53,7 +53,7 @@ impl Modify for SecurityAddon { models::api::DiskStatus, models::api::DiskStatusName, models::api::DiskCount, - models::api::Node, + models::api::NodeInfo, models::api::NodeProblem, models::api::NodeStatus, models::api::NodeStatusName, diff --git a/backend/src/models/api.rs b/backend/src/models/api.rs index c6d2a17d..a2eb1a33 100644 --- a/backend/src/models/api.rs +++ b/backend/src/models/api.rs @@ -11,6 +11,8 @@ pub use crate::models::shared::{BobConnectionData, Credentials}; /// Physical disk definition #[derive(Debug, Clone, Eq, PartialEq, Serialize)] #[cfg_attr(all(feature = "swagger", debug_assertions), derive(ToSchema))] +#[serde(rename_all = "camelCase")] +#[tsync] pub struct Disk { /// Disk name pub name: String, @@ -19,7 +21,7 @@ pub struct Disk { pub path: String, /// Disk status - #[serde(flatten)] + // #[serde(flatten)] pub status: DiskStatus, #[serde(rename = "totalSpace")] @@ -52,7 +54,7 @@ pub enum DiskStatus { #[serde(rename = "good")] Good, #[serde(rename = "bad")] - Bad(Vec), + Bad { problems: Vec }, #[serde(rename = "offline")] Offline, } @@ -66,7 +68,9 @@ impl DiskStatus { / space.total_disk_space_bytes as f64) < DEFAULT_MIN_FREE_SPACE_PERCENTAGE { - Self::Bad(vec![DiskProblem::FreeSpaceRunningOut]) + Self::Bad { + problems: vec![DiskProblem::FreeSpaceRunningOut], + } } else { Self::Good } @@ -89,13 +93,15 @@ pub enum DiskStatusName { #[derive(Debug, Clone, Serialize)] #[cfg_attr(all(feature = "swagger", debug_assertions), derive(ToSchema))] -pub struct Node { +#[serde(rename_all = "camelCase")] +#[tsync] +pub struct NodeInfo { pub name: String, pub hostname: String, pub vdisks: Vec, - #[serde(flatten)] + // #[serde(flatten)] pub status: NodeStatus, #[serde(rename = "rps")] @@ -158,10 +164,8 @@ impl NodeProblem { if node_metrics[RawMetricEntry::HardwareBobCpuLoad].value >= max_cpu { res.push(Self::HighCPULoad); } - if (1. - - (node_metrics[RawMetricEntry::HardwareTotalSpace].value - - node_metrics[RawMetricEntry::HardwareFreeSpace].value) as f64 - / node_metrics[RawMetricEntry::HardwareTotalSpace].value as f64) + if (node_metrics[RawMetricEntry::HardwareFreeSpace].value as f64 + / node_metrics[RawMetricEntry::HardwareTotalSpace].value as f64) < min_free_space_perc { res.push(Self::FreeSpaceRunningOut); @@ -189,7 +193,7 @@ pub enum NodeStatus { #[serde(rename = "good")] Good, #[serde(rename = "bad")] - Bad(Vec), + Bad { problems: Vec }, #[serde(rename = "offline")] Offline, } @@ -200,7 +204,7 @@ impl NodeStatus { if problems.is_empty() { Self::Good } else { - Self::Bad(problems) + Self::Bad { problems } } } } @@ -235,6 +239,8 @@ pub enum NodeStatusName { /// [`VDisk`]'s replicas #[derive(Debug, Clone, Eq, PartialEq, Serialize)] #[cfg_attr(all(feature = "swagger", debug_assertions), derive(ToSchema))] +#[serde(rename_all = "camelCase")] +#[tsync] pub struct Replica { pub node: String, @@ -242,7 +248,7 @@ pub struct Replica { pub path: String, - #[serde(flatten)] + // #[serde(flatten)] pub status: ReplicaStatus, } @@ -270,7 +276,7 @@ pub enum ReplicaStatus { #[serde(rename = "good")] Good, #[serde(rename = "offline")] - Offline(Vec), + Offline { problems: Vec }, } /// Disk space information in bytes @@ -327,7 +333,7 @@ impl Add for SpaceInfo { pub struct VDisk { pub id: u64, - #[serde(flatten)] + // #[serde(flatten)] pub status: VDiskStatus, #[serde(rename = "partitionCount")] @@ -593,7 +599,11 @@ impl From for TypedMetrics { let mut value = value.metrics; for key in RawMetricEntry::iter() { let value = value - .remove(&serde_json::to_string(&key).expect("infallible")) + .remove( + serde_json::to_string(&key) + .expect("infallible") + .trim_matches('"'), + ) .unwrap_or_default(); map.insert(key, value); } diff --git a/backend/src/services/api.rs b/backend/src/services/api.rs index 091d9057..fee57f4d 100644 --- a/backend/src/services/api.rs +++ b/backend/src/services/api.rs @@ -62,7 +62,7 @@ pub async fn get_disks_count(Extension(client): Extension) -> Jso match DiskStatus::from_space_info(&space, &disk.name) { DiskStatus::Good => count[DiskStatusName::Good] += 1, DiskStatus::Offline => count[DiskStatusName::Offline] += 1, - DiskStatus::Bad(_) => count[DiskStatusName::Bad] += 1, + DiskStatus::Bad { .. } => count[DiskStatusName::Bad] += 1, } }); count[DiskStatusName::Offline] = (disks.len() - active) as u64; @@ -308,7 +308,7 @@ pub async fn get_vdisk_info( pub async fn get_node_info( Extension(client): Extension, Path(node_name): Path, -) -> AxumResult> { +) -> AxumResult> { tracing::info!("get /nodes/{node_name} : {client:?}"); let handle = Arc::new( client @@ -317,6 +317,10 @@ pub async fn get_node_info( .ok_or(StatusCode::NOT_FOUND)?, ); + let nodes = { + let handle = client.api_main().clone(); + tokio::spawn(async move { fetch_nodes(&handle).await }) + }; let status = { let handle = handle.clone(); tokio::spawn(async move { handle.get_status().await }) @@ -330,9 +334,32 @@ pub async fn get_node_info( tokio::spawn(async move { handle.clone().get_space_info().await }) }; + let mut node = NodeInfo { + name: node_name.clone(), + hostname: String::new(), + vdisks: vec![], + status: NodeStatus::Offline, + rps: None, + alien_count: None, + corrupted_count: None, + space: None, + }; + let Ok(Ok(GetStatusResponse::AJSONWithNodeInfo(status))) = status.await else { - return Err(StatusCode::NOT_FOUND.into()); + // Fallback to general info + node.hostname = if let Ok(Ok(nodes)) = nodes.await { + nodes + .iter() + .find(|node| node.name == node_name) + .ok_or(StatusCode::NOT_FOUND)? + .address + .clone() + } else { + String::new() + }; + return Ok(Json(node)); }; + node.hostname = status.address; let mut vdisks: FuturesUnordered<_> = status .vdisks @@ -345,16 +372,6 @@ pub async fn get_node_info( }) .collect(); - let mut node = Node { - name: status.name.clone(), - hostname: status.address.clone(), - vdisks: vec![], - status: NodeStatus::Offline, - rps: None, - alien_count: None, - corrupted_count: None, - space: None, - }; if let ( Ok(Ok(GetMetricsResponse::Metrics(metric))), Ok(Ok(GetSpaceInfoResponse::SpaceInfo(space))), diff --git a/backend/src/services/methods.rs b/backend/src/services/methods.rs index 0ca0abbf..922dbe60 100644 --- a/backend/src/services/methods.rs +++ b/backend/src/services/methods.rs @@ -216,7 +216,9 @@ pub async fn get_vdisk_by_id(client: &HttpBobClient, vdisk_id: u64) -> AxumResul node: replica.node, disk: replica.disk, path: replica.path, - status: ReplicaStatus::Offline(vec![ReplicaProblem::NodeUnavailable]), + status: ReplicaStatus::Offline { + problems: vec![ReplicaProblem::NodeUnavailable], + }, }, ) }) @@ -237,8 +239,8 @@ pub async fn get_vdisk_by_id(client: &HttpBobClient, vdisk_id: u64) -> AxumResul status: disk .is_active .then_some(ReplicaStatus::Good) - .unwrap_or_else(|| { - ReplicaStatus::Offline(vec![ReplicaProblem::DiskUnavailable]) + .unwrap_or_else(|| ReplicaStatus::Offline { + problems: vec![ReplicaProblem::DiskUnavailable], }), }, ); @@ -251,7 +253,7 @@ pub async fn get_vdisk_by_id(client: &HttpBobClient, vdisk_id: u64) -> AxumResul let replicas: Vec<_> = replicas.into_values().collect(); let count = replicas .iter() - .filter(|replica| matches!(replica.status, ReplicaStatus::Offline(_))) + .filter(|replica| matches!(replica.status, ReplicaStatus::Offline { .. })) .count(); let status = if count == 0 { VDiskStatus::Good diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 712b5cd4..998dceee 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -9,7 +9,7 @@ readme.workspace = true repository.workspace = true [lib] -name = "frontend" +name = "bindings" path = "/dev/null" crate-type = ["lib"] diff --git a/frontend/bindings.rs b/frontend/bindings.rs new file mode 100644 index 00000000..f06f5769 --- /dev/null +++ b/frontend/bindings.rs @@ -0,0 +1,43 @@ +// Some structs that must be redefined for transpiling without changing actual types on backend + +#[tsync] +pub type Hostname = String; + +// Same as in `backend/src/connector/dto.rs` +// Imported for bindings generation (tsync doesn't respect serde(rename)) + +/// BOB's Node interface +#[tsync] +pub struct DTONode { + pub name: String, + + pub address: String, + + pub vdisks: Option>, +} + +/// BOB's Node Configuration interface +#[tsync] +pub struct DTONodeConfiguration { + pub blob_file_name_prefix: Option, + + pub root_dir_name: Option, +} + +/// BOB's VDisk interface +#[tsync] +pub struct DTOVDisk { + pub id: i32, + + pub replicas: Option>, +} + +/// BOB's Replica interface +#[tsync] +pub struct DTOReplica { + pub node: String, + + pub disk: String, + + pub path: String, +} diff --git a/frontend/build.rs b/frontend/build.rs index eb102e52..2320761b 100644 --- a/frontend/build.rs +++ b/frontend/build.rs @@ -62,7 +62,7 @@ pub fn build_types() { inputs[0].push("backend"); inputs[1].push("frontend"); - inputs[1].push("frontend.rs"); + inputs[1].push("bindings.rs"); output.push("src/types/rust.d.ts"); tsync::generate_typescript_defs(inputs, output, false); diff --git a/frontend/frontend.rs b/frontend/frontend.rs deleted file mode 100644 index e167e3fc..00000000 --- a/frontend/frontend.rs +++ /dev/null @@ -1,4 +0,0 @@ -// Some structs that must be redefined for transpiling without changing actual types on backend - -#[tsync] -pub type Hostname = String; diff --git a/frontend/src/components/nodeList/nodeList.module.css b/frontend/src/components/nodeList/nodeList.module.css new file mode 100644 index 00000000..5ca0c0f5 --- /dev/null +++ b/frontend/src/components/nodeList/nodeList.module.css @@ -0,0 +1,27 @@ +.greendot { + height: 16px; + width: 16px; + background-color: #34b663; + border-radius: 50%; + display: inline-block; +} + +.reddot { + height: 16px; + width: 16px; + background-color: #c3234b; + border-radius: 50%; + display: inline-block; +} + +.graydot { + height: 16px; + width: 16px; + background-color: #7b817e; + border-radius: 50%; + display: inline-block; +} + +.totalspace { + color: #7b817e; +} diff --git a/frontend/src/components/nodeList/nodeList.tsx b/frontend/src/components/nodeList/nodeList.tsx new file mode 100644 index 00000000..dee5da8f --- /dev/null +++ b/frontend/src/components/nodeList/nodeList.tsx @@ -0,0 +1,123 @@ +import { Context } from '@appTypes/context.ts'; +import defaultTheme from '@layouts/DefaultTheme.ts'; +import { Box, ThemeProvider } from '@mui/system'; +import { useStore } from '@nanostores/react'; +import axios from 'axios'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import NodeTable from '../nodeTable/nodeTable.tsx'; + +const stubNode: NodeInfo = { + name: 'loading...', + hostname: 'loading...', + vdisks: [], + status: { + status: 'Offline', + }, + rps: { + map: { + put: 0, + get: 0, + exist: 0, + delete: 0, + }, + }, + alienCount: 0, + corruptedCount: 0, + space: { + total_disk: 0, + free_disk: 0, + used_disk: 0, + occupied_disk: 0, + }, +}; + +const NodeListPage = () => { + const [nodes, setNodes] = useState([]); + const [nodeList, setNodeList] = useState([]); + const context = useStore(Context); + + const fetchNodeList = useMemo( + () => async () => { + try { + const [res] = await Promise.all([axios.get('/api/v1/nodes/list')]); + setNodes( + res.data + .map((dtoNode: DTONode) => { + return { + ...stubNode, + name: dtoNode.name, + hostname: dtoNode.address, + } as NodeInfo; + }) + .sort((a, b) => (a.name < b.name ? -1 : 1)), + ); + setNodeList(res.data); + } catch (err) { + console.log(err); + } + }, + [], + ); + + const fetchNode = useCallback( + (nodeName: string) => async () => { + try { + const [res] = await Promise.all([axios.get('/api/v1/nodes/' + nodeName)]); + return res.data; + } catch (err) { + console.log(err); + } + }, + [], + ); + + useEffect(() => { + const fetchNodes = async () => { + const res = ( + await Promise.all( + nodeList.map(async (node) => { + return fetchNode(node.name)() + .catch(console.error) + .then((resultNode) => resultNode); + }), + ) + ).filter((node): node is NodeInfo => { + return typeof node !== undefined; + }); + setNodes(res.concat(nodes.filter((item) => !res.find((n) => (n?.name || '') == item.name)))); + }; + const interval = setInterval(() => { + fetchNodes(); + }, context.refreshTime * 1000); + + return () => clearInterval(interval); + }, [fetchNode, context.enabled, context.refreshTime, nodeList, nodes]); + + useEffect(() => { + fetchNodeList(); + }, [fetchNodeList]); + + return ( + + + + + + ); +}; + +export default NodeListPage; diff --git a/frontend/src/components/nodeTable/nodeTable.module.css b/frontend/src/components/nodeTable/nodeTable.module.css new file mode 100644 index 00000000..7217ac99 --- /dev/null +++ b/frontend/src/components/nodeTable/nodeTable.module.css @@ -0,0 +1,31 @@ +.greendot { + height: 16px; + width: 16px; + background-color: #34b663; + border-radius: 50%; + display: inline-block; +} + +.reddot { + height: 16px; + width: 16px; + background-color: #c3234b; + border-radius: 50%; + display: inline-block; +} + +.graydot { + height: 16px; + width: 16px; + background-color: #7b817e; + border-radius: 50%; + display: inline-block; +} + +.totalspace { + color: #7b817e; +} + +.greyHeader { + background-color: #35373c; +} diff --git a/frontend/src/components/nodeTable/nodeTable.tsx b/frontend/src/components/nodeTable/nodeTable.tsx new file mode 100644 index 00000000..c700b49b --- /dev/null +++ b/frontend/src/components/nodeTable/nodeTable.tsx @@ -0,0 +1,183 @@ +import { formatBytes } from '@appTypes/common.ts'; +import { Link } from '@mui/material'; +import { Box } from '@mui/system'; +import type { GridColDef, GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { DataGrid, GridToolbar } from '@mui/x-data-grid'; +import axios from 'axios'; +import React from 'react'; + +import style from './nodeTable.module.css'; + +axios.defaults.withCredentials = true; + +const DotMap: Record = { + good: style.greendot, + bad: style.graydot, + offline: style.reddot, +}; + +const defaultRps: RPS = { + map: { + put: 0, + exist: 0, + get: 0, + delete: 0, + }, +}; + +const defaultSpace: SpaceInfo = { + total_disk: 0, + used_disk: 0, + occupied_disk: 0, + free_disk: 0, +}; + +const columns: GridColDef[] = [ + { + field: 'nodename', + headerName: 'Node Name', + flex: 1, + width: 200, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + sortable: false, + renderCell: (params: GridRenderCellParams) => { + return ( + + {params.value} + + ); + }, + }, + { + field: 'hostname', + headerName: 'Hostname', + flex: 1, + width: 200, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + width: 200, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + renderCell: (params: GridRenderCellParams) => { + const status = params.value || 'offline'; + return ( + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + }, + { + field: 'space', + headerName: 'Occupied Space', + flex: 1, + width: 150, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + renderCell: (params: GridRenderCellParams) => { + const space = params.value || defaultSpace; + return ( +
+ {formatBytes(space.used_disk)} /{' '} + {formatBytes(space.total_disk)} +
+ ); + }, + }, + { + field: 'rps', + headerName: 'RPS', + flex: 1, + width: 150, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + renderCell: (params: GridRenderCellParams) => { + const rps = (params.value || defaultRps).map; + return
{rps.get + rps.put + rps.exist + rps.delete}
; + }, + }, + { + field: 'aliens', + headerName: 'Aliens', + flex: 1, + width: 200, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + }, + { + field: 'corruptedBlobs', + headerName: 'Corrupted BLOBs', + flex: 1, + width: 200, + align: 'center', + headerAlign: 'center', + headerClassName: style.greyHeader, + }, +]; + +const NodeTable = ({ nodes }: { nodes: NodeInfo[] }) => { + const data = nodes.sort() + ? nodes.map((node, i) => { + return { + id: i, + nodename: node.name, + hostname: node.hostname, + status: node.status.status.toLowerCase(), + space: node.space, + rps: node.rps, + aliens: node.alienCount || 0, + corruptedBlobs: node.corruptedCount || 0, + } as NodeTableCols; + }) + : []; + return ( + searchInput.split(',').map((value) => value.trim()), + }, + }, + }} + /> + ); +}; + +export default NodeTable; diff --git a/frontend/src/pages/nodelist/index.astro b/frontend/src/pages/nodelist/index.astro index 1ff9feb3..54e101a1 100644 --- a/frontend/src/pages/nodelist/index.astro +++ b/frontend/src/pages/nodelist/index.astro @@ -1,5 +1,10 @@ --- import Layout from '@layouts/Layout.astro'; +import NodeListPage from '@components/nodeList/nodeList.tsx'; --- - + +
+ +
+
diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index 01fc75b9..47d38c27 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -31,3 +31,29 @@ export function getCookie(field: string) { export function eraseCookie(name: string) { document.cookie = name + '=; Max-Age=-99999999;'; } + +export function formatBytes(bytes: number, decimals = 0) { + if (!+bytes) return '0B'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}`; +} + +export function proxiedPropertiesOf() { + return new Proxy( + {}, + { + get: (_, prop) => prop, + set: () => { + throw Error('Set not supported'); + }, + }, + ) as { + [P in keyof TObj]?: P; + }; +} diff --git a/frontend/src/types/data.d.ts b/frontend/src/types/data.d.ts index 439ab1e8..07e37272 100644 --- a/frontend/src/types/data.d.ts +++ b/frontend/src/types/data.d.ts @@ -11,3 +11,14 @@ interface DashboardState { rpsBreakdownList: RPSList; dataLoaded: boolean; } + +interface NodeTableCols { + id: number; + nodename: string; + hostname: string; + status: NodeStatusName; + space?: SpaceInfo; + rps?: RPS; + aliens?: number; + corruptedBlobs?: number; +} diff --git a/frontend/src/types/rust.d.ts b/frontend/src/types/rust.d.ts index 1b4483d0..18211e87 100644 --- a/frontend/src/types/rust.d.ts +++ b/frontend/src/types/rust.d.ts @@ -1,5 +1,18 @@ /* This file is generated and managed by tsync */ +/** Physical disk definition */ +interface Disk { + /** Disk name */ + name: string; + /** Disk path */ + path: string; + /** Disk status */ + status: DiskStatus; + totalSpace: number; + usedSpace: number; + iops: number; +} + /** Defines kind of problem on disk */ type DiskProblem = | "FreeSpaceRunningOut"; @@ -12,11 +25,16 @@ type DiskProblem = */ type DiskStatus = | DiskStatus__Good + | DiskStatus__Bad | DiskStatus__Offline; type DiskStatus__Good = { status: "Good"; }; +type DiskStatus__Bad = { + status: "Bad"; + problems: Array; +}; type DiskStatus__Offline = { status: "Offline"; }; @@ -25,6 +43,17 @@ type DiskStatus__Offline = { type DiskStatusName = | "good" | "bad" | "offline"; +interface NodeInfo { + name: string; + hostname: string; + vdisks: Array; + status: NodeStatus; + rps?: RPS; + alienCount?: number; + corruptedCount?: number; + space?: SpaceInfo; +} + /** Defines kind of problem on Node */ type NodeProblem = | "AliensExists" | "CorruptedExists" | "FreeSpaceRunningOut" | "VirtualMemLargerThanRAM" | "HighCPULoad"; @@ -38,11 +67,16 @@ type NodeProblem = */ type NodeStatus = | NodeStatus__Good + | NodeStatus__Bad | NodeStatus__Offline; type NodeStatus__Good = { status: "Good"; }; +type NodeStatus__Bad = { + status: "Bad"; + problems: Array; +}; type NodeStatus__Offline = { status: "Offline"; }; @@ -51,6 +85,14 @@ type NodeStatus__Offline = { type NodeStatusName = | "good" | "bad" | "offline"; +/** [`VDisk`]'s replicas */ +interface Replica { + node: string; + disk: string; + path: string; + status: ReplicaStatus; +} + /** Reasons why Replica is offline */ type ReplicaProblem = | "NodeUnavailable" | "DiskUnavailable"; @@ -63,11 +105,16 @@ type ReplicaProblem = * Content - List of problems on replica. 'null' if status != 'offline' */ type ReplicaStatus = - | ReplicaStatus__Good; + | ReplicaStatus__Good + | ReplicaStatus__Offline; type ReplicaStatus__Good = { status: "Good"; }; +type ReplicaStatus__Offline = { + status: "Offline"; + problems: Array; +}; /** Disk space information in bytes */ interface SpaceInfo { @@ -126,3 +173,29 @@ interface Credentials { } type Hostname = string + +/** BOB's Node interface */ +interface DTONode { + name: string; + address: string; + vdisks?: Array; +} + +/** BOB's Node Configuration interface */ +interface DTONodeConfiguration { + blob_file_name_prefix?: string; + root_dir_name?: string; +} + +/** BOB's VDisk interface */ +interface DTOVDisk { + id: number; + replicas?: Array; +} + +/** BOB's Replica interface */ +interface DTOReplica { + node: string; + disk: string; + path: string; +}