From 0f60436b2c3524616301a21d56286a60f7eba4ed Mon Sep 17 00:00:00 2001 From: baldo Date: Thu, 26 May 2022 13:58:01 +0200 Subject: [PATCH] Admin: Basic node listing --- frontend/src/router/index.ts | 6 ++ frontend/src/stores/nodes.ts | 29 ++++++ frontend/src/utils/Api.ts | 4 +- frontend/src/views/AdminNodesView.vue | 61 ++++++++++++ frontend/tsconfig.json | 2 + server/resources/nodeResource.ts | 8 +- server/services/monitoringService.test.ts | 8 +- server/services/monitoringService.ts | 8 +- server/types/index.ts | 44 +-------- server/types/shared.ts | 109 +++++++++++++++++++++- server/utils/resources.ts | 2 +- 11 files changed, 223 insertions(+), 58 deletions(-) create mode 100644 frontend/src/stores/nodes.ts create mode 100644 frontend/src/views/AdminNodesView.vue diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ea1ac7c..0a2f2ad 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from "vue-router"; import AdminDashboardView from "@/views/AdminDashboardView.vue"; +import AdminNodesView from "@/views/AdminNodesView.vue"; import HomeView from "@/views/HomeView.vue"; const router = createRouter({ @@ -15,6 +16,11 @@ const router = createRouter({ name: "admin", component: AdminDashboardView, }, + { + path: "/admin/nodes", + name: "admin-nodes", + component: AdminNodesView, + }, ], }); diff --git a/frontend/src/stores/nodes.ts b/frontend/src/stores/nodes.ts new file mode 100644 index 0000000..f559cdf --- /dev/null +++ b/frontend/src/stores/nodes.ts @@ -0,0 +1,29 @@ +import {defineStore} from "pinia"; +import {isEnhancedNodes, type EnhancedNode} from "@/types"; +import {internalApi} from "@/utils/Api"; + +interface NodesStoreState { + nodes: EnhancedNode[]; +} + +export const useNodesStore = defineStore({ + id: "nodes", + state(): NodesStoreState { + return { + nodes: [], + }; + }, + getters: { + getNodes(state: NodesStoreState): EnhancedNode[] { + return state.nodes; + }, + }, + actions: { + async refresh(): Promise { + this.nodes = await internalApi.get( + "nodes", + isEnhancedNodes + ); + }, + }, +}); diff --git a/frontend/src/utils/Api.ts b/frontend/src/utils/Api.ts index 282e44c..94596b8 100644 --- a/frontend/src/utils/Api.ts +++ b/frontend/src/utils/Api.ts @@ -1,3 +1,5 @@ +import type {TypeGuard} from "@/types/shared"; + class Api { private baseURL: string = import.meta.env.BASE_URL; private apiPrefix = "api/"; @@ -12,7 +14,7 @@ class Api { return this.baseURL + this.apiPrefix + path; } - async get(path: string, isT: (arg: unknown) => arg is T): Promise { + async get(path: string, isT: TypeGuard): Promise { const url = this.toURL(path); const result = await fetch(url); const json = await result.json(); diff --git a/frontend/src/views/AdminNodesView.vue b/frontend/src/views/AdminNodesView.vue new file mode 100644 index 0000000..770f195 --- /dev/null +++ b/frontend/src/views/AdminNodesView.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0c3ee8c..b6dfe88 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -7,6 +7,8 @@ "paths": { "@/*": ["./src/*"], }, + "target": "es2018", + "lib": ["es2018"], "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ }, diff --git a/server/resources/nodeResource.ts b/server/resources/nodeResource.ts index 12a2a49..d8ac067 100644 --- a/server/resources/nodeResource.ts +++ b/server/resources/nodeResource.ts @@ -10,7 +10,7 @@ import {forConstraint, forConstraints} from "../validation/validator"; import * as Resources from "../utils/resources"; import {Entity} from "../utils/resources"; import {Request, Response} from "express"; -import {Node} from "../types"; +import {EnhancedNode, Node} from "../types"; const nodeFields = ['hostname', 'key', 'email', 'nickname', 'mac', 'coords', 'monitoring']; @@ -97,7 +97,7 @@ async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any } const macs = _.map(realNodes, (node: Node): string => node.mac); const nodeStateByMac = await MonitoringService.getByMacs(macs); - const enhancedNodes: Entity[] = _.map(realNodes, (node: Node) => { + const enhancedNodes: EnhancedNode[] = _.map(realNodes, (node: Node): EnhancedNode => { const nodeState = nodeStateByMac[node.mac]; if (nodeState) { return deepExtend({}, node, { @@ -107,10 +107,10 @@ async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any } }); } - return node; + return node as EnhancedNode; }); - const filteredNodes = Resources.filter( + const filteredNodes = Resources.filter( enhancedNodes, [ 'hostname', diff --git a/server/services/monitoringService.test.ts b/server/services/monitoringService.test.ts index 1edf951..6159071 100644 --- a/server/services/monitoringService.test.ts +++ b/server/services/monitoringService.test.ts @@ -1,6 +1,6 @@ import moment from 'moment'; import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService"; -import {NodeState} from "../types"; +import {OnlineState} from "../types"; import Logger from '../logger'; import {MockLogger} from "../__mocks__/logger"; @@ -242,7 +242,7 @@ test('parseNode() should succeed parsing node without site and domain', () => { const expectedParsedNode: ParsedNode = { mac: "12:34:56:78:90:AB", importTimestamp: importTimestamp, - state: NodeState.ONLINE, + state: OnlineState.ONLINE, lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), site: '', domain: '' @@ -274,7 +274,7 @@ test('parseNode() should succeed parsing node with site and domain', () => { const expectedParsedNode: ParsedNode = { mac: "12:34:56:78:90:AB", importTimestamp: importTimestamp, - state: NodeState.ONLINE, + state: OnlineState.ONLINE, lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), site: 'test-site', domain: 'test-domain' @@ -463,7 +463,7 @@ test('parseNodesJson() should parse valid nodes', () => { const expectedParsedNode: ParsedNode = { mac: "12:34:56:78:90:AB", importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING), - state: NodeState.ONLINE, + state: OnlineState.ONLINE, lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), site: 'test-site', domain: 'test-domain' diff --git a/server/services/monitoringService.ts b/server/services/monitoringService.ts index efe86c6..c3f3184 100644 --- a/server/services/monitoringService.ts +++ b/server/services/monitoringService.ts @@ -16,7 +16,7 @@ import {normalizeMac} from "../utils/strings"; import {monitoringDisableUrl} from "../utils/urlBuilder"; import CONSTRAINTS from "../validation/constraints"; import {forConstraint} from "../validation/validator"; -import {MAC, MailType, Node, NodeId, NodeState, NodeStateData, UnixTimestampSeconds} from "../types"; +import {MAC, MailType, Node, NodeId, OnlineState, NodeStateData, UnixTimestampSeconds} from "../types"; const MONITORING_STATE_MACS_CHUNK_SIZE = 100; const NEVER_ONLINE_NODES_DELETION_CHUNK_SIZE = 20; @@ -37,7 +37,7 @@ const DELETE_OFFLINE_NODES_AFTER_DURATION: {amount: number, unit: unitOfTime.Dur export type ParsedNode = { mac: string, importTimestamp: Moment, - state: NodeState, + state: OnlineState, lastSeen: Moment, site: string, domain: string, @@ -212,7 +212,7 @@ export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode { return { mac: mac, importTimestamp: importTimestamp, - state: isOnline ? NodeState.ONLINE : NodeState.OFFLINE, + state: isOnline ? OnlineState.ONLINE : OnlineState.OFFLINE, lastSeen: lastSeen, site: site || '', domain: domain || '' @@ -520,7 +520,7 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise = (arg: unknown) => arg is T; export function isObject(arg: unknown): arg is object { return arg !== null && typeof arg === "object"; } -export function isArray(arg: unknown, isT: (arg: unknown) => arg is T): arg is Array { +export function isArray(arg: unknown, isT: TypeGuard): arg is Array { if (!Array.isArray(arg)) { return false; } @@ -22,12 +23,18 @@ export function isString(arg: unknown): arg is string { return typeof arg === "string" } +export function toIsArray(isT: TypeGuard): TypeGuard { + return (arg): arg is T[] => isArray(arg, isT); +} + +export function toIsEnum(enumDef: E): TypeGuard { + return (arg): arg is E => Object.values(enumDef).includes(arg as [keyof E]); +} + export type Version = string; -export function isVersion(arg: unknown): arg is Version { - // Should be good enough for now. - return typeof arg === "string"; -} +// Should be good enough for now. +export const isVersion = isString; export type NodeStatistics = { registered: number; @@ -229,3 +236,95 @@ export function isClientConfig(arg: unknown): arg is ClientConfig { typeof cfg.rootPath === "string" ); } + +// TODO: Token type. +export type Token = string; +export const isToken = isString; + +export type FastdKey = string; +export const isFastdKey = isString; + +export type MAC = string; +export const isMAC = isString; + +export type UnixTimestampSeconds = number; +export type UnixTimestampMilliseconds = number; + +export type MonitoringToken = string; +export enum MonitoringState { + ACTIVE = "active", + PENDING = "pending", + DISABLED = "disabled", +} + +export const isMonitoringState = toIsEnum(MonitoringState); + +export type NodeId = string; +export const isNodeId = isString; + +export interface Node { + token: Token; + nickname: string; + email: string; + hostname: string; + coords?: string; // TODO: Use object with longitude and latitude. + key?: FastdKey; + mac: MAC; + monitoring: boolean; + monitoringConfirmed: boolean; + monitoringState: MonitoringState; + modifiedAt: UnixTimestampSeconds; +} + +export function isNode(arg: unknown): arg is Node { + if (!isObject(arg)) { + return false; + } + const node = arg as Node; + // noinspection SuspiciousTypeOfGuard + return ( + isToken(node.token) && + typeof node.nickname === "string" && + typeof node.email === "string" && + typeof node.hostname === "string" && + (node.coords === undefined || typeof node.coords === "string") && + (node.key === undefined || isFastdKey(node.key)) && + isMAC(node.mac) && + typeof node.monitoring === "boolean" && + typeof node.monitoringConfirmed === "boolean" && + isMonitoringState(node.monitoringState) && + typeof node.modifiedAt === "number" + ); +} + +export enum OnlineState { + ONLINE = "ONLINE", + OFFLINE = "OFFLINE", +} +export const isOnlineState = toIsEnum(OnlineState); + +export type Site = string; +export const isSite = isString; + +export type Domain = string; +export const isDomain = isString; + +export interface EnhancedNode extends Node { + site?: Site, + domain?: Domain, + onlineState?: OnlineState, +} + +export function isEnhancedNode(arg: unknown): arg is EnhancedNode { + if (!isNode(arg)) { + return false; + } + const node = arg as EnhancedNode; + return ( + (node.site === undefined || isSite(node.site)) && + (node.domain === undefined || isDomain(node.domain)) && + (node.onlineState === undefined || isOnlineState(node.onlineState)) + ); +} + +export const isEnhancedNodes = toIsArray(isEnhancedNode); diff --git a/server/utils/resources.ts b/server/utils/resources.ts index 3a34fde..bc92fe4 100644 --- a/server/utils/resources.ts +++ b/server/utils/resources.ts @@ -152,7 +152,7 @@ export async function getValidRestParams( return restParams as RestParams; } -export function filter (entities: ArrayLike, allowedFilterFields: string[], restParams: RestParams) { +export function filter(entities: ArrayLike, allowedFilterFields: string[], restParams: RestParams): E[] { let query = restParams.q; if (query) { query = _.toLower(query.trim());