Admin: Basic node listing

This commit is contained in:
baldo 2022-05-26 13:58:01 +02:00
parent c9ac65eaad
commit 0f60436b2c
11 changed files with 223 additions and 58 deletions

View file

@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import AdminDashboardView from "@/views/AdminDashboardView.vue"; import AdminDashboardView from "@/views/AdminDashboardView.vue";
import AdminNodesView from "@/views/AdminNodesView.vue";
import HomeView from "@/views/HomeView.vue"; import HomeView from "@/views/HomeView.vue";
const router = createRouter({ const router = createRouter({
@ -15,6 +16,11 @@ const router = createRouter({
name: "admin", name: "admin",
component: AdminDashboardView, component: AdminDashboardView,
}, },
{
path: "/admin/nodes",
name: "admin-nodes",
component: AdminNodesView,
},
], ],
}); });

View file

@ -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<void> {
this.nodes = await internalApi.get<EnhancedNode[]>(
"nodes",
isEnhancedNodes
);
},
},
});

View file

@ -1,3 +1,5 @@
import type {TypeGuard} from "@/types/shared";
class Api { class Api {
private baseURL: string = import.meta.env.BASE_URL; private baseURL: string = import.meta.env.BASE_URL;
private apiPrefix = "api/"; private apiPrefix = "api/";
@ -12,7 +14,7 @@ class Api {
return this.baseURL + this.apiPrefix + path; return this.baseURL + this.apiPrefix + path;
} }
async get<T>(path: string, isT: (arg: unknown) => arg is T): Promise<T> { async get<T>(path: string, isT: TypeGuard<T>): Promise<T> {
const url = this.toURL(path); const url = this.toURL(path);
const result = await fetch(url); const result = await fetch(url);
const json = await result.json(); const json = await result.json();

View file

@ -0,0 +1,61 @@
<script setup lang="ts">
import {useNodesStore} from "@/stores/nodes";
import {ref} from "vue";
import type {MAC} from "@/types";
type NodeRedactField = "nickname" | "email" | "token";
type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
type NodesRedactFieldsMap = Partial<Record<MAC, NodeRedactFieldsMap>>;
const nodes = useNodesStore();
const page = ref(0);
function refresh(): void {
nodes.refresh();
}
refresh();
</script>
<template>
<main>
<h2>Knoten</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Besitzer*in</th>
<th>E-Mail</th>
<th>Token</th>
<th>MAC</th>
<th>VPN</th>
<th>Site</th>
<th>Domäne</th>
<th>GPS</th>
<th>Status</th>
<th>Monitoring</th>
</tr>
</thead>
<tbody>
<tr v-for="node in nodes.getNodes">
<td>{{node.hostname}}</td>
<td>{{node.nickname}}</td><!-- TODO: Redact values -->
<td>{{node.email}}</td><!-- TODO: Redact values -->
<td>{{node.token}}</td><!-- TODO: Redact values -->
<td>{{node.mac}}</td>
<td>{{node.key}}</td><!-- TODO: Icon if set -->
<td>{{node.site}}</td>
<td>{{node.domain}}</td>
<td>{{node.coords}}</td><!-- TODO: Icon with link if set -->
<td>{{node.onlineState}}</td>
<td>{{node.monitoring}}</td><!-- TODO: Icon regarding state -->
</tr>
</tbody>
</table>
</main>
</template>
<style lang="scss" scoped>
</style>

View file

@ -7,6 +7,8 @@
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
}, },
"target": "es2018",
"lib": ["es2018"],
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
}, },

View file

@ -10,7 +10,7 @@ import {forConstraint, forConstraints} from "../validation/validator";
import * as Resources from "../utils/resources"; import * as Resources from "../utils/resources";
import {Entity} from "../utils/resources"; import {Entity} from "../utils/resources";
import {Request, Response} from "express"; import {Request, Response} from "express";
import {Node} from "../types"; import {EnhancedNode, Node} from "../types";
const nodeFields = ['hostname', 'key', 'email', 'nickname', 'mac', 'coords', 'monitoring']; 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 macs = _.map(realNodes, (node: Node): string => node.mac);
const nodeStateByMac = await MonitoringService.getByMacs(macs); 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]; const nodeState = nodeStateByMac[node.mac];
if (nodeState) { if (nodeState) {
return deepExtend({}, node, { 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<EnhancedNode>(
enhancedNodes, enhancedNodes,
[ [
'hostname', 'hostname',

View file

@ -1,6 +1,6 @@
import moment from 'moment'; import moment from 'moment';
import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService"; import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService";
import {NodeState} from "../types"; import {OnlineState} from "../types";
import Logger from '../logger'; import Logger from '../logger';
import {MockLogger} from "../__mocks__/logger"; import {MockLogger} from "../__mocks__/logger";
@ -242,7 +242,7 @@ test('parseNode() should succeed parsing node without site and domain', () => {
const expectedParsedNode: ParsedNode = { const expectedParsedNode: ParsedNode = {
mac: "12:34:56:78:90:AB", mac: "12:34:56:78:90:AB",
importTimestamp: importTimestamp, importTimestamp: importTimestamp,
state: NodeState.ONLINE, state: OnlineState.ONLINE,
lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING),
site: '<unknown-site>', site: '<unknown-site>',
domain: '<unknown-domain>' domain: '<unknown-domain>'
@ -274,7 +274,7 @@ test('parseNode() should succeed parsing node with site and domain', () => {
const expectedParsedNode: ParsedNode = { const expectedParsedNode: ParsedNode = {
mac: "12:34:56:78:90:AB", mac: "12:34:56:78:90:AB",
importTimestamp: importTimestamp, importTimestamp: importTimestamp,
state: NodeState.ONLINE, state: OnlineState.ONLINE,
lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING),
site: 'test-site', site: 'test-site',
domain: 'test-domain' domain: 'test-domain'
@ -463,7 +463,7 @@ test('parseNodesJson() should parse valid nodes', () => {
const expectedParsedNode: ParsedNode = { const expectedParsedNode: ParsedNode = {
mac: "12:34:56:78:90:AB", mac: "12:34:56:78:90:AB",
importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING), importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING),
state: NodeState.ONLINE, state: OnlineState.ONLINE,
lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING),
site: 'test-site', site: 'test-site',
domain: 'test-domain' domain: 'test-domain'

View file

@ -16,7 +16,7 @@ import {normalizeMac} from "../utils/strings";
import {monitoringDisableUrl} from "../utils/urlBuilder"; import {monitoringDisableUrl} from "../utils/urlBuilder";
import CONSTRAINTS from "../validation/constraints"; import CONSTRAINTS from "../validation/constraints";
import {forConstraint} from "../validation/validator"; 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 MONITORING_STATE_MACS_CHUNK_SIZE = 100;
const NEVER_ONLINE_NODES_DELETION_CHUNK_SIZE = 20; 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 = { export type ParsedNode = {
mac: string, mac: string,
importTimestamp: Moment, importTimestamp: Moment,
state: NodeState, state: OnlineState,
lastSeen: Moment, lastSeen: Moment,
site: string, site: string,
domain: string, domain: string,
@ -212,7 +212,7 @@ export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode {
return { return {
mac: mac, mac: mac,
importTimestamp: importTimestamp, importTimestamp: importTimestamp,
state: isOnline ? NodeState.ONLINE : NodeState.OFFLINE, state: isOnline ? OnlineState.ONLINE : OnlineState.OFFLINE,
lastSeen: lastSeen, lastSeen: lastSeen,
site: site || '<unknown-site>', site: site || '<unknown-site>',
domain: domain || '<unknown-domain>' domain: domain || '<unknown-domain>'
@ -520,7 +520,7 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise<RetrieveN
'SET state = ?, modified_at = ?' + 'SET state = ?, modified_at = ?' +
'WHERE import_timestamp < ?', 'WHERE import_timestamp < ?',
[ [
NodeState.OFFLINE, moment().unix(), OnlineState.OFFLINE, moment().unix(),
minTimestamp.unix() minTimestamp.unix()
] ]
); );

View file

@ -1,49 +1,15 @@
import {Domain, MonitoringToken, OnlineState, Site} from "./shared";
export * from "./config"; export * from "./config";
export * from "./logger"; export * from "./logger";
export * from "./shared"; export * from "./shared";
// TODO: Token type.
export type Token = string;
export type FastdKey = string;
export type MAC = string;
export type UnixTimestampSeconds = number;
export type UnixTimestampMilliseconds = number;
export type MonitoringToken = string;
export enum MonitoringState {
ACTIVE = "active",
PENDING = "pending",
DISABLED = "disabled",
}
export type NodeId = string;
export enum NodeState {
ONLINE = "ONLINE",
OFFLINE = "OFFLINE",
}
export type NodeStateData = { export type NodeStateData = {
site: string, site: Site,
domain: string, domain: Domain,
state: NodeState, state: OnlineState,
} }
export type 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;
};
// TODO: Complete interface / class declaration. // TODO: Complete interface / class declaration.
export type NodeSecrets = { export type NodeSecrets = {
monitoringToken?: MonitoringToken, monitoringToken?: MonitoringToken,

View file

@ -1,12 +1,13 @@
import {ArrayField, Field, RawJsonField} from "sparkson"; import {ArrayField, Field, RawJsonField} from "sparkson";
// Types shared with the client. // Types shared with the client.
export type TypeGuard<T> = (arg: unknown) => arg is T;
export function isObject(arg: unknown): arg is object { export function isObject(arg: unknown): arg is object {
return arg !== null && typeof arg === "object"; return arg !== null && typeof arg === "object";
} }
export function isArray<T>(arg: unknown, isT: (arg: unknown) => arg is T): arg is Array<T> { export function isArray<T>(arg: unknown, isT: TypeGuard<T>): arg is Array<T> {
if (!Array.isArray(arg)) { if (!Array.isArray(arg)) {
return false; return false;
} }
@ -22,12 +23,18 @@ export function isString(arg: unknown): arg is string {
return typeof arg === "string" return typeof arg === "string"
} }
export function toIsArray<T>(isT: TypeGuard<T>): TypeGuard<T[]> {
return (arg): arg is T[] => isArray(arg, isT);
}
export function toIsEnum<E>(enumDef: E): TypeGuard<E> {
return (arg): arg is E => Object.values(enumDef).includes(arg as [keyof E]);
}
export type Version = string; export type Version = string;
export function isVersion(arg: unknown): arg is Version { // Should be good enough for now.
// Should be good enough for now. export const isVersion = isString;
return typeof arg === "string";
}
export type NodeStatistics = { export type NodeStatistics = {
registered: number; registered: number;
@ -229,3 +236,95 @@ export function isClientConfig(arg: unknown): arg is ClientConfig {
typeof cfg.rootPath === "string" 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);

View file

@ -152,7 +152,7 @@ export async function getValidRestParams(
return restParams as RestParams; return restParams as RestParams;
} }
export function filter (entities: ArrayLike<Entity>, allowedFilterFields: string[], restParams: RestParams) { export function filter<E>(entities: ArrayLike<E>, allowedFilterFields: string[], restParams: RestParams): E[] {
let query = restParams.q; let query = restParams.q;
if (query) { if (query) {
query = _.toLower(query.trim()); query = _.toLower(query.trim());