Initial setup for new frontend.

This commit is contained in:
baldo 2022-02-22 15:39:39 +01:00
parent 7671bfd4d3
commit 59f7897d8e
33 changed files with 2594 additions and 12 deletions

15
frontend/.eslintrc.cjs Normal file
View file

@ -0,0 +1,15 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier",
],
env: {
"vue/setup-compiler-macros": true,
},
};

28
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

52
frontend/README.md Normal file
View file

@ -0,0 +1,52 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
yarn install
```
### Compile and Hot-Reload for Development
```sh
yarn run dev
```
### Type-Check, Compile and Minify for Production
```sh
yarn run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
yarn run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
yarn run lint
```

1
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
frontend/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

36
frontend/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "ffffng-frontend",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --port 5050",
"test:unit": "vitest --environment jsdom",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"pinia": "^2.0.14",
"vue": "^3.2.34",
"vue-router": "^4.0.15"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.1.3",
"@types/jsdom": "^16.2.14",
"@types/node": "^17.0.34",
"@vitejs/plugin-vue": "^2.3.3",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0",
"@vue/test-utils": "^2.0.0-rc.21",
"@vue/tsconfig": "^0.1.3",
"eslint": "^8.15.0",
"eslint-plugin-vue": "^9.0.1",
"jsdom": "^19.0.0",
"prettier": "^2.6.2",
"sass": "^1.51.0",
"typescript": "~4.6.4",
"vite": "^2.9.9",
"vitest": "^0.12.6",
"vue-tsc": "^0.34.15"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

197
frontend/public/icon.svg Normal file
View file

@ -0,0 +1,197 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="341.21152"
height="341.21152"
id="svg3447"
version="1.1"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
sodipodi:docname="icon.svg"
inkscape:export-filename="/home/baldo/projects/ffhh/ffffng/frontend/public/apple-touch-icon.png"
inkscape:export-xdpi="50.643074"
inkscape:export-ydpi="50.643074"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs3449">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4434">
<rect
transform="rotate(45)"
ry="1.8153608"
rx="1.2299931"
y="8491.9297"
x="1914.3979"
height="261.76361"
width="261.76361"
id="rect4436"
style="opacity:0.576613;fill:#ff0000;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4590">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4592"
d="m -4623.8797,7360.2562 -32.3968,-15.9515 21.309,-23.1111 z m -53.9986,-39.0626 21.3091,23.1067 -32.3947,15.9559 z"
style="fill:#ff0000;fill-opacity:1;fill-rule:evenodd;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4594">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4596"
d="m -4611.873,7366.1514 -44.3484,-21.8363 29.1702,-31.6371 z m -73.9194,-53.4734 29.1702,31.6312 -44.3455,21.8422 z"
style="fill:#ff0000;fill-opacity:1;fill-rule:evenodd;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4598">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4600"
d="m -4588.8535,7377.4531 -67.2619,-33.1184 44.2416,-47.9831 z m -112.1113,-81.1015 44.2416,47.9741 -67.2575,33.1274 z"
style="fill:#ff0000;fill-opacity:1;fill-rule:evenodd;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4434-6">
<rect
transform="rotate(45)"
ry="1.8153608"
rx="1.2299931"
y="8491.9297"
x="1914.3979"
height="261.76361"
width="261.76361"
id="rect4436-4"
style="opacity:0.576613;fill:#ff0000;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4590-9">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4592-5"
d="m -4623.8797,7360.2562 -32.3968,-15.9515 21.309,-23.1111 z m -53.9986,-39.0626 21.3091,23.1067 -32.3947,15.9559 z"
style="fill:#ff0000;fill-opacity:1;fill-rule:evenodd;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4594-4">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4596-2"
d="m -4611.873,7366.1514 -44.3484,-21.8363 29.1702,-31.6371 z m -73.9194,-53.4734 29.1702,31.6312 -44.3455,21.8422 z"
style="fill:#ff0000;fill-opacity:1;fill-rule:evenodd;stroke:none" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4598-4">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4600-8"
d="m -4588.8535,7377.4531 -67.2619,-33.1184 44.2416,-47.9831 z m -112.1113,-81.1015 44.2416,47.9741 -67.2575,33.1274 z"
style="fill:#ff0000;fill-opacity:1;fill-rule:evenodd;stroke:none" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1.8529412"
inkscape:cx="267.95238"
inkscape:cy="168.65079"
inkscape:document-units="px"
inkscape:current-layer="g3580"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="2558"
inkscape:window-height="1408"
inkscape:window-x="2560"
inkscape:window-y="30"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="true" />
<metadata
id="metadata3452">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-60.513146,-24.984767)">
<g
id="g3580"
transform="translate(2.688147,-2.688134)">
<g
id="path4355">
<path
style="color:#000000;fill:#333333;stroke-width:22.2988;stroke-linecap:square;stroke-linejoin:round;-inkscape-stroke:none"
d="m 57.824999,198.27868 c 0,94.22296 76.382801,170.60575 170.605761,170.60575 50.69154,0 96.21945,-22.10819 127.46763,-57.20847 26.8344,-30.1424 43.13814,-69.86587 43.13814,-113.39728 0,-94.22297 -76.3828,-170.605779 -170.60577,-170.605779 -94.22296,0 -170.605761,76.382809 -170.605761,170.605779 z"
id="path1157" />
<path
style="color:#000000;fill:#e5287a;stroke-linecap:square;stroke-linejoin:round;-inkscape-stroke:none"
d="m 228.43164,30.966797 c -92.28211,0 -167.312499,75.030383 -167.312499,167.312503 0,92.28211 75.030389,167.31054 167.312499,167.31054 49.64738,0 94.37002,-21.69128 125.00391,-56.10156 26.30688,-29.54986 42.30664,-68.57425 42.30664,-111.20898 0,-92.28212 -75.02843,-167.312503 -167.31055,-167.312503 z m 0,20.527344 c 81.18863,0 146.7832,65.596529 146.7832,146.785159 0,37.50949 -14.0143,71.61624 -37.11132,97.56054 -26.89608,30.21168 -65.99275,49.22461 -109.67188,49.22461 -81.18862,0 -146.785156,-65.59654 -146.785156,-146.78515 0,-81.18863 65.596546,-146.785159 146.785156,-146.785159 z"
id="path1154" />
</g>
<path
style="display:inline;fill:none;stroke:#ededed;stroke-width:9.63968;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dashoffset:0;stroke-opacity:1"
d="m 96.1688,198.27867 c 0,73.04626 59.21569,132.26196 132.26196,132.26196 73.04627,0 132.26196,-59.2157 132.26196,-132.26196 0,-73.04627 -59.21569,-132.261977 -132.26196,-132.261977 -73.04627,0 -132.26196,59.215707 -132.26196,132.261977 z"
id="path4357"
inkscape:connector-curvature="0" />
<path
sodipodi:type="star"
style="color:#000000;overflow:visible;fill:#fdbc41;stop-color:#000000"
id="path1810"
inkscape:flatsided="false"
sodipodi:sides="8"
sodipodi:cx="-173.53415"
sodipodi:cy="-46.257851"
sodipodi:r1="101.43466"
sodipodi:r2="76.075996"
sodipodi:arg1="0.78539816"
sodipodi:arg2="1.1780972"
inkscape:rounded="0.55"
inkscape:randomized="0"
d="m -101.80901,25.467286 c -16.58168,16.581684 -20.9471,-10.414024 -42.61211,-1.440082 -21.66502,8.973943 -5.66298,31.149606 -29.11303,31.149607 -23.45004,10e-7 -7.44801,-22.175662 -29.11302,-31.149604 -21.66502,-8.973944 -26.03043,18.021764 -42.61212,1.44008 -16.58168,-16.5816823 10.41403,-20.9470983 1.44009,-42.612111 -8.97395,-21.665016 -31.14961,-5.662982 -31.14961,-29.113026 0,-23.450041 22.17566,-7.448008 31.1496,-29.113021 8.97395,-21.665016 -18.02176,-26.030429 -1.44008,-42.612119 16.58169,-16.58168 20.9471,10.41403 42.61211,1.44008 21.66502,-8.97394 5.66299,-31.1496 29.11303,-31.1496 23.45004,0 7.44801,22.17566 29.11302,31.1496 21.66502,8.97395 26.03043,-18.02176 42.61212,-1.44008 16.581681,16.58168 -10.41403,20.9471 -1.44008,42.612113 8.973939,21.665015 31.149602,5.662982 31.149603,29.113026 10e-7,23.450041 -22.175663,7.448008 -31.149603,29.113021 -8.97395,21.6650153 18.021763,26.0304305 1.44008,42.612116 z"
transform="matrix(0.81688462,0,0,0.81688462,370.18813,236.06599)" />
<circle
style="color:#000000;overflow:visible;fill:#333333;stroke-width:0.844574;stop-color:#000000"
id="path953"
cx="228.43076"
cy="198.27866"
r="41.555809"
inkscape:export-filename="/home/baldo/projects/ffhh/ffffng/frontend/public/icon-512.png"
inkscape:export-xdpi="591.40002"
inkscape:export-ydpi="591.40002" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View file

@ -0,0 +1,6 @@
{
"icons": [
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
]
}

17
frontend/src/App.vue Normal file
View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import {RouterLink, RouterView} from "vue-router";
import PageHeader from "@/components/PageHeader.vue";
import PageFooter from "@/components/PageFooter.vue";
</script>
<template>
<PageHeader />
<main>
<RouterView />
</main>
<PageFooter />
</template>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import { useVersionStore } from "@/stores/version";
const version = useVersionStore();
function refresh(): void {
version.refresh();
}
refresh();
</script>
<template>
<footer>
{{ version.getVersion }}
</footer>
</template>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
</script>
<template>
<header>
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink> |
<RouterLink to="/admin">Admin</RouterLink>
</nav>
</div>
</header>
</template>
<style lang="scss" scoped></style>

12
frontend/src/main.ts Normal file
View file

@ -0,0 +1,12 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

View file

@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from "vue-router";
import AdminDashboardView from "@/views/AdminDashboardView.vue";
import HomeView from "@/views/HomeView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/admin",
name: "admin",
component: AdminDashboardView,
},
],
});
export default router;

View file

@ -0,0 +1,29 @@
import { defineStore } from "pinia";
import { isStatistics, type Statistics } from "@/types";
import { internalApi } from "@/utils/Api";
interface StatisticsStoreState {
statistics: Statistics | null;
}
export const useStatisticsStore = defineStore({
id: "statistics",
state(): StatisticsStoreState {
return {
statistics: null,
};
},
getters: {
getStatistics(state: StatisticsStoreState): Statistics | null {
return state.statistics;
},
},
actions: {
async refresh(): Promise<void> {
this.statistics = await internalApi.get<Statistics>(
"statistics",
isStatistics
);
},
},
});

View file

@ -0,0 +1,38 @@
import { defineStore } from "pinia";
import { isObject, isVersion, type Version } from "@/types";
import { api } from "@/utils/Api";
interface VersionResponse {
version: Version;
}
function isVersionResponse(arg: unknown): arg is VersionResponse {
return isObject(arg) && isVersion((arg as VersionResponse).version);
}
interface VersionStoreState {
version: Version | null;
}
export const useVersionStore = defineStore({
id: "version",
state(): VersionStoreState {
return {
version: null,
};
},
getters: {
getVersion(state: VersionStoreState): Version | null {
return state.version;
},
},
actions: {
async refresh(): Promise<void> {
const response = await api.get<VersionResponse>(
"version",
isVersionResponse
);
this.version = response.version;
},
},
});

View file

@ -0,0 +1 @@
export * from "./shared";

View file

@ -0,0 +1 @@
../../../server/types/shared.ts

37
frontend/src/utils/Api.ts Normal file
View file

@ -0,0 +1,37 @@
class Api {
private baseURL: string = import.meta.env.BASE_URL;
private apiPrefix = "api/";
constructor(apiPrefix?: string) {
if (apiPrefix) {
this.apiPrefix = apiPrefix;
}
}
private toURL(path: string): string {
return this.baseURL + this.apiPrefix + path;
}
async get<T>(path: string, isT: (arg: unknown) => arg is T): Promise<T> {
const url = this.toURL(path);
const result = await fetch(url);
const json = await result.json();
if (!isT(json)) {
console.log(json);
throw new Error(`API get result has wrong type. ${url} => ${json}`);
}
return json;
}
}
export const api = new Api();
class InternalApi extends Api {
constructor() {
super("internal/api/");
}
}
export const internalApi = new InternalApi();

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { useStatisticsStore } from "@/stores/statistics";
const statistics = useStatisticsStore();
function refresh(): void {
statistics.refresh();
}
refresh();
</script>
<template>
<main>
<div v-if="statistics.getStatistics">
<h1>Nodes</h1>
<div>
Registered: {{ statistics.getStatistics.nodes.registered }}<br />
With VPN-key: {{ statistics.getStatistics.nodes.withVPN }}<br />
With coordinates: {{ statistics.getStatistics.nodes.withCoords }}<br />
Monitoring active: {{ statistics.getStatistics.nodes.monitoring.active }}<br />
Monitoring pending: {{ statistics.getStatistics.nodes.monitoring.pending }}
</div>
<button @click="refresh()">Refresh</button>
</div>
</main>
</template>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
</script>
<template>
Home
</template>
<style lang="scss" scoped></style>

20
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"references": [
{
"path": "./tsconfig.vite-config.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View file

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node", "vitest"]
}
}

View file

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"types": ["node", "jsdom"]
}
}

24
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,24 @@
import { fileURLToPath, URL } from "url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
"/api/": {
target: "http://localhost:8080",
},
"/internal/api/": {
target: "http://localhost:8080",
},
},
},
});

1907
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,5 @@
import {ArrayField, Field, RawJsonField} from "sparkson"
export type Version = string;
// TODO: Replace string types by more specific types like URL, Password, etc.
export class LoggingConfig {

View file

@ -1,5 +1,6 @@
export * from "./config";
export * from "./logger";
export * from "./shared";
// TODO: Token type.
export type Token = string;
@ -48,16 +49,6 @@ export type NodeSecrets = {
monitoringToken?: MonitoringToken,
};
export type NodeStatistics = {
registered: number,
withVPN: number,
withCoords: number,
monitoring: {
active: number,
pending: number
}
};
export type MailId = string;
export type MailData = any;
export type MailType = string;

45
server/types/shared.ts Normal file
View file

@ -0,0 +1,45 @@
// Types shared with the client.
export function isObject(arg: unknown): arg is object {
return arg !== null && typeof arg === "object";
}
export type Version = string;
export function isVersion(arg: unknown): arg is Version {
// Should be good enough for now.
return typeof arg === "string";
}
export type NodeStatistics = {
registered: number;
withVPN: number;
withCoords: number;
monitoring: {
active: number;
pending: number;
};
};
export function isNodeStatistics(arg: unknown): arg is NodeStatistics {
if (!isObject(arg)) {
return false;
}
const stats = arg as NodeStatistics;
return (
typeof stats.registered === "number" &&
typeof stats.withVPN === "number" &&
typeof stats.withCoords === "number" &&
typeof stats.monitoring === "object" &&
typeof stats.monitoring.active === "number" &&
typeof stats.monitoring.pending === "number"
);
}
export interface Statistics {
nodes: NodeStatistics;
}
export function isStatistics(arg: unknown): arg is Statistics {
return isObject(arg) && isNodeStatistics((arg as Statistics).nodes);
}

View file

@ -11,6 +11,7 @@ stdenv.mkDerivation rec {
nasm
nodejs-16_x
rsync
sass
sqlite
yarn
zlib